From 8ec64872542ef5df276ce3bf108a561d5cb4a1f8 Mon Sep 17 00:00:00 2001 From: Markinatorina Date: Sun, 3 May 2026 16:21:34 +0200 Subject: [PATCH 1/2] core>module clean up pt2 --- .../AgentOrchestrationChatContributor.cs | 37 + .../AgentOrchestrationModule.cs | 259 +++- .../AgentOrchestrationPermissionKeys.cs | 13 + ...gentOrchestrationStepDescriptorProvider.cs | 148 +++ .../AgentOrchestrationStepKeys.cs | 72 ++ .../AgentOrchestrationTaskStepExecutor.cs | 207 +++ .../AgentOrchestrationTriggerKeys.cs | 21 + .../EventBusTriggerSource.cs | 73 +- .../Handlers/ScheduledJobEndpoints.cs | 107 ++ .../Services/CronEvaluator.cs | 10 +- .../Services/ScheduledJobService.cs | 98 +- .../Services/ScheduledJobWorker.cs | 175 +++ ...harpClaw.Modules.AgentOrchestration.csproj | 4 + ...ntOrchestrationTriggerAttributeHandlers.cs | 129 ++ .../TaskScripting}/LifecycleTriggerSource.cs | 29 +- .../TaskScripting/TaskChainTriggerSource.cs | 79 ++ .../TaskScriptingParserExtension.cs | 63 + .../TaskScriptingStepDescriptorProvider.cs | 53 + .../TaskScriptingStepExecutor.cs | 246 ++++ .../TaskScripting/TaskScriptingStepKeys.cs | 63 + .../TaskScripting/TaskScriptingTriggerKeys.cs | 42 + .../ComputerUse/ComputerUseModule.cs | 5 +- .../ComputerUse/ComputerUseParserExtension.cs | 6 +- .../Contracts/IShortcutLauncher.cs | 31 + .../Services/ShortcutLauncherService.cs | 38 +- .../ComputerUseTriggerAttributeHandlers.cs | 206 +++ .../Triggers/ComputerUseTriggerKeys.cs | 33 + .../Triggers/DeviceTriggerSource.cs | 2 +- .../Triggers/HotkeyTriggerSource.cs | 2 +- .../ComputerUse/Triggers/IdleTriggerSource.cs | 17 +- .../Triggers/OsShortcutTriggerKeys.cs | 33 + .../Triggers/OsShortcutTriggerSource.cs | 86 ++ .../Triggers/ProcessTriggerSource.cs | 6 +- .../Triggers/WindowFocusTriggerSource.cs | 6 +- .../ContextToolsChatContributor.cs | 20 + .../ContextTools/ContextToolsModule.cs | 5 +- .../ContextToolsPermissionKeys.cs | 19 + .../Services/ContextDataReader.cs | 99 ++ .../Services/ContextToolsService.cs | 4 +- .../DatabaseAccess/DatabaseAccessModule.cs | 4 +- .../DatabaseAccessParserExtension.cs | 22 + .../DatabaseAccessTriggerAttributeHandlers.cs | 57 + .../Triggers/DatabaseAccessTriggerKeys.cs | 17 + .../Triggers/QueryRowsTriggerSource.cs | 8 +- .../Services/EditorBridgeService.cs | 2 +- .../FileChangedTriggerSource.cs | 29 +- .../FilesystemTriggers}/FileWatchEvent.cs | 2 +- .../FilesystemTriggerAttributeHandlers.cs | 39 + .../FilesystemTriggerKeys.cs | 17 + .../FilesystemTriggersModule.cs | 39 + .../FilesystemTriggersParserExtension.cs | 21 + ...harpClaw.Modules.FilesystemTriggers.csproj | 22 + DefaultModules/FilesystemTriggers/module.json | 17 + DefaultModules/Http/HttpModule.cs | 40 + DefaultModules/Http/HttpParserExtension.cs | 21 + .../Http/HttpStepDescriptorProvider.cs | 55 + DefaultModules/Http/HttpStepKeys.cs | 16 + DefaultModules/Http/HttpTaskStepExecutor.cs | 71 ++ .../Http/HttpTriggerAttributeHandlers.cs | 41 + DefaultModules/Http/HttpTriggerKeys.cs | 17 + .../Http/SharpClaw.Modules.Http.csproj | 23 + .../Http}/WebhookTriggerSource.cs | 38 +- DefaultModules/Http/module.json | 17 + .../Metrics/BuiltInMetricProviders.cs | 36 + DefaultModules/Metrics/MetricTriggerKeys.cs | 16 + .../Metrics}/MetricTriggerSource.cs | 27 +- DefaultModules/Metrics/MetricsModule.cs | 44 + .../Metrics/MetricsParserExtension.cs | 21 + .../MetricsTriggerAttributeHandlers.cs | 41 + .../Metrics/SharpClaw.Modules.Metrics.csproj | 22 + DefaultModules/Metrics/module.json | 17 + .../HostProbeTriggerSource.cs | 30 +- .../NetworkTriggers}/NetworkState.cs | 2 +- .../NetworkTriggerAttributeHandlers.cs | 59 + .../NetworkTriggers/NetworkTriggerKeys.cs | 19 + .../NetworkTriggers}/NetworkTriggerSource.cs | 24 +- .../NetworkTriggers/NetworkTriggersModule.cs | 40 + .../NetworkTriggersParserExtension.cs | 21 + .../SharpClaw.Modules.NetworkTriggers.csproj | 22 + DefaultModules/NetworkTriggers/module.json | 17 + .../Google/Clients/GoogleVertexAIApiClient.cs | 1 + .../LlamaSharp/Cli/LocalModelCliCommand.cs | 1 + .../Clients/GroqTranscriptionApiClient.cs | 1 + .../Clients/OpenAiTranscriptionApiClient.cs | 1 + .../SharpClaw.Modules.Transcription.csproj | 1 + .../Cli/CliDispatcher.cs | 186 +-- .../Handlers/ModuleHandlers.cs | 2 +- .../Handlers/ScheduledJobHandlers.cs | 116 -- .../Handlers/TaskHandlers.cs | 50 - SharpClaw.Application.API/Program.cs | 53 +- .../SharpClaw.Application.API.csproj | 7 + .../Webhooks/WebhookRouteRegistry.cs | 9 +- .../Clients/CompletionParameterSpec.cs | 1 + .../Clients/ProviderApiClientFactory.cs | 1 + .../Modules/ExternalModuleHost.cs | 2 + .../Modules/HostAgentJobController.cs | 2 +- .../Modules/HostModuleContractAdapters.cs | 86 +- .../Modules/ModuleConfigStore.cs | 2 +- .../Modules/ModuleEventDispatcher.cs | 5 +- .../Services/AgentActionService.cs | 8 +- .../Services/AgentJobService.cs | 10 +- .../Services/AgentService.cs | 7 +- .../Services/Auth/AuthService.cs | 2 +- .../Services/ChannelService.cs | 4 +- .../Services/ChatProcessingBridge.cs | 55 + .../Services/ChatService.cs | 113 +- .../Services/ContextService.cs | 4 +- .../Services/DefaultResourceSetService.cs | 12 +- .../Services/GlobalFlagEvaluator.cs | 23 + .../Services/HeaderTagProcessor.cs | 55 +- .../Services/HostAgentBridge.cs | 423 +++++++ .../Services/ModelService.cs | 2 +- .../Services/ModuleService.cs | 4 +- .../Services/ProviderCostService.cs | 1 + .../Services/ProviderService.cs | 3 +- .../Services/RoleService.cs | 4 +- .../Services/ScheduledTaskService.cs | 155 --- .../Services/SeedingService.cs | 7 +- .../Services/TaskInstanceLauncher.cs | 35 + .../Services/TaskOrchestrator.cs | 1105 ++--------------- .../Services/TaskPreflightChecker.cs | 1 + .../Services/TaskRuntimeHost.cs | 2 +- .../Services/TaskService.cs | 61 +- .../Services/TaskToolProvider.cs | 20 +- .../Services/TaskTriggerRegistrar.cs | 224 ++-- .../Services/ThreadService.cs | 2 +- .../Services/ToolAwarenessSetService.cs | 2 +- .../Triggers/BuiltInMetricProviders.cs | 63 - .../Services/Triggers/HostQueueMetrics.cs | 38 + .../Triggers/IWebhookRouteRegistrar.cs | 16 - .../Triggers/TaskTriggerHostService.cs | 2 +- .../Triggers/TaskTriggerSourceRegistry.cs | 49 + .../SharpClaw.Application.Core.csproj | 6 +- .../Compilation/TaskScriptCompiler.cs | 3 - .../Models/TaskLoopKind.cs | 11 - .../Models/TaskStepDefinition.cs | 31 +- .../Parsing/TaskScriptParser.cs | 580 +++------ .../Registry/TaskStepRegistry.cs | 276 +--- .../Validation/TaskScriptValidator.cs | 38 +- .../InfrastructureServiceExtensions.cs | 3 + .../Models/Jobs/ScheduledJobDB.cs | 70 -- .../Persistence/JSON/JsonFileOptions.cs | 6 +- .../JSON/JsonFilePersistenceService.cs | 3 +- .../Persistence/SharpClawDbContext.cs | 46 +- ...harpClaw.Application.Infrastructure.csproj | 1 + .../Chat/IChatProcessingBridge.cs | 37 + .../Chat/IChatProcessingContributor.cs | 36 + .../DTOs/Providers/ProviderDtos.cs | 2 +- .../Entities/Core/Access/GlobalFlagDB.cs | 4 +- .../Entities/Core/Access/ResourceAccessDB.cs | 4 +- SharpClaw.Contracts/Entities/Core/AgentDB.cs | 8 +- .../ClearanceAgentWhitelistEntryDB.cs | 4 +- .../ClearanceUserWhitelistEntryDB.cs | 4 +- .../Core/Clearance/PermissionSetDB.cs | 4 +- .../Entities/Core/Clearance/RoleDB.cs | 4 +- .../Entities/Core/Context/ChannelContextDB.cs | 8 +- .../Entities/Core/Context/ChannelDB.cs | 10 +- .../Entities/Core/Context/ChatThreadDB.cs | 4 +- .../Core/Context/DefaultResourceEntryDB.cs | 2 +- .../Core/Context/DefaultResourceSetDB.cs | 2 +- .../Entities/Core/Jobs/AgentJobDB.cs | 6 +- .../Entities/Core/Jobs/AgentJobLogEntryDB.cs | 2 +- .../Entities/Core/Messages/ChatMessageDB.cs | 4 +- SharpClaw.Contracts/Entities/Core/ModelDB.cs | 2 +- .../Entities/Core/ModuleConfigEntryDB.cs | 2 +- .../Entities/Core/ModuleStateDB.cs | 2 +- .../Entities/Core/ProviderDB.cs | 5 +- .../Entities/Core/RefreshTokenDB.cs | 2 +- .../Entities/Core/Tasks/TaskDefinitionDB.cs | 2 +- .../Entities/Core/Tasks/TaskExecutionLogDB.cs | 2 +- .../Entities/Core/Tasks/TaskInstanceDB.cs | 4 +- .../Entities/Core/Tasks/TaskOutputEntryDB.cs | 2 +- .../Core/Tasks/TaskTriggerBindingDB.cs | 2 +- .../Entities/Core/ToolAwarenessSetDB.cs | 2 +- SharpClaw.Contracts/Entities/Core/UserDB.cs | 4 +- .../Enums/DatabaseAccessLevel.cs | 7 - .../Models/WellKnownCapabilityKeys.cs | 26 +- .../Modules/IContextDataReader.cs | 42 - .../Modules/ISharpClawEventSinkRegistry.cs | 17 + .../Modules/ModuleEntityModelBuilder.cs | 0 SharpClaw.Contracts/Modules/ThreadSummary.cs | 13 + .../Permissions/IGlobalFlagEvaluator.cs | 26 + .../Persistence/ISharpClawDataContext.cs | 31 + .../Services/IScheduledJobService.cs | 43 + .../SharpClaw.Contracts.csproj | 1 - SharpClaw.Contracts/Tasks/IHostAgentBridge.cs | 89 ++ .../Tasks/IHostQueueMetrics.cs | 20 + .../Tasks/IShortcutLauncherService.cs | 22 - .../Tasks/ITaskEventHandler.cs | 7 +- .../Tasks/ITaskInstanceLauncher.cs | 20 + .../Tasks/ITaskMetricProvider.cs | 13 +- .../Tasks/ITaskParserModuleExtension.cs | 34 +- .../Tasks/ITaskStepDescriptorProvider.cs | 19 + .../Tasks/ITaskStepExecutionContext.cs | 56 + .../Tasks/ITaskStepInvocation.cs | 48 + .../Tasks/ITaskStepInvocationExecutor.cs | 20 + SharpClaw.Contracts/Tasks/ITaskToolCatalog.cs | 25 + .../Tasks/ITaskTriggerAttributeHandler.cs | 26 + .../Tasks/ITaskTriggerBindingSideEffect.cs | 50 + .../Tasks/ITaskTriggerSource.cs | 65 + .../Tasks/ITaskTriggerSourceRegistry.cs | 33 + .../Tasks/IWebhookRouteRegistrar.cs | 22 + .../Tasks/IWebhookTriggerHost.cs | 28 + .../Tasks/TaskDefinitionDescriptor.cs | 15 + .../Tasks/TaskParserPrimitives.cs | 55 + .../Tasks/TaskPermissionKeys.cs | 25 - .../Tasks}/TaskStepDescriptor.cs | 27 +- SharpClaw.Contracts/Tasks/TaskStepResult.cs | 19 + .../Tasks/TaskTriggerAttributeContext.cs | 86 ++ .../TaskTriggerAttributeDiagnosticSeverity.cs | 20 + .../Tasks/TaskTriggerBindingDescriptor.cs | 18 + .../Tasks/TaskTriggerDefinition.cs | 76 +- SharpClaw.Contracts/Tasks/TaskTriggerKind.cs | 23 - .../Tasks/TriggerConcurrency.cs | 16 - .../Tasks/WellKnownTaskStepKeys.cs | 113 -- .../Tasks/WellKnownTriggerKeys.cs | 22 - .../WellKnownProviderKeys.cs | 2 +- SharpClaw.Tests/Cli/ChannelCliCommandTests.cs | 7 +- .../Persistence/ColdEntityIndexTests.cs | 2 +- .../Persistence/ColdEntityStoreTests.cs | 2 +- .../ColdIndexMaintenanceServiceTests.cs | 4 +- .../JsonFilePersistenceServiceTests.cs | 2 +- .../PersistenceEntityResolverTests.cs | 6 +- .../Persistence/QuarantineServiceTests.cs | 2 +- .../LlamaSharpParameterParityTests.cs | 1 + SharpClaw.Tests/SharpClaw.Tests.csproj | 8 + SharpClaw.Tests/TaskStepRegistrySetup.cs | 50 + SharpClaw.Tests/Tasks/CronSchedulerTests.cs | 441 ------- .../Tasks/ScheduledJobCrudTests.cs | 369 ------ .../Tasks/TaskScriptCompilerTests.cs | 20 +- .../Tasks/TaskScriptParserTests.cs | 22 +- .../Tasks/TaskScriptSemanticsTests.cs | 8 +- .../Tasks/TaskScriptValidatorTests.cs | 52 +- .../Tasks/TaskStepKeyAssignmentTests.cs | 65 +- .../Tasks/TaskTriggerAttributeParserTests.cs | 436 +++---- .../Tasks/WebhookTriggerSourceTests.cs | 26 +- SharpClaw.slnx | 5 + all-refs.txt | 487 ++++++++ docs/Tasks-documentation.md | 37 +- docs/guides/Module-Creation-Guide.md | 12 +- typed-prop-refs.txt | 15 + typed-props-search.txt | 126 ++ writers.txt | 87 ++ 243 files changed, 6826 insertions(+), 4714 deletions(-) create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs create mode 100644 DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/AgentOrchestration}/EventBusTriggerSource.cs (60%) create mode 100644 DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs rename {SharpClaw.Application.Core => DefaultModules/AgentOrchestration}/Services/CronEvaluator.cs (81%) rename {SharpClaw.Application.Core => DefaultModules/AgentOrchestration}/Services/ScheduledJobService.cs (72%) create mode 100644 DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/AgentOrchestration/TaskScripting}/LifecycleTriggerSource.cs (59%) create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepKeys.cs create mode 100644 DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingTriggerKeys.cs create mode 100644 DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs create mode 100644 DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs create mode 100644 DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs create mode 100644 DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs create mode 100644 DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs create mode 100644 DefaultModules/ContextTools/ContextToolsChatContributor.cs create mode 100644 DefaultModules/ContextTools/ContextToolsPermissionKeys.cs create mode 100644 DefaultModules/ContextTools/Services/ContextDataReader.cs create mode 100644 DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs create mode 100644 DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs create mode 100644 DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/FilesystemTriggers}/FileChangedTriggerSource.cs (70%) rename {SharpClaw.Contracts/Tasks => DefaultModules/FilesystemTriggers}/FileWatchEvent.cs (84%) create mode 100644 DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs create mode 100644 DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs create mode 100644 DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs create mode 100644 DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs create mode 100644 DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj create mode 100644 DefaultModules/FilesystemTriggers/module.json create mode 100644 DefaultModules/Http/HttpModule.cs create mode 100644 DefaultModules/Http/HttpParserExtension.cs create mode 100644 DefaultModules/Http/HttpStepDescriptorProvider.cs create mode 100644 DefaultModules/Http/HttpStepKeys.cs create mode 100644 DefaultModules/Http/HttpTaskStepExecutor.cs create mode 100644 DefaultModules/Http/HttpTriggerAttributeHandlers.cs create mode 100644 DefaultModules/Http/HttpTriggerKeys.cs create mode 100644 DefaultModules/Http/SharpClaw.Modules.Http.csproj rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/Http}/WebhookTriggerSource.cs (83%) create mode 100644 DefaultModules/Http/module.json create mode 100644 DefaultModules/Metrics/BuiltInMetricProviders.cs create mode 100644 DefaultModules/Metrics/MetricTriggerKeys.cs rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/Metrics}/MetricTriggerSource.cs (74%) create mode 100644 DefaultModules/Metrics/MetricsModule.cs create mode 100644 DefaultModules/Metrics/MetricsParserExtension.cs create mode 100644 DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs create mode 100644 DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj create mode 100644 DefaultModules/Metrics/module.json rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/NetworkTriggers}/HostProbeTriggerSource.cs (74%) rename {SharpClaw.Contracts/Tasks => DefaultModules/NetworkTriggers}/NetworkState.cs (77%) create mode 100644 DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs create mode 100644 DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs rename {SharpClaw.Application.Core/Services/Triggers/Sources => DefaultModules/NetworkTriggers}/NetworkTriggerSource.cs (73%) create mode 100644 DefaultModules/NetworkTriggers/NetworkTriggersModule.cs create mode 100644 DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs create mode 100644 DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj create mode 100644 DefaultModules/NetworkTriggers/module.json delete mode 100644 SharpClaw.Application.API/Handlers/ScheduledJobHandlers.cs create mode 100644 SharpClaw.Application.Core/Services/ChatProcessingBridge.cs create mode 100644 SharpClaw.Application.Core/Services/GlobalFlagEvaluator.cs create mode 100644 SharpClaw.Application.Core/Services/HostAgentBridge.cs delete mode 100644 SharpClaw.Application.Core/Services/ScheduledTaskService.cs create mode 100644 SharpClaw.Application.Core/Services/TaskInstanceLauncher.cs delete mode 100644 SharpClaw.Application.Core/Services/Triggers/BuiltInMetricProviders.cs create mode 100644 SharpClaw.Application.Core/Services/Triggers/HostQueueMetrics.cs delete mode 100644 SharpClaw.Application.Core/Services/Triggers/IWebhookRouteRegistrar.cs create mode 100644 SharpClaw.Application.Core/Services/Triggers/TaskTriggerSourceRegistry.cs delete mode 100644 SharpClaw.Application.Infrastructure.Tasks/Models/TaskLoopKind.cs delete mode 100644 SharpClaw.Application.Infrastructure/Models/Jobs/ScheduledJobDB.cs create mode 100644 SharpClaw.Contracts/Chat/IChatProcessingBridge.cs create mode 100644 SharpClaw.Contracts/Chat/IChatProcessingContributor.cs delete mode 100644 SharpClaw.Contracts/Enums/DatabaseAccessLevel.cs delete mode 100644 SharpClaw.Contracts/Modules/IContextDataReader.cs create mode 100644 SharpClaw.Contracts/Modules/ISharpClawEventSinkRegistry.cs delete mode 100644 SharpClaw.Contracts/Modules/ModuleEntityModelBuilder.cs create mode 100644 SharpClaw.Contracts/Modules/ThreadSummary.cs create mode 100644 SharpClaw.Contracts/Permissions/IGlobalFlagEvaluator.cs create mode 100644 SharpClaw.Contracts/Services/IScheduledJobService.cs create mode 100644 SharpClaw.Contracts/Tasks/IHostAgentBridge.cs create mode 100644 SharpClaw.Contracts/Tasks/IHostQueueMetrics.cs delete mode 100644 SharpClaw.Contracts/Tasks/IShortcutLauncherService.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskInstanceLauncher.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskStepDescriptorProvider.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskStepInvocation.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskStepInvocationExecutor.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskToolCatalog.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskTriggerAttributeHandler.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskTriggerBindingSideEffect.cs create mode 100644 SharpClaw.Contracts/Tasks/ITaskTriggerSourceRegistry.cs create mode 100644 SharpClaw.Contracts/Tasks/IWebhookRouteRegistrar.cs create mode 100644 SharpClaw.Contracts/Tasks/IWebhookTriggerHost.cs create mode 100644 SharpClaw.Contracts/Tasks/TaskDefinitionDescriptor.cs create mode 100644 SharpClaw.Contracts/Tasks/TaskParserPrimitives.cs delete mode 100644 SharpClaw.Contracts/Tasks/TaskPermissionKeys.cs rename {SharpClaw.Application.Infrastructure.Tasks/Registry => SharpClaw.Contracts/Tasks}/TaskStepDescriptor.cs (64%) create mode 100644 SharpClaw.Contracts/Tasks/TaskStepResult.cs create mode 100644 SharpClaw.Contracts/Tasks/TaskTriggerAttributeContext.cs create mode 100644 SharpClaw.Contracts/Tasks/TaskTriggerAttributeDiagnosticSeverity.cs create mode 100644 SharpClaw.Contracts/Tasks/TaskTriggerBindingDescriptor.cs delete mode 100644 SharpClaw.Contracts/Tasks/TaskTriggerKind.cs delete mode 100644 SharpClaw.Contracts/Tasks/TriggerConcurrency.cs delete mode 100644 SharpClaw.Contracts/Tasks/WellKnownTaskStepKeys.cs delete mode 100644 SharpClaw.Contracts/Tasks/WellKnownTriggerKeys.cs rename {SharpClaw.Contracts/Providers => SharpClaw.Providers.Common}/WellKnownProviderKeys.cs (97%) create mode 100644 SharpClaw.Tests/TaskStepRegistrySetup.cs delete mode 100644 SharpClaw.Tests/Tasks/CronSchedulerTests.cs delete mode 100644 SharpClaw.Tests/Tasks/ScheduledJobCrudTests.cs create mode 100644 all-refs.txt create mode 100644 typed-prop-refs.txt create mode 100644 typed-props-search.txt create mode 100644 writers.txt diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs new file mode 100644 index 00000000..87fde322 --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs @@ -0,0 +1,37 @@ +using SharpClaw.Contracts.Chat; +using SharpClaw.Contracts.Permissions; +using SharpClaw.Contracts.Providers; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Module-owned that surfaces +/// active task definitions as agent tools when the agent has the +/// module-owned +/// flag granted. +/// +/// The flag constant is referenced directly from the module's own +/// permission-key class — no typed accessor shim. The data path goes +/// through the narrow contract so the +/// module does not need to depend on TaskToolProvider, +/// SharpClawDbContext, or any infrastructure type. The clearance +/// pipeline is invoked through . +/// +/// +internal sealed class AgentOrchestrationChatContributor( + IGlobalFlagEvaluator flagEvaluator, + ITaskToolCatalog taskTools) : IChatProcessingContributor +{ + public async Task> GetExtraToolsAsync( + Guid agentId, CancellationToken ct = default) + { + var approved = await flagEvaluator.IsApprovedAsync( + AgentOrchestrationPermissionKeys.CanInvokeTasksAsTool, agentId, ct); + + if (!approved) + return []; + + return await taskTools.GetToolDefinitionsAsync(ct); + } +} diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs index 7ee8e0e1..1899a875 100644 --- a/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs @@ -5,8 +5,12 @@ using Microsoft.EntityFrameworkCore; +using SharpClaw.Contracts.Chat; +using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Persistence; +using SharpClaw.Contracts.Services; +using SharpClaw.Contracts.Tasks; using SharpClaw.Modules.AgentOrchestration.Services; namespace SharpClaw.Modules.AgentOrchestration; @@ -15,12 +19,21 @@ namespace SharpClaw.Modules.AgentOrchestration; /// Default module: agent lifecycle (create sub-agent, manage agent), /// task editing, and skill access. All tools flow through the job pipeline. /// -public sealed class AgentOrchestrationModule : ISharpClawModule +public sealed class AgentOrchestrationModule : ISharpClawModule, ITaskParserAware { - public string Id => "sharpclaw_agent_orchestration"; + public const string ModuleIdValue = "sharpclaw_agent_orchestration"; + + public string Id => ModuleIdValue; public string DisplayName => "Agent Orchestration"; public string ToolPrefix => "ao"; + /// + /// Parser extension contributed by the merged Task Scripting subsystem + /// (folded into Agent Orchestration). Registers the OnTimer + /// event-handler name and the scripting-language primitives. + /// + public ITaskParserModuleExtension ParserExtension => TaskScriptingParserExtension.Instance; + // ═══════════════════════════════════════════════════════════════ // DI Registration // ═══════════════════════════════════════════════════════════════ @@ -30,6 +43,42 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(sp => sp.GetRequiredService() .CreateDbContext()); services.TryAddScoped(); + services.AddScoped(); + + // Module-owned chat contributor: surfaces task definitions as agent + // tools when the agent has CanInvokeTasksAsTool. The flag key is read + // directly from AgentOrchestrationPermissionKeys; the policy decision + // and the data fetch both live in the module. + services.AddScoped(); + + // Event-bus triggers (Event / TaskCompleted / TaskFailed) — moved here + // from core by the trigger-extraction plan. The same instance is + // exposed both as a trigger source and as a host event sink. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + // ── Merged from sharpclaw_task_scripting ─────────────────── + // Task scripting primitives (declare/assign/conditional/loop/return/ + // event_handler/evaluate) and runtime control (delay/wait_until_stopped/log). + services.AddScoped(); + + // Lifecycle triggers (Startup / Shutdown). + services.AddSingleton(); + + // Task-chain triggers (TaskCompleted / TaskFailed). Same instance is + // exposed as a trigger source and as a host event sink so it observes + // orchestrator completion events. + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + // ── Scheduled jobs (relocated from core) ─────────────────── + // Module owns the scheduling logic; the host only exposes + // ITaskInstanceLauncher to actually start a task instance. + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddSingleton(); } // ═══════════════════════════════════════════════════════════════ @@ -105,6 +154,26 @@ public IReadOnlyList GetResourceTypeDescriptors() public IReadOnlyList GetCliCommands() => [ + new( + Name: "schedule", + Aliases: [], + Scope: ModuleCliScope.TopLevel, + Description: "Manage cron scheduled jobs", + UsageLines: + [ + "schedule list List all scheduled jobs", + "schedule get Show a scheduled job", + "schedule create --cron [--timezone ] [--name ]", + " Create a cron scheduled job", + "schedule update --cron [--timezone ]", + " Update cron expression / timezone", + "schedule pause Pause a scheduled job", + "schedule resume Resume a paused job", + "schedule delete Delete a scheduled job", + "schedule preview [--timezone ] [--count N]", + " Preview next occurrences of a cron expression", + ], + Handler: HandleScheduleCommandAsync), new( Name: "aotask", Aliases: ["aot"], @@ -135,6 +204,166 @@ public IReadOnlyList GetCliCommands() => Handler: HandleResourceAoSkillCommandAsync), ]; + private static async Task HandleScheduleCommandAsync( + string[] args, IServiceProvider sp, CancellationToken ct) + { + // args[0] = "schedule" (top-level), args[1] = sub-command. + // When forwarded from the legacy "task schedule …" alias the host + // dispatcher rewrites args so args[0] is still "schedule". + var ids = sp.GetRequiredService(); + var svc = sp.GetRequiredService(); + + if (args.Length < 2) + { + PrintScheduleUsage(); + return; + } + + var sub = args[1].ToLowerInvariant(); + switch (sub) + { + case "list": + ids.PrintJson(await svc.ListAsync(ct)); + break; + + case "get" when args.Length >= 3: + { + var job = await svc.GetByIdAsync(ids.Resolve(args[2]), ct); + if (job is not null) ids.PrintJson(job); + else Console.Error.WriteLine("Not found."); + break; + } + case "get": + Console.Error.WriteLine("schedule get "); + break; + + case "create": + { + var flags = ParseFlags(args, 2); + if (!flags.TryGetValue("cron", out var cronExpr) || string.IsNullOrWhiteSpace(cronExpr)) + { + Console.Error.WriteLine("schedule create --cron [--timezone ] [--name ]"); + break; + } + + Guid? taskId = args.Length >= 3 && Guid.TryParse(args[2], out var tid) ? tid : null; + flags.TryGetValue("timezone", out var tz); + flags.TryGetValue("name", out var name); + + try + { + var result = await svc.CreateAsync(new CreateScheduledJobRequest( + Name: name ?? cronExpr, + TaskDefinitionId: taskId, + CronExpression: cronExpr, + CronTimezone: tz), ct); + ids.PrintJson(result); + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine(ex.Message); + } + break; + } + + case "update" when args.Length >= 3: + { + var jobId = ids.Resolve(args[2]); + var flags = ParseFlags(args, 3); + flags.TryGetValue("cron", out var cronExpr); + flags.TryGetValue("timezone", out var tz); + + try + { + var result = await svc.UpdateAsync(jobId, new UpdateScheduledJobRequest( + CronExpression: cronExpr, + CronTimezone: tz), ct); + if (result is not null) ids.PrintJson(result); + else Console.Error.WriteLine("Not found."); + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine(ex.Message); + } + break; + } + case "update": + Console.Error.WriteLine("schedule update --cron [--timezone ]"); + break; + + case "pause" when args.Length >= 3: + { + var result = await svc.PauseAsync(ids.Resolve(args[2]), ct); + if (result is not null) ids.PrintJson(result); + else Console.Error.WriteLine("Not found."); + break; + } + case "pause": + Console.Error.WriteLine("schedule pause "); + break; + + case "resume" when args.Length >= 3: + { + var result = await svc.ResumeAsync(ids.Resolve(args[2]), ct); + if (result is not null) ids.PrintJson(result); + else Console.Error.WriteLine("Not found."); + break; + } + case "resume": + Console.Error.WriteLine("schedule resume "); + break; + + case "delete" when args.Length >= 3: + { + var ok = await svc.DeleteAsync(ids.Resolve(args[2]), ct); + Console.WriteLine(ok ? "Done." : "Not found."); + break; + } + case "delete": + Console.Error.WriteLine("schedule delete "); + break; + + case "preview" when args.Length >= 3: + { + var expr = args[2]; + var flags = ParseFlags(args, 3); + flags.TryGetValue("timezone", out var tz); + var count = flags.TryGetValue("count", out var cStr) && int.TryParse(cStr, out var c) ? c : 10; + count = count <= 0 ? 10 : Math.Min(count, 100); + try + { + ids.PrintJson(svc.PreviewExpression(expr, tz, count)); + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine(ex.Message); + } + break; + } + case "preview": + Console.Error.WriteLine("schedule preview [--timezone ] [--count N]"); + break; + + default: + Console.Error.WriteLine($"Unknown command: schedule {sub}"); + PrintScheduleUsage(); + break; + } + } + + private static void PrintScheduleUsage() + { + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" schedule list List all scheduled jobs"); + Console.Error.WriteLine(" schedule get Show a scheduled job"); + Console.Error.WriteLine(" schedule create --cron [--timezone ] [--name ]"); + Console.Error.WriteLine(" schedule update --cron [--timezone ]"); + Console.Error.WriteLine(" schedule pause Pause a scheduled job"); + Console.Error.WriteLine(" schedule resume Resume a paused job"); + Console.Error.WriteLine(" schedule delete Delete a scheduled job"); + Console.Error.WriteLine(" schedule preview [--timezone ] [--count N]"); + } + private static async Task HandleResourceAoTaskCommandAsync( string[] args, IServiceProvider sp, CancellationToken ct) { @@ -461,6 +690,8 @@ public IReadOnlyList GetGlobalFlagDescriptors() => new("CanCreateSubAgents", "Create Sub-Agents", "Create sub-agents with permissions ≤ the creator's.", "CreateSubAgentAsync"), new("CanEditAgentHeader", "Edit Agent Header", "Edit the custom chat header of specific agents.", "CanEditAgentHeaderAsync"), new("CanEditChannelHeader", "Edit Channel Header", "Edit the custom chat header of specific channels.", "CanEditChannelHeaderAsync"), + new(AgentOrchestrationPermissionKeys.CanInvokeTasksAsTool, "Invoke Tasks As Tool", + "Expose active task definitions in the agent tool list.", "InvokeTaskAsToolAsync"), ]; // ═══════════════════════════════════════════════════════════════ @@ -580,10 +811,30 @@ public async Task ExecuteToolAsync( // Lifecycle // ═══════════════════════════════════════════════════════════════ + private ScheduledJobWorker? _scheduledJobWorker; + public Task InitializeAsync(IServiceProvider services, CancellationToken ct) - => Task.CompletedTask; + { + _scheduledJobWorker = services.GetService(); + _scheduledJobWorker?.Start(); + return Task.CompletedTask; + } + + public async Task ShutdownAsync() + { + if (_scheduledJobWorker is not null) + await _scheduledJobWorker.StopAsync(); + } - public Task ShutdownAsync() => Task.CompletedTask; + // ═══════════════════════════════════════════════════════════════ + // Endpoint Mapping + // ═══════════════════════════════════════════════════════════════ + + public void MapEndpoints(object app) + { + var endpoints = (Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)app; + Handlers.ScheduledJobEndpoints.MapScheduledJobEndpoints(endpoints); + } // ═══════════════════════════════════════════════════════════════ // Schema builders diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs new file mode 100644 index 00000000..93a4008d --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs @@ -0,0 +1,13 @@ +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Module-owned global-flag keys for the agent-orchestration module. +/// +public static class AgentOrchestrationPermissionKeys +{ + /// + /// Grants permission to invoke task definitions as agent tools. + /// Agents with this flag see active task definitions in their tool list. + /// + public const string CanInvokeTasksAsTool = "CanInvokeTasksAsTool"; +} diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs new file mode 100644 index 00000000..f5d5929c --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs @@ -0,0 +1,148 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Contributes chat/output and entity provisioning step descriptors owned by +/// the agent orchestration module to the central task step registry. +/// +public sealed class AgentOrchestrationStepDescriptorProvider : ITaskStepDescriptorProvider +{ + public string ModuleId => "sharpclaw_agent_orchestration"; + + public IReadOnlyList Descriptors { get; } = Build(); + + private static TaskStepDescriptor[] Build() + { + const string owner = "sharpclaw_agent_orchestration"; + return + [ + // ── Agent interaction ──────────────────────────────────── + new TaskStepDescriptor + { + MethodName = "Chat", + StepKey = AgentOrchestrationStepKeys.Chat, + OwnerId = owner, + ExpressionArgIndex = 1, + }, + new TaskStepDescriptor + { + MethodName = "ChatStream", + StepKey = AgentOrchestrationStepKeys.ChatStream, + OwnerId = owner, + ExpressionArgIndex = 1, + }, + new TaskStepDescriptor + { + MethodName = "ChatToThread", + StepKey = AgentOrchestrationStepKeys.ChatToThread, + OwnerId = owner, + ExpressionArgIndex = 1, + }, + + // ── Output ────────────────────────────────────────────── + new TaskStepDescriptor + { + MethodName = "Emit", + StepKey = AgentOrchestrationStepKeys.Emit, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "ParseResponse", + StepKey = AgentOrchestrationStepKeys.ParseResponse, + OwnerId = owner, + CapturesGenericType = true, + }, + + // ── Entity lookup / creation ──────────────────────────── + new TaskStepDescriptor + { + MethodName = "FindModel", + StepKey = AgentOrchestrationStepKeys.FindModel, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "FindProvider", + StepKey = AgentOrchestrationStepKeys.FindProvider, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "FindAgent", + StepKey = AgentOrchestrationStepKeys.FindAgent, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "CreateAgent", + StepKey = AgentOrchestrationStepKeys.CreateAgent, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "CreateThread", + StepKey = AgentOrchestrationStepKeys.CreateThread, + OwnerId = owner, + FirstArgIsExpression = true, + }, + + // ── Roles / permissions / channels ────────────────────── + new TaskStepDescriptor + { + MethodName = "CreateRole", + StepKey = AgentOrchestrationStepKeys.CreateRole, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "FindRole", + StepKey = AgentOrchestrationStepKeys.FindRole, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "SetRolePermissions", + StepKey = AgentOrchestrationStepKeys.SetRolePermissions, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "AssignRole", + StepKey = AgentOrchestrationStepKeys.AssignRole, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "CreateChannel", + StepKey = AgentOrchestrationStepKeys.CreateChannel, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "FindChannel", + StepKey = AgentOrchestrationStepKeys.FindChannel, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "AddAllowedAgent", + StepKey = AgentOrchestrationStepKeys.AddAllowedAgent, + OwnerId = owner, + FirstArgIsExpression = true, + }, + ]; + } +} diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs new file mode 100644 index 00000000..8b9cd2fb --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs @@ -0,0 +1,72 @@ +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Stable well-known task-step keys owned by the Agent Orchestration module: +/// chat/output primitives and entity lookup/provisioning operations. +/// +/// IMPORTANT: The literal string values intentionally match the legacy +/// core.* values so existing serialized task scripts continue to parse. +/// Only the C# location of the constants changes; the wire format does not. +/// +/// +public static class AgentOrchestrationStepKeys +{ + // ── Agent interaction ───────────────────────────────────────────────────── + + /// Send a message to an agent and await the full response. + public const string Chat = "core.chat"; + + /// Send a message to an agent and stream the response. + public const string ChatStream = "core.chat_stream"; + + /// Send a chat message into a specific thread. + public const string ChatToThread = "core.chat_to_thread"; + + // ── Output ──────────────────────────────────────────────────────────────── + + /// Push a result object to SSE / WebSocket listeners. + public const string Emit = "core.emit"; + + /// Parse an agent text response into a typed data object. + public const string ParseResponse = "core.parse_response"; + + // ── Entity lookup / creation ────────────────────────────────────────────── + + /// Find a model by name or custom ID. + public const string FindModel = "core.find_model"; + + /// Find a provider by name or custom ID. + public const string FindProvider = "core.find_provider"; + + /// Find an agent by name or custom ID. + public const string FindAgent = "core.find_agent"; + + /// Create a new agent. + public const string CreateAgent = "core.create_agent"; + + /// Create a new thread in a channel. + public const string CreateThread = "core.create_thread"; + + // ── Role / permission / channel provisioning ────────────────────────────── + + /// Create a new role (upsert by name). + public const string CreateRole = "core.create_role"; + + /// Find a role by name or custom ID. + public const string FindRole = "core.find_role"; + + /// Set the permission flags on an existing role. + public const string SetRolePermissions = "core.set_role_permissions"; + + /// Assign a role to an agent. + public const string AssignRole = "core.assign_role"; + + /// Create a new channel (upsert by custom ID). + public const string CreateChannel = "core.create_channel"; + + /// Find a channel by title or custom ID. + public const string FindChannel = "core.find_channel"; + + /// Add an agent to a channel's allowed agents list (idempotent). + public const string AddAllowedAgent = "core.add_allowed_agent"; +} diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs new file mode 100644 index 00000000..7aef8beb --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs @@ -0,0 +1,207 @@ +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Module-side executor for chat / output / provisioning task steps owned by +/// Agent Orchestration. All real work is delegated to +/// , an application-host service +/// resolved from the running task's +/// scope. This keeps the module free of any direct dependency on Core / EF +/// types while still owning the step semantics. +/// +public sealed class AgentOrchestrationTaskStepExecutor : ITaskStepExecutorExtension +{ + public string ModuleId => "sharpclaw_agent_orchestration"; + + public bool CanExecute(string moduleStepKey) => moduleStepKey switch + { + AgentOrchestrationStepKeys.Emit + or AgentOrchestrationStepKeys.Chat + or AgentOrchestrationStepKeys.ChatStream + or AgentOrchestrationStepKeys.ChatToThread + or AgentOrchestrationStepKeys.ParseResponse + or AgentOrchestrationStepKeys.FindModel + or AgentOrchestrationStepKeys.FindProvider + or AgentOrchestrationStepKeys.FindAgent + or AgentOrchestrationStepKeys.FindRole + or AgentOrchestrationStepKeys.FindChannel + or AgentOrchestrationStepKeys.CreateAgent + or AgentOrchestrationStepKeys.CreateThread + or AgentOrchestrationStepKeys.CreateRole + or AgentOrchestrationStepKeys.SetRolePermissions + or AgentOrchestrationStepKeys.AssignRole + or AgentOrchestrationStepKeys.CreateChannel + or AgentOrchestrationStepKeys.AddAllowedAgent => true, + _ => false, + }; + + public async Task ExecuteAsync( + string moduleStepKey, + ITaskStepExecutionContext context, + IReadOnlyList? arguments, + string? expression, + string? resultVariable) + { + if (moduleStepKey == AgentOrchestrationStepKeys.Emit) + { + await context.WriteOutputAsync(expression); + return true; + } + + var bridge = context.Services.GetRequiredService(); + var ct = context.CancellationToken; + var taskName = string.Empty; + + switch (moduleStepKey) + { + case AgentOrchestrationStepKeys.Chat: + { + var agentId = ParseGuidArg(arguments, 0); + var content = await bridge.ChatAsync( + context.InstanceId, taskName, expression ?? string.Empty, agentId, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = content; + break; + } + case AgentOrchestrationStepKeys.ChatStream: + { + var agentId = ParseGuidArg(arguments, 0); + var content = await bridge.ChatStreamAsync( + context.InstanceId, taskName, expression ?? string.Empty, agentId, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = content; + break; + } + case AgentOrchestrationStepKeys.ChatToThread: + { + if (arguments is null || arguments.Count < 1 || !Guid.TryParse(arguments[0], out var threadId)) + throw new InvalidOperationException( + "ChatToThread requires a thread ID as first argument."); + var agentId = ParseGuidArg(arguments, 2); + var content = await bridge.ChatToThreadAsync( + context.InstanceId, taskName, threadId, expression ?? string.Empty, agentId, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = content; + break; + } + case AgentOrchestrationStepKeys.ParseResponse: + { + var typeName = arguments is { Count: > 0 } ? arguments[0] : null; + var parsed = bridge.ParseStructuredResponse( + context.InstanceId, expression ?? string.Empty, typeName); + if (resultVariable is not null) + context.Variables[resultVariable] = parsed; + break; + } + case AgentOrchestrationStepKeys.FindModel: + StoreFindResult(resultVariable, + await bridge.FindModelAsync(expression ?? string.Empty, ct), context); + break; + case AgentOrchestrationStepKeys.FindProvider: + StoreFindResult(resultVariable, + await bridge.FindProviderAsync(expression ?? string.Empty, ct), context); + break; + case AgentOrchestrationStepKeys.FindAgent: + StoreFindResult(resultVariable, + await bridge.FindAgentAsync(expression ?? string.Empty, ct), context); + break; + case AgentOrchestrationStepKeys.FindRole: + StoreFindResult(resultVariable, + await bridge.FindRoleAsync(expression ?? string.Empty, ct), context); + break; + case AgentOrchestrationStepKeys.FindChannel: + StoreFindResult(resultVariable, + await bridge.FindChannelAsync(expression ?? string.Empty, ct), context); + break; + case AgentOrchestrationStepKeys.CreateAgent: + { + var name = arguments is { Count: > 0 } ? arguments[0] : "Task Agent"; + var modelId = ParseGuidArg(arguments, 1) ?? Guid.Empty; + var systemPrompt = arguments is { Count: > 2 } ? arguments[2] : null; + var customId = arguments is { Count: > 3 } ? arguments[3] : null; + var id = await bridge.CreateAgentAsync( + context.InstanceId, name, modelId, systemPrompt, customId, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = id.ToString(); + break; + } + case AgentOrchestrationStepKeys.CreateThread: + { + var channelId = ParseGuidArg(arguments, 0); + var threadName = arguments is { Count: > 1 } ? arguments[1] : null; + var id = await bridge.CreateThreadAsync(context.InstanceId, channelId, threadName, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = id.ToString(); + break; + } + case AgentOrchestrationStepKeys.CreateRole: + { + var id = await bridge.CreateRoleAsync(expression ?? string.Empty, ct); + if (resultVariable is not null) + context.Variables[resultVariable] = id.ToString(); + await context.AppendLogAsync($"CreateRole '{expression}' → {id}"); + break; + } + case AgentOrchestrationStepKeys.SetRolePermissions: + { + if (!Guid.TryParse(expression, out var roleId)) + throw new InvalidOperationException($"SetRolePermissions: invalid role ID '{expression}'."); + var flagsJson = arguments is { Count: > 1 } ? arguments[1] : null; + await bridge.SetRolePermissionsAsync(roleId, flagsJson ?? string.Empty, ct); + await context.AppendLogAsync($"SetRolePermissions {roleId}"); + break; + } + case AgentOrchestrationStepKeys.AssignRole: + { + if (!Guid.TryParse(expression, out var agentId)) + throw new InvalidOperationException($"AssignRole: invalid agent ID '{expression}'."); + var roleId = ParseGuidArg(arguments, 1) + ?? throw new InvalidOperationException("AssignRole: invalid role ID."); + await bridge.AssignRoleAsync(agentId, roleId, ct); + await context.AppendLogAsync($"AssignRole agent={agentId} role={roleId}"); + break; + } + case AgentOrchestrationStepKeys.CreateChannel: + { + var title = expression ?? string.Empty; + var agentId = ParseGuidArg(arguments, 1) + ?? throw new InvalidOperationException("CreateChannel: invalid agent ID."); + var customId = arguments is { Count: > 2 } ? arguments[2] : null; + var channelId = await bridge.CreateChannelAsync( + context.InstanceId, title, agentId, customId, ct); + + if (context.ChannelId == Guid.Empty) + context.SetChannelId(channelId); + + if (resultVariable is not null) + context.Variables[resultVariable] = channelId.ToString(); + break; + } + case AgentOrchestrationStepKeys.AddAllowedAgent: + { + if (!Guid.TryParse(expression, out var agentId)) + throw new InvalidOperationException($"AddAllowedAgent: invalid agent ID '{expression}'."); + var channelId = ParseGuidArg(arguments, 1); + await bridge.AddAllowedAgentAsync(context.InstanceId, agentId, channelId, ct); + break; + } + } + + return true; + } + + private static Guid? ParseGuidArg(IReadOnlyList? args, int index) + { + if (args is null || index >= args.Count) return null; + return Guid.TryParse(args[index], out var g) ? g : null; + } + + private static void StoreFindResult(string? resultVariable, Guid? id, ITaskStepExecutionContext context) + { + if (resultVariable is not null) + context.Variables[resultVariable] = id?.ToString(); + } +} diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs new file mode 100644 index 00000000..cd8ac506 --- /dev/null +++ b/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs @@ -0,0 +1,21 @@ +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Trigger-key constants owned by the sharpclaw_agent_orchestration +/// module. String values are persisted verbatim in +/// TaskTriggerBindingDB.Kind and in serialized task scripts. +/// +public static class AgentOrchestrationTriggerKeys +{ + /// Generic host-bus event with a SharpClawEventType filter. + public const string Event = "Event"; + + // Parameter names persisted into TaskTriggerDefinition.Parameters. + // Preserved verbatim to remain wire-compatible with serialized scripts. + + /// Comma-separated list of SharpClawEventType flag names. + public const string EventType = "EventType"; + + /// Optional substring filter applied to SourceId / Summary. + public const string EventFilter = "EventFilter"; +} diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs b/DefaultModules/AgentOrchestration/EventBusTriggerSource.cs similarity index 60% rename from SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs rename to DefaultModules/AgentOrchestration/EventBusTriggerSource.cs index 12de1923..786661a5 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs +++ b/DefaultModules/AgentOrchestration/EventBusTriggerSource.cs @@ -1,20 +1,24 @@ -using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Core.Modules; + using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.AgentOrchestration; /// /// Trigger source that fires on SharpClaw host events -/// (, , -/// ). -/// Implements to receive events from the -/// . +/// (, +/// , +/// ). +/// Implements so the host dispatcher +/// delivers events directly to it. +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// /// public sealed class EventBusTriggerSource( - ModuleEventDispatcher dispatcher, + ISharpClawEventSinkRegistry sinkRegistry, ILogger logger) : ITaskTriggerSource, ISharpClawEventSink { private IReadOnlyList _contexts = []; @@ -22,22 +26,38 @@ public sealed class EventBusTriggerSource( // ── ITaskTriggerSource ──────────────────────────────────────── public IReadOnlyList TriggerKeys { get; } = - [WellKnownTriggerKeys.Event, WellKnownTriggerKeys.TaskCompleted, WellKnownTriggerKeys.TaskFailed]; + [ + AgentOrchestrationTriggerKeys.Event, + ]; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { _contexts = contexts; - dispatcher.InvalidateSinkCache(); + sinkRegistry.InvalidateCache(); return Task.CompletedTask; } public Task StopAsync() { _contexts = []; - dispatcher.InvalidateSinkCache(); + sinkRegistry.InvalidateCache(); return Task.CompletedTask; } + /// + public string? GetBindingValue(TaskTriggerDefinition def) => def.TriggerKey switch + { + AgentOrchestrationTriggerKeys.Event => + def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType), + _ => null, + }; + + /// + public string? GetBindingFilter(TaskTriggerDefinition def) => + def.TriggerKey == AgentOrchestrationTriggerKeys.Event + ? def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter) + : null; + // ── ISharpClawEventSink ─────────────────────────────────────── public SharpClawEventType SubscribedEvents => @@ -67,26 +87,17 @@ public async Task OnEventAsync(SharpClawEvent evt, CancellationToken ct) private static bool MatchesContext(ITaskTriggerSourceContext ctx, SharpClawEvent evt) { var def = ctx.Definition; - switch (def.TriggerKey) - { - case WellKnownTriggerKeys.TaskCompleted: - if (!evt.Type.HasFlag(SharpClawEventType.JobCompleted)) return false; - break; - case WellKnownTriggerKeys.TaskFailed: - if (!evt.Type.HasFlag(SharpClawEventType.JobFailed)) return false; - break; - case WellKnownTriggerKeys.Event: - if (string.IsNullOrWhiteSpace(def.EventType)) return false; - // EventType may be a comma-separated list of SharpClawEventType flag names - if (!MatchesEventTypeFilter(def.EventType, evt.Type)) return false; - // Optional filter on SourceId / Summary - if (!string.IsNullOrWhiteSpace(def.EventFilter) && - !MatchesEventFilter(def.EventFilter, evt)) - return false; - break; - default: - return false; - } + if (def.TriggerKey != AgentOrchestrationTriggerKeys.Event) return false; + + var eventType = + def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType); + if (string.IsNullOrWhiteSpace(eventType)) return false; + if (!MatchesEventTypeFilter(eventType, evt.Type)) return false; + var filter = + def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter); + if (!string.IsNullOrWhiteSpace(filter) && + !MatchesEventFilter(filter, evt)) + return false; return true; } diff --git a/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs b/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs new file mode 100644 index 00000000..890e5994 --- /dev/null +++ b/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs @@ -0,0 +1,107 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +using SharpClaw.Contracts.DTOs.Tasks; +using SharpClaw.Contracts.Services; + +namespace SharpClaw.Modules.AgentOrchestration.Handlers; + +// ═══════════════════════════════════════════════════════════════════ +// Scheduled jobs /scheduled-jobs +// Owned by the AgentOrchestration module. Delegates to the +// host-supplied IScheduledJobService contract. +// ═══════════════════════════════════════════════════════════════════ + +public static class ScheduledJobEndpoints +{ + public static IEndpointRouteBuilder MapScheduledJobEndpoints(this IEndpointRouteBuilder routes) + { + var group = routes.MapGroup("/scheduled-jobs"); + + group.MapPost("/", async ( + CreateScheduledJobRequest request, IScheduledJobService svc, CancellationToken ct) => + { + try + { + var result = await svc.CreateAsync(request, ct); + return Results.Ok(result); + } + catch (InvalidOperationException ex) + { + return Results.UnprocessableEntity(new { error = ex.Message }); + } + }); + + group.MapGet("/", async (IScheduledJobService svc, CancellationToken ct) + => Results.Ok(await svc.ListAsync(ct))); + + group.MapGet("/{jobId:guid}", async (Guid jobId, IScheduledJobService svc, CancellationToken ct) => + { + var job = await svc.GetByIdAsync(jobId, ct); + return job is not null ? Results.Ok(job) : Results.NotFound(); + }); + + group.MapPut("/{jobId:guid}", async ( + Guid jobId, UpdateScheduledJobRequest request, + IScheduledJobService svc, CancellationToken ct) => + { + try + { + var result = await svc.UpdateAsync(jobId, request, ct); + return result is not null ? Results.Ok(result) : Results.NotFound(); + } + catch (InvalidOperationException ex) + { + return Results.UnprocessableEntity(new { error = ex.Message }); + } + }); + + group.MapDelete("/{jobId:guid}", async ( + Guid jobId, IScheduledJobService svc, CancellationToken ct) + => await svc.DeleteAsync(jobId, ct) ? Results.NoContent() : Results.NotFound()); + + // ── Pause / Resume ───────────────────────────────────────── + + group.MapPost("/{jobId:guid}/pause", async ( + Guid jobId, IScheduledJobService svc, CancellationToken ct) => + { + var result = await svc.PauseAsync(jobId, ct); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }); + + group.MapPost("/{jobId:guid}/resume", async ( + Guid jobId, IScheduledJobService svc, CancellationToken ct) => + { + var result = await svc.ResumeAsync(jobId, ct); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }); + + // ── Preview endpoints ────────────────────────────────────── + + group.MapGet("/{jobId:guid}/preview", async ( + Guid jobId, IScheduledJobService svc, CancellationToken ct, int count = 10) => + { + count = count <= 0 ? 10 : Math.Min(count, 100); + var result = await svc.PreviewJobAsync(jobId, count, ct); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }); + + group.MapGet("/preview", ( + IScheduledJobService svc, string expression, string? timezone = null, int count = 10) => + { + count = count <= 0 ? 10 : Math.Min(count, 100); + try + { + var result = svc.PreviewExpression(expression, timezone, count); + return Results.Ok(result); + } + catch (InvalidOperationException ex) + { + return Results.UnprocessableEntity(new { error = ex.Message }); + } + }); + + return routes; + } +} diff --git a/SharpClaw.Application.Core/Services/CronEvaluator.cs b/DefaultModules/AgentOrchestration/Services/CronEvaluator.cs similarity index 81% rename from SharpClaw.Application.Core/Services/CronEvaluator.cs rename to DefaultModules/AgentOrchestration/Services/CronEvaluator.cs index 17a8f722..2cb6afb3 100644 --- a/SharpClaw.Application.Core/Services/CronEvaluator.cs +++ b/DefaultModules/AgentOrchestration/Services/CronEvaluator.cs @@ -1,6 +1,6 @@ using Cronos; -namespace SharpClaw.Application.Services; +namespace SharpClaw.Modules.AgentOrchestration.Services; /// /// Thin wrapper around the Cronos library. All cron parsing and @@ -42,8 +42,7 @@ public static bool TryParse(string expression, out string? error) ArgumentNullException.ThrowIfNull(expression); var tz = ResolveTimezone(timezone); - var next = Parse(expression).GetNextOccurrence(after, tz); - return next; + return Parse(expression).GetNextOccurrence(after, tz); } /// @@ -73,13 +72,8 @@ public static IEnumerable GetNextOccurrences( } } - // ───────────────────────────────────────────────────────────── - // Private helpers - // ───────────────────────────────────────────────────────────── - private static CronExpression Parse(string expression) { - // Six space-separated fields → second-resolution format. var format = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length == 6 ? CronFormat.IncludeSeconds : CronFormat.Standard; diff --git a/SharpClaw.Application.Core/Services/ScheduledJobService.cs b/DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs similarity index 72% rename from SharpClaw.Application.Core/Services/ScheduledJobService.cs rename to DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs index 9584d5ec..cde09653 100644 --- a/SharpClaw.Application.Core/Services/ScheduledJobService.cs +++ b/DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs @@ -1,38 +1,38 @@ using System.Text.Json; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Models.Jobs; + using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Persistence; +using SharpClaw.Contracts.Services; +using SharpClaw.Modules.AgentOrchestration.Models; -namespace SharpClaw.Application.Services; +namespace SharpClaw.Modules.AgentOrchestration.Services; /// -/// CRUD and lifecycle management for entities. -/// Cron validation is enforced on create and update. +/// Module-owned implementation of . +/// Lives in SharpClaw.Modules.AgentOrchestration: scheduling is the +/// module's responsibility, the host only exposes the means to start +/// task/job runs. /// public sealed class ScheduledJobService( - SharpClawDbContext db, - ILogger logger) + AgentOrchestrationDbContext db, + ILogger logger) : IScheduledJobService { - // ═══════════════════════════════════════════════════════════════ - // Validation constants - // ═══════════════════════════════════════════════════════════════ + public const string ErrBothSchedules = "SCHED001"; + public const string ErrInvalidCron = "SCHED002"; + public const string ErrInvalidTz = "SCHED003"; + public const string WarnNeverFires = "SCHED004"; - public const string ErrBothSchedules = "SCHED001"; - public const string ErrInvalidCron = "SCHED002"; - public const string ErrInvalidTz = "SCHED003"; - public const string WarnNeverFires = "SCHED004"; + CronPreviewResponse IScheduledJobService.PreviewExpression( + string expression, string? timezone, int count) + => PreviewExpression(expression, timezone, count); // ═══════════════════════════════════════════════════════════════ // CRUD // ═══════════════════════════════════════════════════════════════ - /// - /// Validate cron fields and persist a new scheduled job. - /// Throws on hard validation errors. - /// public async Task CreateAsync( CreateScheduledJobRequest request, CancellationToken ct = default) @@ -66,7 +66,7 @@ public async Task CreateAsync( MaxRetries = request.MaxRetries, }; - db.ScheduledTasks.Add(entity); + db.ScheduledJobs.Add(entity); await db.SaveChangesAsync(ct); return ToResponse(entity); } @@ -74,29 +74,25 @@ public async Task CreateAsync( public async Task GetByIdAsync( Guid id, CancellationToken ct = default) { - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); return entity is null ? null : ToResponse(entity); } public async Task> ListAsync( CancellationToken ct = default) - => await db.ScheduledTasks + => await db.ScheduledJobs .OrderBy(j => j.NextRunAt) .Select(j => ToResponse(j)) .ToListAsync(ct); - /// - /// Partial update. Validates cron fields if expression or timezone changes. - /// public async Task UpdateAsync( Guid id, UpdateScheduledJobRequest request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); if (entity is null) return null; - // Determine effective schedule values after potential update var effectiveCron = request.CronExpression ?? entity.CronExpression; var effectiveTz = request.CronTimezone ?? entity.CronTimezone; var effectiveInterval = request.RepeatInterval ?? entity.RepeatInterval; @@ -114,13 +110,13 @@ public async Task> ListAsync( entity.NextRunAt = nextRunAt; } - if (request.Name is not null) entity.Name = request.Name; - if (request.RepeatInterval.HasValue) entity.RepeatInterval = request.RepeatInterval; - if (request.CronExpression is not null) entity.CronExpression = request.CronExpression; - if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone; - if (request.MissedFirePolicy.HasValue) entity.MissedFirePolicy = request.MissedFirePolicy.Value; - if (request.MaxRetries.HasValue) entity.MaxRetries = request.MaxRetries.Value; - if (request.CallerAgentId.HasValue) entity.CallerAgentId = request.CallerAgentId; + if (request.Name is not null) entity.Name = request.Name; + if (request.RepeatInterval.HasValue) entity.RepeatInterval = request.RepeatInterval; + if (request.CronExpression is not null) entity.CronExpression = request.CronExpression; + if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone; + if (request.MissedFirePolicy.HasValue) entity.MissedFirePolicy = request.MissedFirePolicy.Value; + if (request.MaxRetries.HasValue) entity.MaxRetries = request.MaxRetries.Value; + if (request.CallerAgentId.HasValue) entity.CallerAgentId = request.CallerAgentId; if (request.ParameterValues is not null) entity.ParameterValuesJson = request.ParameterValues.Count > 0 ? JsonSerializer.Serialize(request.ParameterValues) @@ -132,10 +128,10 @@ public async Task> ListAsync( public async Task DeleteAsync(Guid id, CancellationToken ct = default) { - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); if (entity is null) return false; - db.ScheduledTasks.Remove(entity); + db.ScheduledJobs.Remove(entity); await db.SaveChangesAsync(ct); return true; } @@ -147,7 +143,7 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task PauseAsync( Guid id, CancellationToken ct = default) { - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); if (entity is null) return null; if (entity.Status == ScheduledTaskStatus.Pending) @@ -162,14 +158,13 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task ResumeAsync( Guid id, CancellationToken ct = default) { - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); if (entity is null) return null; if (entity.Status == ScheduledTaskStatus.Paused) { entity.Status = ScheduledTaskStatus.Pending; - // Re-derive NextRunAt if the schedule has passed while paused if (!string.IsNullOrEmpty(entity.CronExpression)) { var next = CronEvaluator.GetNextOccurrence( @@ -195,17 +190,13 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default) } // ═══════════════════════════════════════════════════════════════ - // Preview (stateless) + // Preview // ═══════════════════════════════════════════════════════════════ - /// - /// Compute upcoming occurrences for a stored job's cron expression. - /// Returns null when the job does not exist or has no cron expression. - /// public async Task PreviewJobAsync( Guid id, int count = 10, CancellationToken ct = default) { - var entity = await db.ScheduledTasks.FindAsync([id], ct); + var entity = await db.ScheduledJobs.FindAsync([id], ct); if (entity is null || string.IsNullOrEmpty(entity.CronExpression)) return null; @@ -217,10 +208,6 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default) return new CronPreviewResponse(entity.CronExpression, entity.CronTimezone, occurrences); } - /// - /// Stateless preview — no DB access. - /// Throws for invalid expression or timezone. - /// public static CronPreviewResponse PreviewExpression( string expression, string? timezone = null, int count = 10) { @@ -250,11 +237,6 @@ public static CronPreviewResponse PreviewExpression( // Validation helper // ═══════════════════════════════════════════════════════════════ - /// - /// Validates cron-related fields and returns the effective NextRunAt - /// together with any non-fatal warning messages. - /// Throws on hard errors. - /// internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings) ValidateCronFields( string? cronExpression, @@ -264,19 +246,16 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings) { var warnings = new List(); - // SCHED001: both schedules set if (!string.IsNullOrEmpty(cronExpression) && repeatInterval.HasValue) throw new InvalidOperationException( $"{ErrBothSchedules}: CronExpression and RepeatInterval are mutually exclusive."); if (!string.IsNullOrEmpty(cronExpression)) { - // SCHED002: invalid cron if (!CronEvaluator.TryParse(cronExpression, out var cronErr)) throw new InvalidOperationException( $"{ErrInvalidCron}: {cronErr}"); - // SCHED003: invalid timezone if (!string.IsNullOrWhiteSpace(cronTimezone)) { try { TimeZoneInfo.FindSystemTimeZoneById(cronTimezone); } @@ -287,13 +266,11 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings) } } - // Auto-derive NextRunAt when not supplied var next = CronEvaluator.GetNextOccurrence( cronExpression, DateTimeOffset.UtcNow, cronTimezone); if (next is null) { - // SCHED004: expression never fires — warn, allow creation warnings.Add( $"{WarnNeverFires}: Expression '{cronExpression}' has no future " + "occurrences from now. The job will never fire unless updated."); @@ -304,15 +281,10 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings) return (suppliedNextRunAt ?? next.Value, warnings); } - // Interval or one-shot var effective = suppliedNextRunAt ?? DateTimeOffset.UtcNow; return (effective, warnings); } - // ═══════════════════════════════════════════════════════════════ - // Projection - // ═══════════════════════════════════════════════════════════════ - internal static ScheduledJobResponse ToResponse(ScheduledJobDB e) => new( e.Id, e.Name, e.Status, e.NextRunAt, e.RepeatInterval, e.CronExpression, e.CronTimezone, e.MissedFirePolicy, diff --git a/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs b/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs new file mode 100644 index 00000000..59707d0d --- /dev/null +++ b/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs @@ -0,0 +1,175 @@ +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration.Models; + +namespace SharpClaw.Modules.AgentOrchestration.Services; + +/// +/// Module-owned scheduler worker. Polls module-side +/// rows and dispatches their bound task definitions through the host-supplied +/// contract. Started by the module's +/// InitializeAsync; stopped by ShutdownAsync. +/// +public sealed class ScheduledJobWorker( + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + + private readonly CancellationTokenSource _cts = new(); + private Task? _runner; + + public void Start() + { + if (_runner is not null) return; + _runner = Task.Run(() => RunLoopAsync(_cts.Token)); + } + + public async Task StopAsync() + { + if (_runner is null) return; + try { _cts.Cancel(); } catch { } + try { await _runner.ConfigureAwait(false); } + catch (OperationCanceledException) { } + catch (Exception ex) + { + logger.LogWarning(ex, "ScheduledJobWorker shutdown produced an exception."); + } + _runner = null; + } + + private async Task RunLoopAsync(CancellationToken ct) + { + logger.LogInformation("ScheduledJobWorker started."); + + try + { + while (!ct.IsCancellationRequested) + { + try + { + await ProcessDueJobsAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error in scheduled job processing loop."); + } + + await Task.Delay(PollInterval, ct); + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + } + } + + internal async Task ProcessDueJobsAsync(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var now = DateTimeOffset.UtcNow; + + var dueJobs = await db.ScheduledJobs + .Where(t => t.Status == ScheduledTaskStatus.Pending && t.NextRunAt <= now) + .ToListAsync(ct); + + var missedThreshold = TimeSpan.FromMinutes( + configuration.GetValue("Scheduler:MissedFireThresholdMinutes", 60)); + + foreach (var job in dueJobs) + { + job.Status = ScheduledTaskStatus.Running; + job.LastRunAt = now; + await db.SaveChangesAsync(ct); + + try + { + logger.LogInformation("Executing scheduled job {Name} ({Id}).", job.Name, job.Id); + + if (job.TaskDefinitionId.HasValue) + { + var launcher = scope.ServiceProvider.GetRequiredService(); + + Dictionary? paramValues = null; + if (!string.IsNullOrEmpty(job.ParameterValuesJson)) + paramValues = JsonSerializer.Deserialize>( + job.ParameterValuesJson); + + var instanceId = await launcher.LaunchAsync( + job.TaskDefinitionId.Value, + paramValues, + job.CallerAgentId, + ct); + + logger.LogInformation( + "Scheduled job {Name} ({Id}) launched task instance {InstanceId}.", + job.Name, job.Id, instanceId); + } + + job.Status = ScheduledTaskStatus.Completed; + job.RetryCount = 0; + job.LastError = null; + + bool wasMissed = (now - job.NextRunAt) > missedThreshold; + if (job.MissedFirePolicy == MissedFirePolicy.Skip && wasMissed) + { + AdvanceNextRunAt(job, now); + await db.SaveChangesAsync(ct); + continue; + } + + AdvanceNextRunAt(job, now); + + logger.LogInformation("Scheduled job {Name} ({Id}) completed.", job.Name, job.Id); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + job.RetryCount++; + job.LastError = ex.Message; + + if (job.RetryCount < job.MaxRetries) + { + job.Status = ScheduledTaskStatus.Pending; + job.NextRunAt = now.AddSeconds(30 * job.RetryCount); + logger.LogWarning(ex, "Scheduled job {Name} failed (attempt {Attempt}/{Max}), retrying.", + job.Name, job.RetryCount, job.MaxRetries); + } + else + { + job.Status = ScheduledTaskStatus.Failed; + logger.LogError(ex, "Scheduled job {Name} failed permanently after {Max} attempts.", + job.Name, job.MaxRetries); + } + } + + await db.SaveChangesAsync(ct); + } + } + + private static void AdvanceNextRunAt(ScheduledJobDB job, DateTimeOffset now) + { + if (!string.IsNullOrEmpty(job.CronExpression)) + { + var next = CronEvaluator.GetNextOccurrence( + job.CronExpression, now, job.CronTimezone); + + job.Status = next.HasValue ? ScheduledTaskStatus.Pending + : ScheduledTaskStatus.Completed; + job.NextRunAt = next ?? job.NextRunAt; + } + else if (job.RepeatInterval.HasValue) + { + job.Status = ScheduledTaskStatus.Pending; + job.NextRunAt = now.Add(job.RepeatInterval.Value); + } + } +} diff --git a/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj b/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj index fa8ab93b..c4e50793 100644 --- a/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj +++ b/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj @@ -23,6 +23,10 @@ + + + + diff --git a/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs b/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs new file mode 100644 index 00000000..ee20598b --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs @@ -0,0 +1,129 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Module-owned implementations +/// for the trigger-attribute family claimed by +/// sharpclaw_agent_orchestration: +/// [Schedule], [OnStartup], [OnShutdown], +/// [OnTaskCompleted], [OnTaskFailed], [OnTrigger]. +/// +/// Each handler returns a shaped +/// identically to what the legacy core switch produced, so the parser's +/// downstream BuildTriggerParameters mirroring and +/// [ConcurrencyPolicy] override pass remain wire-compatible. +/// +/// +internal static class AgentOrchestrationTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["Schedule"] = new ScheduleHandler(), + ["OnStartup"] = new OnStartupHandler(), + ["OnShutdown"] = new OnShutdownHandler(), + ["OnTaskCompleted"] = new OnTaskCompletedHandler(), + ["OnTaskFailed"] = new OnTaskFailedHandler(), + ["OnTrigger"] = new OnTriggerHandler(), + ["OnEvent"] = new OnEventHandler(), + }; + + private sealed class OnEventHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var eventType = context.GetStringArg(0); + if (!string.IsNullOrEmpty(eventType)) + p[AgentOrchestrationTriggerKeys.EventType] = eventType; + var filter = context.GetNamedStringArg("Filter"); + if (!string.IsNullOrEmpty(filter)) + p[AgentOrchestrationTriggerKeys.EventFilter] = filter; + return new TaskTriggerDefinition + { + TriggerKey = AgentOrchestrationTriggerKeys.Event, + Parameters = p, + }; + } + } + + private sealed class ScheduleHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var expr = context.GetStringArg(0); + if (!string.IsNullOrEmpty(expr)) + p[TaskScriptingTriggerKeys.CronExpression] = expr; + var tz = context.GetNamedStringArg("Timezone"); + if (!string.IsNullOrEmpty(tz)) + p[TaskScriptingTriggerKeys.CronTimezone] = tz; + return new TaskTriggerDefinition + { + TriggerKey = TaskScriptingTriggerKeys.Cron, + Parameters = p, + }; + } + } + + private sealed class OnStartupHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) => + new() { TriggerKey = TaskScriptingTriggerKeys.Startup }; + } + + private sealed class OnShutdownHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) => + new() { TriggerKey = TaskScriptingTriggerKeys.Shutdown }; + } + + private sealed class OnTaskCompletedHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var src = context.GetStringArg(0); + if (!string.IsNullOrEmpty(src)) + p[TaskScriptingTriggerKeys.SourceTaskName] = src; + return new TaskTriggerDefinition + { + TriggerKey = TaskScriptingTriggerKeys.TaskCompleted, + Parameters = p, + }; + } + } + + private sealed class OnTaskFailedHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var src = context.GetStringArg(0); + if (!string.IsNullOrEmpty(src)) + p[TaskScriptingTriggerKeys.SourceTaskName] = src; + return new TaskTriggerDefinition + { + TriggerKey = TaskScriptingTriggerKeys.TaskFailed, + Parameters = p, + }; + } + } + + private sealed class OnTriggerHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var filter = context.GetNamedStringArg("Filter"); + if (!string.IsNullOrEmpty(filter)) + p[TaskScriptingTriggerKeys.CustomSourceFilter] = filter; + return new TaskTriggerDefinition + { + TriggerKey = context.GetStringArg(0), + Parameters = p, + }; + } + } +} diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs b/DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs similarity index 59% rename from SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs rename to DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs index 131e5176..9ee80cd4 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs +++ b/DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs @@ -1,16 +1,19 @@ -using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.Extensions.Hosting; -using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Tasks; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.AgentOrchestration; /// -/// Fires once on application startup () and -/// registers a shutdown callback () via +/// Fires once on application startup +/// () and registers a shutdown +/// callback () via /// . +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// /// public sealed class LifecycleTriggerSource( IHostApplicationLifetime lifetime, @@ -21,17 +24,17 @@ public sealed class LifecycleTriggerSource( private CancellationTokenRegistration _stopReg; public IReadOnlyList TriggerKeys { get; } = - [WellKnownTriggerKeys.Startup, WellKnownTriggerKeys.Shutdown]; + [TaskScriptingTriggerKeys.Startup, TaskScriptingTriggerKeys.Shutdown]; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { _contexts = contexts; _startReg = lifetime.ApplicationStarted.Register(() => - _ = Task.Run(() => FireMatchingAsync(WellKnownTriggerKeys.Startup))); + _ = Task.Run(() => FireMatchingAsync(TaskScriptingTriggerKeys.Startup))); _stopReg = lifetime.ApplicationStopping.Register(() => - _ = Task.Run(() => FireMatchingAsync(WellKnownTriggerKeys.Shutdown))); + _ = Task.Run(() => FireMatchingAsync(TaskScriptingTriggerKeys.Shutdown))); return Task.CompletedTask; } @@ -44,6 +47,14 @@ public Task StopAsync() return Task.CompletedTask; } + /// + /// + /// Lifecycle bindings have no per-definition discriminator — every binding + /// of a given kind fires on every host event. + /// Returning matches the legacy registrar fallback. + /// + public string? GetBindingValue(TaskTriggerDefinition def) => null; + private async Task FireMatchingAsync(string key) { foreach (var ctx in _contexts.Where(c => c.Definition.TriggerKey == key)) diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs new file mode 100644 index 00000000..738bc262 --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; + +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Trigger source that fires when another task definition completes +/// () or fails +/// (). Receives host events +/// through . +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// +/// +public sealed class TaskChainTriggerSource( + ISharpClawEventSinkRegistry sinkRegistry, + ILogger logger) : ITaskTriggerSource, ISharpClawEventSink +{ + private IReadOnlyList _contexts = []; + + public IReadOnlyList TriggerKeys { get; } = + [ + TaskScriptingTriggerKeys.TaskCompleted, + TaskScriptingTriggerKeys.TaskFailed, + ]; + + public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) + { + _contexts = contexts; + sinkRegistry.InvalidateCache(); + return Task.CompletedTask; + } + + public Task StopAsync() + { + _contexts = []; + sinkRegistry.InvalidateCache(); + return Task.CompletedTask; + } + + /// + public string? GetBindingValue(TaskTriggerDefinition def) => + def.Parameters.GetValueOrDefault(TaskScriptingTriggerKeys.SourceTaskName); + + // ── ISharpClawEventSink ─────────────────────────────────────── + + public SharpClawEventType SubscribedEvents => + SharpClawEventType.JobCompleted | SharpClawEventType.JobFailed; + + public async Task OnEventAsync(SharpClawEvent evt, CancellationToken ct) + { + foreach (var ctx in _contexts) + { + if (!MatchesContext(ctx, evt)) continue; + + try + { + await ctx.FireAsync(ct: ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, + "TaskChainTriggerSource failed to fire context for definition {Id}.", + ctx.TaskDefinitionId); + } + } + } + + private static bool MatchesContext(ITaskTriggerSourceContext ctx, SharpClawEvent evt) => + ctx.Definition.TriggerKey switch + { + TaskScriptingTriggerKeys.TaskCompleted => evt.Type.HasFlag(SharpClawEventType.JobCompleted), + TaskScriptingTriggerKeys.TaskFailed => evt.Type.HasFlag(SharpClawEventType.JobFailed), + _ => false, + }; +} diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs new file mode 100644 index 00000000..4fbb6151 --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs @@ -0,0 +1,63 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Registers the Task Scripting module's event-handler names with the parser. +/// The lifecycle OnTimer handler is contributed here as a module-owned +/// trigger; the parser stores the trigger key on +/// TaskStepDefinition.ModuleTriggerKey. +/// +public sealed class TaskScriptingParserExtension : ITaskParserModuleExtension +{ + public static readonly TaskScriptingParserExtension Instance = new(); + + /// + /// Stable trigger key recorded on the parsed step's + /// ModuleTriggerKey for OnTimer handlers. + /// + public const string TimerTriggerKey = "sharpclaw.task_scripting.timer"; + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnTimer"] = (TimerTriggerKey, "sharpclaw_agent_orchestration"), + }; + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + /// + /// Wire-format step-key strings for the statement-shaped scripting + /// primitives the parser emits directly. Sourced from + /// so the module is the single + /// source of truth; core defines no statement step-key constants. + /// + /// + /// Trigger-attribute handlers owned by this module. Phase 2 of the + /// trigger-attribute migration: [Schedule], [OnStartup], + /// [OnShutdown], [OnTaskCompleted], [OnTaskFailed], + /// and [OnTrigger] are claimed here. The parser routes matching + /// attribute occurrences through these handlers before falling back to + /// its built-in switch. + /// + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + AgentOrchestrationTriggerAttributeHandlers.All; + + public TaskParserPrimitives? Primitives { get; } = new() + { + DeclareVariable = TaskScriptingStepKeys.DeclareVariable, + Assign = TaskScriptingStepKeys.Assign, + EventHandler = TaskScriptingStepKeys.EventHandler, + Conditional = TaskScriptingStepKeys.Conditional, + Loop = TaskScriptingStepKeys.Loop, + Return = TaskScriptingStepKeys.Return, + Delay = TaskScriptingStepKeys.Delay, + Evaluate = TaskScriptingStepKeys.Evaluate, + Log = TaskScriptingStepKeys.Log, + ParseResponse = TaskScriptingStepKeys.ParseResponse, + }; +} diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs new file mode 100644 index 00000000..ed2b183b --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs @@ -0,0 +1,53 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Contributes the scripting-language and runtime-control step descriptors +/// owned by the task scripting module to the central task step registry. +/// +public sealed class TaskScriptingStepDescriptorProvider : ITaskStepDescriptorProvider +{ + public string ModuleId => "sharpclaw_agent_orchestration"; + + public IReadOnlyList Descriptors { get; } = Build(); + + private static TaskStepDescriptor[] Build() + { + var owner = "sharpclaw_agent_orchestration"; + return + [ + // ── Statement constructs (registered by key only) ──────────── + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.DeclareVariable, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Assign, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.EventHandler, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Conditional, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Loop, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Return, OwnerId = owner }, + new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Evaluate, OwnerId = owner }, + + // ── Runtime control ──────────────────────────────────────── + new TaskStepDescriptor + { + MethodName = "Delay", + StepKey = TaskScriptingStepKeys.Delay, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "WaitUntilStopped", + StepKey = TaskScriptingStepKeys.WaitUntilStopped, + OwnerId = owner, + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "Log", + StepKey = TaskScriptingStepKeys.Log, + OwnerId = owner, + FirstArgIsExpression = true, + }, + ]; + } +} diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs new file mode 100644 index 00000000..dda326ed --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs @@ -0,0 +1,246 @@ +using System.Text.Json; + +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Module-side executor that owns the Task Scripting language primitives: +/// declare/assign, evaluate, log, delay, wait-until-stopped, return, and the +/// control-flow constructs (conditional, loop, event handler). +/// +/// Implements so it can drive +/// nested control flow through +/// while remaining oblivious to the orchestrator's internal types. +/// +/// +public sealed class TaskScriptingStepExecutor : ITaskStepInvocationExecutor +{ + public string ModuleId => "sharpclaw_agent_orchestration"; + + public bool CanExecute(string moduleStepKey) => moduleStepKey switch + { + TaskScriptingStepKeys.DeclareVariable + or TaskScriptingStepKeys.Assign + or TaskScriptingStepKeys.Log + or TaskScriptingStepKeys.Delay + or TaskScriptingStepKeys.WaitUntilStopped + or TaskScriptingStepKeys.Conditional + or TaskScriptingStepKeys.Loop + or TaskScriptingStepKeys.EventHandler + or TaskScriptingStepKeys.Return + or TaskScriptingStepKeys.Evaluate => true, + _ => false, + }; + + /// + /// Resolved-argument path is unused — every Task Scripting primitive needs + /// raw step access (nested bodies, unresolved expressions, handler bodies). + /// The orchestrator routes us through . + /// + public Task ExecuteAsync( + string moduleStepKey, + ITaskStepExecutionContext context, + IReadOnlyList? arguments, + string? expression, + string? resultVariable) => Task.FromResult(true); + + public async Task ExecuteInvocationAsync( + ITaskStepInvocation step, + ITaskStepExecutionContext context) + { + switch (step.StepKey) + { + case TaskScriptingStepKeys.DeclareVariable: + case TaskScriptingStepKeys.Assign: + context.Variables[step.VariableName ?? ""] = step.RawExpression; + return TaskStepResult.Continue; + + case TaskScriptingStepKeys.Evaluate: + if (step.ResultVariable is not null) + context.Variables[step.ResultVariable] = step.RawExpression; + return TaskStepResult.Continue; + + case TaskScriptingStepKeys.Log: + { + var message = step.RawExpression is null + ? string.Empty + : context.ResolveExpression(step.RawExpression); + await context.AppendLogAsync(message); + return TaskStepResult.Continue; + } + + case TaskScriptingStepKeys.Delay: + { + var resolved = step.RawExpression is null + ? null + : context.ResolveExpression(step.RawExpression); + if (int.TryParse(resolved, out var delayMs)) + await DelayWithPauseAsync(delayMs, context); + return TaskStepResult.Continue; + } + + case TaskScriptingStepKeys.WaitUntilStopped: + try + { + await Task.Delay(Timeout.Infinite, context.CancellationToken); + } + catch (OperationCanceledException) + { + // Expected — task was stopped. + } + return TaskStepResult.Continue; + + case TaskScriptingStepKeys.Return: + return TaskStepResult.Return; + + case TaskScriptingStepKeys.Conditional: + { + var branch = context.EvaluateCondition(step.RawExpression) + ? step.Body + : step.ElseBody; + if (branch is null) return TaskStepResult.Continue; + return await context.ExecuteStepsAsync(branch, context.CancellationToken); + } + + case TaskScriptingStepKeys.Loop: + return await ExecuteLoopAsync(step, context); + + case TaskScriptingStepKeys.EventHandler: + { + if (step.ModuleTriggerKey is null) + throw new InvalidOperationException( + "EventHandler step requires a ModuleTriggerKey."); + context.RegisterEventHandler( + step.ModuleTriggerKey, + step.HandlerParameter, + step.Body ?? []); + await context.AppendLogAsync( + $"Registered event handler: {step.ModuleTriggerKey}"); + return TaskStepResult.Continue; + } + } + + return TaskStepResult.Continue; + } + + // ── Loop helpers ───────────────────────────────────────────────── + + private static async Task ExecuteLoopAsync( + ITaskStepInvocation step, ITaskStepExecutionContext context) + { + var ct = context.CancellationToken; + var isForEach = step.VariableName is not null; + + if (isForEach) + { + foreach (var item in EnumerateLoopValues(step, context)) + { + ct.ThrowIfCancellationRequested(); + await context.WaitIfPausedAsync(); + if (step.VariableName is not null) + context.Variables[step.VariableName] = item; + if (step.Body is null) continue; + var result = await context.ExecuteStepsAsync(step.Body, ct); + if (result == TaskStepResult.Return) return TaskStepResult.Return; + } + return TaskStepResult.Continue; + } + + while (context.EvaluateCondition(step.RawExpression)) + { + ct.ThrowIfCancellationRequested(); + await context.WaitIfPausedAsync(); + if (step.Body is null) continue; + var result = await context.ExecuteStepsAsync(step.Body, ct); + if (result == TaskStepResult.Return) return TaskStepResult.Return; + } + return TaskStepResult.Continue; + } + + private static IEnumerable EnumerateLoopValues( + ITaskStepInvocation step, ITaskStepExecutionContext context) + { + if (string.IsNullOrWhiteSpace(step.RawExpression)) + yield break; + + // Prefer the raw variable lookup so non-string sequences (lists, + // arrays) iterate without going through the string-only resolver. + if (context.Variables.TryGetValue(step.RawExpression, out var direct)) + { + foreach (var item in EnumerateValue(direct)) + yield return item; + yield break; + } + + var resolved = context.ResolveExpression(step.RawExpression); + if (string.IsNullOrWhiteSpace(resolved)) + yield break; + + if (context.Variables.TryGetValue(resolved, out var variableValue)) + { + foreach (var item in EnumerateValue(variableValue)) + yield return item; + yield break; + } + + foreach (var item in EnumerateValue(resolved)) + yield return item; + } + + private static IEnumerable EnumerateValue(object? value) + { + if (value is null) yield break; + + if (value is string text) + { + if (TryEnumerateJsonArray(text, out var items)) + { + foreach (var item in items) yield return item; + yield break; + } + yield return text; + yield break; + } + + if (value is System.Collections.IEnumerable enumerable and not string) + { + foreach (var item in enumerable) yield return item; + } + } + + private static bool TryEnumerateJsonArray(string text, out List values) + { + values = []; + try + { + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + return false; + foreach (var item in doc.RootElement.EnumerateArray()) + { + values.Add(item.ValueKind == JsonValueKind.String + ? item.GetString() + : item.GetRawText()); + } + return true; + } + catch (JsonException) + { + return false; + } + } + + private static async Task DelayWithPauseAsync(int delayMs, ITaskStepExecutionContext context) + { + const int chunkMs = 250; + var remaining = delayMs; + while (remaining > 0) + { + await context.WaitIfPausedAsync(); + var nextDelay = Math.Min(chunkMs, remaining); + await Task.Delay(nextDelay, context.CancellationToken); + remaining -= nextDelay; + } + } +} diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepKeys.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepKeys.cs new file mode 100644 index 00000000..cf540f17 --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepKeys.cs @@ -0,0 +1,63 @@ +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Stable well-known step keys owned by the Task Scripting module: scripting +/// language primitives (declare/assign/conditional/loop/return/event_handler/ +/// evaluate) and runtime-control primitives (delay/wait_until_stopped/log). +/// +/// IMPORTANT: The literal string values intentionally match the legacy +/// core.* values so existing serialized task scripts continue to parse. +/// Only the C# location of the constants changes; the wire format does not. +/// +/// +public static class TaskScriptingStepKeys +{ + // ── State ───────────────────────────────────────────────────────────────── + + /// Declare a local variable, optionally with an initializer. + public const string DeclareVariable = "core.declare_variable"; + + /// Assign a value to an existing variable or property. + public const string Assign = "core.assign"; + + // ── Control flow ────────────────────────────────────────────────────────── + + /// Register a callback for an event trigger. + public const string EventHandler = "core.event_handler"; + + /// Conditional if/else branch. + public const string Conditional = "core.conditional"; + + /// Loop (while or foreach). + public const string Loop = "core.loop"; + + /// Return / exit from the task entry point. + public const string Return = "core.return"; + + /// Evaluate a restricted C# expression. + public const string Evaluate = "core.evaluate"; + + // ── Runtime control ─────────────────────────────────────────────────────── + + /// Await a fixed delay. + public const string Delay = "core.delay"; + + /// Block until the task is cancelled externally. + public const string WaitUntilStopped = "core.wait_until_stopped"; + + /// Write a log message. + public const string Log = "core.log"; + + // ── Response shaping ────────────────────────────────────────────────────── + + /// Parse an agent text response into a typed data object. + /// + /// The runtime semantics live in the Agent Orchestration module + /// (AgentOrchestrationStepKeys.ParseResponse binds the same wire + /// value to ParseResponse<T>() calls on the context API). + /// This entry exists because the parser may emit the same step key + /// from a statement shape, and the validator inspects it to verify the + /// referenced type is known to the task. + /// + public const string ParseResponse = "core.parse_response"; +} diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingTriggerKeys.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingTriggerKeys.cs new file mode 100644 index 00000000..7b0d16e3 --- /dev/null +++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingTriggerKeys.cs @@ -0,0 +1,42 @@ +namespace SharpClaw.Modules.AgentOrchestration; + +/// +/// Trigger-key constants owned by the sharpclaw_task_scripting module. +/// String values are persisted verbatim in TaskTriggerBindingDB.Kind +/// and in serialized task scripts. +/// +public static class TaskScriptingTriggerKeys +{ + /// Fires on a cron schedule. + public const string Cron = "Cron"; + + /// Fires once when the host application has fully started. + public const string Startup = "Startup"; + + /// Fires once when the host application begins shutting down. + public const string Shutdown = "Shutdown"; + + /// Fires when another task definition completes successfully. + public const string TaskCompleted = "TaskCompleted"; + + /// Fires when another task definition fails. + public const string TaskFailed = "TaskFailed"; + + // Parameter names persisted into TaskTriggerDefinition.Parameters. + // Preserved verbatim to remain wire-compatible with serialized scripts. + + /// + /// Source task name persisted on TaskCompleted/TaskFailed + /// bindings as the binding-row discriminator. + /// + public const string SourceTaskName = "SourceTaskName"; + + /// Cron expression for [Schedule] bindings. + public const string CronExpression = "CronExpression"; + + /// Cron timezone for [Schedule] bindings. + public const string CronTimezone = "CronTimezone"; + + /// Filter parameter for [OnTrigger] bindings. + public const string CustomSourceFilter = "CustomSourceFilter"; +} diff --git a/DefaultModules/ComputerUse/ComputerUseModule.cs b/DefaultModules/ComputerUse/ComputerUseModule.cs index bbdf597d..3505c664 100644 --- a/DefaultModules/ComputerUse/ComputerUseModule.cs +++ b/DefaultModules/ComputerUse/ComputerUseModule.cs @@ -56,7 +56,10 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); // OS-level metric providers services.AddSingleton(); services.AddSingleton(); diff --git a/DefaultModules/ComputerUse/ComputerUseParserExtension.cs b/DefaultModules/ComputerUse/ComputerUseParserExtension.cs index 328a04d5..b0870778 100644 --- a/DefaultModules/ComputerUse/ComputerUseParserExtension.cs +++ b/DefaultModules/ComputerUse/ComputerUseParserExtension.cs @@ -1,4 +1,5 @@ using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.ComputerUse.Triggers; namespace SharpClaw.Modules.ComputerUse; @@ -6,7 +7,7 @@ namespace SharpClaw.Modules.ComputerUse; /// Registers the Computer Use module's task-script API methods with /// . /// -internal sealed class ComputerUseParserExtension : ITaskParserModuleExtension +public sealed class ComputerUseParserExtension : ITaskParserModuleExtension { public static readonly ComputerUseParserExtension Instance = new(); @@ -21,4 +22,7 @@ internal sealed class ComputerUseParserExtension : ITaskParserModuleExtension public IReadOnlySet SingleArgExpressionMethods { get; } = new HashSet(); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + ComputerUseTriggerAttributeHandlers.All; } diff --git a/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs b/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs new file mode 100644 index 00000000..57452cff --- /dev/null +++ b/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs @@ -0,0 +1,31 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.ComputerUse.Contracts; + +/// +/// Module-owned contract for OS shortcut creation and removal. +/// The launcher reads shortcut metadata +/// (ShortcutLabel, ShortcutIcon, ShortcutCategory) +/// from using +/// rather than from the +/// typed shim properties on . +/// +public interface IShortcutLauncher +{ + /// + /// Writes the OS shortcut (.lnk / .desktop) and stub launcher for the + /// given trigger definition. + /// + Task WriteShortcutAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default); + + /// + /// Rewrites the shortcut file for an existing task (label, icon, category). + /// Leaves the stub launcher untouched. + /// + Task RefreshShortcutsAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default); + + /// + /// Deletes all shortcut files and the stub launcher for . + /// + Task RemoveShortcutsAsync(string customId, CancellationToken ct = default); +} diff --git a/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs b/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs index baa9df21..81280188 100644 --- a/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs +++ b/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs @@ -3,6 +3,8 @@ using System.Text; using Microsoft.Extensions.Logging; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.ComputerUse.Contracts; +using SharpClaw.Modules.ComputerUse.Triggers; namespace SharpClaw.Modules.ComputerUse.Services; @@ -21,7 +23,7 @@ namespace SharpClaw.Modules.ComputerUse.Services; /// macOS is not supported. When called on macOS a TASK441 warning is /// logged and the method returns without throwing. /// -public sealed class ShortcutLauncherService(ILogger logger) : IShortcutLauncherService +public sealed class ShortcutLauncherService(ILogger logger) : IShortcutLauncher { // ── Directory layout ───────────────────────────────────────── // All files are placed under %LOCALAPPDATA%\SharpClaw\Shortcuts on Windows @@ -213,15 +215,16 @@ private async Task WriteWindowsShortcutAsync( System.Reflection.BindingFlags.SetProperty, null, shortcut, [stubPath]); - var label = definition.ShortcutLabel ?? customId; + var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId; shortcutType.InvokeMember("Description", System.Reflection.BindingFlags.SetProperty, null, shortcut, [label]); - if (!string.IsNullOrWhiteSpace(definition.ShortcutIcon)) + var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon); + if (!string.IsNullOrWhiteSpace(icon)) shortcutType.InvokeMember("IconLocation", System.Reflection.BindingFlags.SetProperty, null, shortcut, - [definition.ShortcutIcon]); + [icon]); shortcutType.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, shortcut, null); @@ -260,11 +263,12 @@ private async Task WriteLinuxDesktopEntryAsync( Directory.CreateDirectory(DesktopApplicationsDirectory); var desktopPath = Path.Combine(DesktopApplicationsDirectory, $"sharpclaw-{safe}.desktop"); - var label = definition.ShortcutLabel ?? customId; - var icon = definition.ShortcutIcon ?? "utilities-terminal"; - var category = string.IsNullOrWhiteSpace(definition.ShortcutCategory) + var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId; + var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon) ?? "utilities-terminal"; + var rawCategory = ReadParam(definition, OsShortcutTriggerKeys.ShortcutCategory); + var category = string.IsNullOrWhiteSpace(rawCategory) ? "Utility;" - : $"{definition.ShortcutCategory};"; + : $"{rawCategory};"; var sb = new StringBuilder(); sb.AppendLine("[Desktop Entry]"); @@ -353,6 +357,24 @@ private static void MakeExecutable(string path) } } + /// + /// Reads a shortcut parameter from . + /// Returns when the key is absent or the value is + /// blank. The launcher reads exclusively from Parameters so the + /// shim typed properties on can be + /// removed in a later phase. + /// + private static string? ReadParam(TaskTriggerDefinition definition, string key) + { + if (definition.Parameters.TryGetValue(key, out var value) + && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + + return null; + } + /// Sanitizes a custom ID for use as a file name. internal static string Sanitize(string customId) { diff --git a/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs new file mode 100644 index 00000000..9102e26d --- /dev/null +++ b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs @@ -0,0 +1,206 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.ComputerUse.Triggers; + +/// +/// Module-owned implementations +/// for the desktop / OS trigger-attribute family claimed by +/// sharpclaw_computer_use: +/// [OnProcessStarted], [OnProcessStopped], +/// [OnWindowFocused], [OnWindowBlurred], [OnHotkey], +/// [OnSystemIdle], [OnSystemActive], [OnScreenLocked], +/// [OnScreenUnlocked], [OnDeviceConnected], +/// [OnDeviceDisconnected], and [OsShortcut]. +/// +/// Behavior preserved verbatim from the legacy core parser switch, +/// including TASK420 (macOS-incompatible attribute warning) and TASK429 +/// (hotkey combination validation). +/// +/// +internal static class ComputerUseTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnProcessStarted"] = new ProcessHandler("ProcessStarted"), + ["OnProcessStopped"] = new ProcessHandler("ProcessStopped"), + ["OnWindowFocused"] = new WindowHandler("WindowFocused"), + ["OnWindowBlurred"] = new WindowHandler("WindowBlurred"), + ["OnHotkey"] = new HotkeyHandler(), + ["OnSystemIdle"] = new SystemIdleHandler(), + ["OnSystemActive"] = new MacIncompatibleHandler("SystemActive"), + ["OnScreenLocked"] = new MacIncompatibleHandler("ScreenLocked"), + ["OnScreenUnlocked"] = new MacIncompatibleHandler("ScreenUnlocked"), + ["OnDeviceConnected"] = new DeviceHandler("DeviceConnected"), + ["OnDeviceDisconnected"]= new DeviceHandler("DeviceDisconnected"), + ["OsShortcut"] = new OsShortcutHandler(), + }; + + private static void EmitMacWarning(TaskTriggerAttributeContext ctx) + { + ctx.Report( + TaskTriggerAttributeDiagnosticSeverity.Warning, + "TASK420", + $"[{ctx.AttributeName}] is not supported on macOS and will be ignored at runtime on that platform."); + } + + private static TaskTriggerDefinition WithProcess(string triggerKey, TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var name = context.GetStringArg(0); + if (!string.IsNullOrEmpty(name)) + p[ComputerUseTriggerKeys.ProcessName] = name; + return new TaskTriggerDefinition + { + TriggerKey = triggerKey, + Parameters = p, + }; + } + + private sealed class ProcessHandler(string triggerKey) : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + EmitMacWarning(context); + return WithProcess(triggerKey, context); + } + } + + private sealed class WindowHandler(string triggerKey) : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + EmitMacWarning(context); + return WithProcess(triggerKey, context); + } + } + + private sealed class MacIncompatibleHandler(string triggerKey) : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + EmitMacWarning(context); + return new TaskTriggerDefinition { TriggerKey = triggerKey }; + } + } + + private sealed class HotkeyHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + EmitMacWarning(context); + var combo = context.GetStringArg(0); + if (!IsHotkeyComboValid(combo)) + { + context.Report( + TaskTriggerAttributeDiagnosticSeverity.Error, + "TASK429", + $"[OnHotkey] key combination \"{combo}\" could not be parsed. " + + "Expected format: \"Modifier+Key\" (e.g. \"Ctrl+Shift+F10\")."); + } + var p = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(combo)) + p[ComputerUseTriggerKeys.HotkeyCombo] = combo; + return new TaskTriggerDefinition + { + TriggerKey = ComputerUseTriggerKeys.Hotkey, + Parameters = p, + }; + } + } + + private sealed class SystemIdleHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + EmitMacWarning(context); + var minutes = context.GetNamedIntArg("Minutes") ?? context.GetIntArg(0); + var p = new Dictionary(StringComparer.Ordinal); + if (minutes.HasValue) + p[ComputerUseTriggerKeys.IdleMinutes] = minutes.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new TaskTriggerDefinition + { + TriggerKey = ComputerUseTriggerKeys.SystemIdle, + Parameters = p, + }; + } + } + + private sealed class DeviceHandler(string triggerKey) : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var cls = context.GetNamedStringArg("Class"); + if (!string.IsNullOrEmpty(cls)) + p[ComputerUseTriggerKeys.DeviceClass] = cls; + var pattern = context.GetNamedStringArg("Pattern"); + if (!string.IsNullOrEmpty(pattern)) + p[ComputerUseTriggerKeys.DeviceNamePattern] = pattern; + return new TaskTriggerDefinition + { + TriggerKey = triggerKey, + Parameters = p, + }; + } + } + + private sealed class OsShortcutHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var label = context.GetStringArg(0); + if (!string.IsNullOrEmpty(label)) + p[OsShortcutTriggerKeys.ShortcutLabel] = label; + var icon = context.GetNamedStringArg("Icon"); + if (!string.IsNullOrEmpty(icon)) + p[OsShortcutTriggerKeys.ShortcutIcon] = icon; + var category = context.GetNamedStringArg("Category"); + if (!string.IsNullOrEmpty(category)) + p[OsShortcutTriggerKeys.ShortcutCategory] = category; + return new TaskTriggerDefinition + { + TriggerKey = OsShortcutTriggerKeys.OsShortcut, + Parameters = p, + }; + } + } + + // ── Hotkey validation (verbatim from TaskScriptParser) ──────── + + private static readonly HashSet KnownModifiers = + ["Ctrl", "Alt", "Shift", "Win", "Meta", "Control", "Windows"]; + + private static readonly HashSet KnownKeys = + ["F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12", + "A","B","C","D","E","F","G","H","I","J","K","L","M", + "N","O","P","Q","R","S","T","U","V","W","X","Y","Z", + "0","1","2","3","4","5","6","7","8","9", + "Space","Enter","Tab","Escape","Backspace","Delete","Insert", + "Home","End","PageUp","PageDown","Up","Down","Left","Right", + "NumPad0","NumPad1","NumPad2","NumPad3","NumPad4", + "NumPad5","NumPad6","NumPad7","NumPad8","NumPad9", + "Multiply","Add","Subtract","Divide","Decimal", + "OemSemicolon","OemPlus","OemComma","OemMinus","OemPeriod", + "OemOpenBrackets","OemCloseBrackets","OemPipe","OemQuotes","OemBackslash", + "PrintScreen","Pause","ScrollLock","CapsLock","NumLock"]; + + private static bool IsHotkeyComboValid(string? combo) + { + if (string.IsNullOrWhiteSpace(combo)) + return false; + + var parts = combo.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + return false; + + for (var i = 0; i < parts.Length - 1; i++) + { + if (!KnownModifiers.Contains(parts[i])) + return false; + } + + return KnownKeys.Contains(parts[^1]); + } +} diff --git a/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs new file mode 100644 index 00000000..8e1da8cd --- /dev/null +++ b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs @@ -0,0 +1,33 @@ +namespace SharpClaw.Modules.ComputerUse.Triggers; + +/// +/// Trigger and parameter keys owned by the computer-use module's desktop +/// trigger family (process, window-focus, hotkey, idle, screen-lock, +/// device). String values mirror the legacy TaskTriggerDefinition +/// property names verbatim so persisted binding rows and serialized +/// scripts continue to round-trip after the typed-property surface is +/// removed. +/// +public static class ComputerUseTriggerKeys +{ + // Trigger keys persisted in TaskTriggerBindingDB.Kind. + public const string ProcessStarted = "ProcessStarted"; + public const string ProcessStopped = "ProcessStopped"; + public const string WindowFocused = "WindowFocused"; + public const string WindowBlurred = "WindowBlurred"; + public const string Hotkey = "Hotkey"; + public const string SystemIdle = "SystemIdle"; + public const string SystemActive = "SystemActive"; + public const string ScreenLocked = "ScreenLocked"; + public const string ScreenUnlocked = "ScreenUnlocked"; + public const string DeviceConnected = "DeviceConnected"; + public const string DeviceDisconnected = "DeviceDisconnected"; + + // Parameter names — must match the legacy TaskTriggerDefinition + // property names so existing on-disk JSON keeps deserialising. + public const string ProcessName = "ProcessName"; + public const string HotkeyCombo = "HotkeyCombo"; + public const string IdleMinutes = "IdleMinutes"; + public const string DeviceClass = "DeviceClass"; + public const string DeviceNamePattern = "DeviceNamePattern"; +} diff --git a/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs index 257cfd82..d15db899 100644 --- a/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs +++ b/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs @@ -155,7 +155,7 @@ private async Task FireMatchingAsync(string triggerKey, string deviceId) { foreach (var ctx in _contexts.Where(c => c.Definition.TriggerKey == triggerKey)) { - var pattern = ctx.Definition.DeviceNamePattern; + var pattern = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.DeviceNamePattern); if (!string.IsNullOrWhiteSpace(pattern) && !deviceId.Contains(pattern, StringComparison.OrdinalIgnoreCase)) continue; diff --git a/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs index a03e810c..65019f3a 100644 --- a/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs +++ b/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs @@ -63,7 +63,7 @@ private async Task StartWindowsHotkeysAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var combo = ctx.Definition.HotkeyCombo; + var combo = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.HotkeyCombo); if (string.IsNullOrWhiteSpace(combo)) continue; if (!TryParseHotkey(combo, out var modifiers, out var vk)) diff --git a/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs index 15d0a69e..1681bdc0 100644 --- a/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs +++ b/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs @@ -66,17 +66,22 @@ private async Task PollAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var threshold = (ctx.Definition.IdleMinutes ?? 5) * 60; + var minutes = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5; + var threshold = minutes * 60; var isIdle = idleSeconds >= threshold; - if (ctx.Definition.TriggerKey == "SystemIdle" && !wasIdle && isIdle) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.SystemIdle && !wasIdle && isIdle) await FireAsync(ctx); - if (ctx.Definition.TriggerKey == "SystemActive" && wasIdle && !isIdle) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.SystemActive && wasIdle && !isIdle) await FireAsync(ctx); } - wasIdle = _contexts.Any(c => GetIdleSeconds() >= (c.Definition.IdleMinutes ?? 5) * 60); + wasIdle = _contexts.Any(c => + { + var m = TryParseInt(c.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5; + return GetIdleSeconds() >= m * 60; + }); } catch (OperationCanceledException) { break; } catch (Exception ex) @@ -137,6 +142,10 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx) } } + private static int? TryParseInt(string? value) => + int.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null; + [StructLayout(LayoutKind.Sequential)] private struct LASTINPUTINFO { diff --git a/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs new file mode 100644 index 00000000..5a0f5d9a --- /dev/null +++ b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs @@ -0,0 +1,33 @@ +namespace SharpClaw.Modules.ComputerUse.Triggers; + +/// +/// Module-owned trigger-key and parameter-name constants for the +/// OsShortcut trigger. The string values must remain byte-identical +/// to the legacy core constants — they are persisted into +/// TaskTriggerBindingDB.Kind and into +/// TaskTriggerDefinition.Parameters keys, and existing serialized +/// task scripts must round-trip without rewrites. +/// +public static class OsShortcutTriggerKeys +{ + /// Trigger key persisted in TaskTriggerBindingDB.Kind. + public const string OsShortcut = "OsShortcut"; + + /// + /// TaskTriggerDefinition.Parameters key for the user-visible + /// shortcut label (window title / desktop entry Name). + /// + public const string ShortcutLabel = "ShortcutLabel"; + + /// + /// TaskTriggerDefinition.Parameters key for the icon path or + /// stock icon name passed to the OS shell when registering the shortcut. + /// + public const string ShortcutIcon = "ShortcutIcon"; + + /// + /// TaskTriggerDefinition.Parameters key for the freedesktop / + /// Start menu category the shortcut appears under. + /// + public const string ShortcutCategory = "ShortcutCategory"; +} diff --git a/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs new file mode 100644 index 00000000..a3df8059 --- /dev/null +++ b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; + +using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.ComputerUse.Contracts; + +namespace SharpClaw.Modules.ComputerUse.Triggers; + +/// +/// Module-owned trigger source for OsShortcut. The OS shortcut +/// trigger is unusual in that the persisted binding row itself looks +/// identical to any other trigger binding — the registrar's default +/// TaskTriggerBindingDB upsert path handles persistence — but +/// creating or removing a binding must also write or delete an OS-level +/// shortcut file (.lnk on Windows, .desktop on Linux). +/// +/// The source therefore opts out of binding-persistence ownership +/// ( is left at +/// the default ) and instead implements +/// so the registrar fires +/// the file-side action immediately after each binding row is added or +/// removed. +/// +public sealed class OsShortcutTriggerSource( + IShortcutLauncher launcher, + ILogger logger) + : ITaskTriggerSource, ITaskTriggerBindingSideEffect +{ + /// + public string TriggerKey => OsShortcutTriggerKeys.OsShortcut; + + /// + public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) => + Task.CompletedTask; + + /// + public Task StopAsync() => Task.CompletedTask; + + /// + public string? GetBindingValue(TaskTriggerDefinition def) + { + if (def.Parameters.TryGetValue(OsShortcutTriggerKeys.ShortcutLabel, out var label) && + !string.IsNullOrWhiteSpace(label)) + { + return label; + } + + return null; + } + + // Explicit interface implementation: TriggerKey is shared with + // ITaskTriggerSource and identical, so we just route to it. + string ITaskTriggerBindingSideEffect.TriggerKey => TriggerKey; + + /// + public async Task OnBindingCreatedAsync( + TaskDefinitionDescriptor definition, + TaskTriggerDefinition trigger, + TaskTriggerBindingDescriptor binding, + CancellationToken ct) + { + var customId = definition.Name; + if (string.IsNullOrWhiteSpace(customId)) + { + logger.LogWarning( + "OsShortcut binding for definition {DefinitionId} has no task name; skipping shortcut write.", + definition.Id); + return; + } + + await launcher.WriteShortcutAsync(trigger, customId, ct); + } + + /// + public async Task OnBindingRemovedAsync( + TaskTriggerBindingDescriptor binding, + CancellationToken ct) + { + var customId = binding.TriggerValue; + if (string.IsNullOrWhiteSpace(customId)) + { + customId = binding.TaskDefinitionId.ToString(); + } + + await launcher.RemoveShortcutsAsync(customId, ct); + } +} diff --git a/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs index afba3db3..ede5b954 100644 --- a/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs +++ b/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs @@ -67,16 +67,16 @@ private async Task PollAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var name = ctx.Definition.ProcessName; + var name = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName); if (string.IsNullOrWhiteSpace(name)) continue; var wasRunning = seen.Contains(name); var isRunning = runningSet.Contains(name); - if (ctx.Definition.TriggerKey == "ProcessStarted" && !wasRunning && isRunning) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.ProcessStarted && !wasRunning && isRunning) await FireAsync(ctx); - if (ctx.Definition.TriggerKey == "ProcessStopped" && wasRunning && !isRunning) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.ProcessStopped && wasRunning && !isRunning) await FireAsync(ctx); } diff --git a/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs index 2bbf2aad..9d8847d4 100644 --- a/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs +++ b/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs @@ -67,7 +67,7 @@ private async Task PollAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var processName = ctx.Definition.ProcessName; + var processName = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName); if (string.IsNullOrWhiteSpace(processName)) continue; var isFocused = !string.IsNullOrEmpty(activeTitle) && @@ -76,10 +76,10 @@ private async Task PollAsync(CancellationToken ct) var wasFocused = lastFocused.GetValueOrDefault(processName, false); lastFocused[processName] = isFocused; - if (ctx.Definition.TriggerKey == "WindowFocused" && !wasFocused && isFocused) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.WindowFocused && !wasFocused && isFocused) await FireAsync(ctx); - if (ctx.Definition.TriggerKey == "WindowBlurred" && wasFocused && !isFocused) + if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.WindowBlurred && wasFocused && !isFocused) await FireAsync(ctx); } } diff --git a/DefaultModules/ContextTools/ContextToolsChatContributor.cs b/DefaultModules/ContextTools/ContextToolsChatContributor.cs new file mode 100644 index 00000000..e0120b8e --- /dev/null +++ b/DefaultModules/ContextTools/ContextToolsChatContributor.cs @@ -0,0 +1,20 @@ +using SharpClaw.Contracts.Chat; +using SharpClaw.Contracts.Modules; +using SharpClaw.Modules.ContextTools.Services; + +namespace SharpClaw.Modules.ContextTools; + +/// +/// Module-owned that surfaces +/// the ContextTools cross-thread visibility policy to the host chat +/// pipeline. The permission key (CanReadCrossThreadHistory) and +/// the underlying query both live in this module; core only invokes +/// and never names the wire string. +/// +internal sealed class ContextToolsChatContributor(ContextDataReader dataReader) + : IChatProcessingContributor +{ + public Task> GetAccessibleThreadsAsync( + Guid agentId, Guid currentChannelId, CancellationToken ct = default) + => dataReader.GetAccessibleThreadsAsync(agentId, currentChannelId, ct); +} diff --git a/DefaultModules/ContextTools/ContextToolsModule.cs b/DefaultModules/ContextTools/ContextToolsModule.cs index 87d1f0f3..52c94cb3 100644 --- a/DefaultModules/ContextTools/ContextToolsModule.cs +++ b/DefaultModules/ContextTools/ContextToolsModule.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using SharpClaw.Contracts.Chat; using SharpClaw.Contracts.Modules; using SharpClaw.Modules.ContextTools.Services; @@ -25,7 +26,9 @@ public sealed class ContextToolsModule : ISharpClawModule public void ConfigureServices(IServiceCollection services) { + services.TryAddScoped(); services.TryAddScoped(); + services.AddScoped(); } // ═══════════════════════════════════════════════════════════════ @@ -40,7 +43,7 @@ public void ConfigureServices(IServiceCollection services) public IReadOnlyList GetGlobalFlagDescriptors() => [ - new("CanReadCrossThreadHistory", "Read Cross-Thread History", "Read conversation history from other threads/channels.", "ReadCrossThreadHistoryAsync"), + new(ContextToolsPermissionKeys.CanReadCrossThreadHistory, "Read Cross-Thread History", "Read conversation history from other threads/channels.", "ReadCrossThreadHistoryAsync"), ]; // ═══════════════════════════════════════════════════════════════ diff --git a/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs b/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs new file mode 100644 index 00000000..49ed18d4 --- /dev/null +++ b/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs @@ -0,0 +1,19 @@ +namespace SharpClaw.Modules.ContextTools; + +/// +/// Module-owned global-flag keys for the context-tools module. +/// +/// The string values are the canonical wire identifiers persisted in +/// GlobalFlagDB.FlagKey. They are exposed to host/core code only +/// through the +/// contract so that core never names them inline. +/// +/// +public static class ContextToolsPermissionKeys +{ + /// + /// Grants permission to read conversation history from threads on + /// channels other than the active one. + /// + public const string CanReadCrossThreadHistory = "CanReadCrossThreadHistory"; +} diff --git a/DefaultModules/ContextTools/Services/ContextDataReader.cs b/DefaultModules/ContextTools/Services/ContextDataReader.cs new file mode 100644 index 00000000..66156051 --- /dev/null +++ b/DefaultModules/ContextTools/Services/ContextDataReader.cs @@ -0,0 +1,99 @@ +using Microsoft.EntityFrameworkCore; + +using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Persistence; + +namespace SharpClaw.Modules.ContextTools.Services; + +/// +/// A single chat message returned by . +/// Module-internal record; never crosses the host boundary. +/// +internal sealed record ChatMessageSummary( + string Role, + string Content, + string Sender, + DateTimeOffset Timestamp); + +/// +/// Module-owned read service that backs the ContextTools inline tools. +/// Reads directly from the host's +/// surface so the cross-thread visibility policy and the +/// CanReadCrossThreadHistory permission key live in the module +/// that owns them. +/// +internal sealed class ContextDataReader(ISharpClawDataContext data) +{ + public Task ThreadExistsAsync(Guid threadId, Guid channelId, CancellationToken ct = default) => + data.ChatThreads.AnyAsync(t => t.Id == threadId && t.ChannelId == channelId, ct); + + public async Task> GetThreadMessagesAsync( + Guid threadId, int maxMessages, CancellationToken ct = default) + { + return await data.ChatMessages + .Where(m => m.ThreadId == threadId) + .OrderByDescending(m => m.CreatedAt) + .Take(maxMessages) + .OrderBy(m => m.CreatedAt) + .Select(m => new ChatMessageSummary( + m.Role, + m.Content, + m.SenderUsername ?? m.SenderAgentName ?? "unknown", + m.CreatedAt)) + .ToListAsync(ct); + } + + public async Task> GetAccessibleThreadsAsync( + Guid agentId, Guid currentChannelId, CancellationToken ct = default) + { + var agentWithRole = await data.Agents + .Include(a => a.Role) + .ThenInclude(r => r!.PermissionSet) + .ThenInclude(ps => ps!.GlobalFlags) + .FirstOrDefaultAsync(a => a.Id == agentId, ct); + + var agentPs = agentWithRole?.Role?.PermissionSet; + var crossThreadKey = ContextToolsPermissionKeys.CanReadCrossThreadHistory; + if (agentPs is null || !agentPs.GlobalFlags.Any(f => f.FlagKey == crossThreadKey)) + return []; + + var isIndependent = (agentPs.GlobalFlags + .FirstOrDefault(f => f.FlagKey == crossThreadKey) + ?.Clearance ?? PermissionClearance.Unset) == PermissionClearance.Independent; + + var channels = await data.Channels + .Include(c => c.AllowedAgents) + .Include(c => c.PermissionSet) + .ThenInclude(ps => ps!.GlobalFlags) + .Include(c => c.AgentContext) + .ThenInclude(ctx => ctx!.PermissionSet) + .ThenInclude(ps => ps!.GlobalFlags) + .Where(c => c.Id != currentChannelId) + .Where(c => c.AgentId == agentId || c.AllowedAgents.Any(a => a.Id == agentId)) + .ToListAsync(ct); + + if (!isIndependent) + { + channels = channels + .Where(c => + { + var effectivePs = c.PermissionSet ?? c.AgentContext?.PermissionSet; + return effectivePs?.GlobalFlags.Any(f => f.FlagKey == crossThreadKey) == true; + }) + .ToList(); + } + + if (channels.Count == 0) + return []; + + var channelIds = channels.Select(c => c.Id).ToList(); + var channelTitles = channels.ToDictionary(c => c.Id, c => c.Title); + + return await data.ChatThreads + .Where(t => channelIds.Contains(t.ChannelId)) + .OrderByDescending(t => t.UpdatedAt) + .Select(t => new ThreadSummary(t.Id, t.Name, t.ChannelId, channelTitles[t.ChannelId])) + .ToListAsync(ct); + } +} diff --git a/DefaultModules/ContextTools/Services/ContextToolsService.cs b/DefaultModules/ContextTools/Services/ContextToolsService.cs index 352f263f..fe87ed5b 100644 --- a/DefaultModules/ContextTools/Services/ContextToolsService.cs +++ b/DefaultModules/ContextTools/Services/ContextToolsService.cs @@ -1,14 +1,12 @@ using System.Text.Json; -using SharpClaw.Contracts.Modules; - namespace SharpClaw.Modules.ContextTools.Services; /// /// Wraps cross-thread context and utility operations for the /// Context Tools module (inline tools). /// -internal sealed class ContextToolsService(IContextDataReader dataReader) +internal sealed class ContextToolsService(ContextDataReader dataReader) { public static async Task WaitAsync( JsonElement parameters, CancellationToken ct) diff --git a/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs b/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs index 14612e11..3dbb26be 100644 --- a/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs +++ b/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs @@ -23,8 +23,10 @@ namespace SharpClaw.Modules.DatabaseAccess; /// Supports PostgreSQL, MySQL, SQLite, and MSSQL with read-only /// safety by default. /// -public sealed class DatabaseAccessModule : ISharpClawModule +public sealed class DatabaseAccessModule : ISharpClawModule, ITaskParserAware { + public ITaskParserModuleExtension ParserExtension => DatabaseAccessParserExtension.Instance; + public string Id => "sharpclaw_database_access"; public string DisplayName => "Database Access"; public string ToolPrefix => "db"; diff --git a/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs b/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs new file mode 100644 index 00000000..09f7aba4 --- /dev/null +++ b/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs @@ -0,0 +1,22 @@ +using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.DatabaseAccess.Triggers; + +namespace SharpClaw.Modules.DatabaseAccess; + +/// Parser extension exposing module-owned trigger-attribute handlers. +public sealed class DatabaseAccessParserExtension : ITaskParserModuleExtension +{ + public static readonly DatabaseAccessParserExtension Instance = new(); + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + DatabaseAccessTriggerAttributeHandlers.All; +} diff --git a/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs new file mode 100644 index 00000000..dee5bbc6 --- /dev/null +++ b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs @@ -0,0 +1,57 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.DatabaseAccess.Triggers; + +/// +/// Module-owned for +/// [OnQueryReturnsRows]. Behavior preserved verbatim from the legacy +/// core parser switch, including the TASK431 SELECT COUNT(*) shape warning. +/// +internal static class DatabaseAccessTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnQueryReturnsRows"] = new OnQueryReturnsRowsHandler(), + }; + + private sealed class OnQueryReturnsRowsHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var query = context.GetStringArg(0); + var interval = context.GetNamedIntArg("PollInterval"); + + if (!IsSelectCountQuery(query)) + { + context.Report( + TaskTriggerAttributeDiagnosticSeverity.Warning, + "TASK431", + "[OnQueryReturnsRows] query should be a SELECT COUNT(*) expression " + + "to avoid unintended side-effects."); + } + + var p = new Dictionary(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(query)) + p[DatabaseAccessTriggerKeys.SqlQuery] = query; + if (interval.HasValue) + p[DatabaseAccessTriggerKeys.QueryPollIntervalSecs] = interval.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return new TaskTriggerDefinition + { + TriggerKey = DatabaseAccessTriggerKeys.QueryReturnsRows, + Parameters = p, + }; + } + + private static bool IsSelectCountQuery(string? query) + { + if (string.IsNullOrWhiteSpace(query)) + return false; + + var normalized = query.Replace('\n', ' ').Replace('\r', ' '); + var upper = normalized.Trim().ToUpperInvariant(); + return upper.StartsWith("SELECT COUNT(", StringComparison.Ordinal); + } + } +} diff --git a/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs new file mode 100644 index 00000000..dd3db1c7 --- /dev/null +++ b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs @@ -0,0 +1,17 @@ +namespace SharpClaw.Modules.DatabaseAccess.Triggers; + +/// +/// Trigger and parameter keys owned by the database-access module's +/// query-rows trigger. String values mirror the legacy +/// TaskTriggerDefinition property names verbatim so persisted +/// binding rows and serialized scripts continue to round-trip. +/// +public static class DatabaseAccessTriggerKeys +{ + public const string QueryReturnsRows = "QueryReturnsRows"; + + // Parameter names — must match the legacy TaskTriggerDefinition + // property names so existing on-disk JSON keeps deserialising. + public const string SqlQuery = "SqlQuery"; + public const string QueryPollIntervalSecs = "QueryPollIntervalSecs"; +} diff --git a/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs b/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs index 8feca5ee..0b372d46 100644 --- a/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs +++ b/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs @@ -53,7 +53,7 @@ private async Task PollAsync(CancellationToken ct) try { var minInterval = _contexts - .Select(c => c.Definition.QueryPollIntervalSecs ?? 60) + .Select(c => TryParseInt(c.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.QueryPollIntervalSecs)) ?? 60) .DefaultIfEmpty(60) .Min(); @@ -61,7 +61,7 @@ private async Task PollAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var sql = ctx.Definition.SqlQuery; + var sql = ctx.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.SqlQuery); if (string.IsNullOrWhiteSpace(sql)) continue; try @@ -93,4 +93,8 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx) "QueryRowsTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId); } } + + private static int? TryParseInt(string? value) => + int.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null; } diff --git a/DefaultModules/EditorCommon/Services/EditorBridgeService.cs b/DefaultModules/EditorCommon/Services/EditorBridgeService.cs index 71e72786..565a98e3 100644 --- a/DefaultModules/EditorCommon/Services/EditorBridgeService.cs +++ b/DefaultModules/EditorCommon/Services/EditorBridgeService.cs @@ -15,7 +15,7 @@ namespace SharpClaw.Modules.EditorCommon.Services; /// connected editor and waits for responses. /// /// On connect the service auto-creates (or reuses) an -/// +/// /// resource so agents can reference it immediately. On disconnect the /// ConnectionId is cleared but the resource persists for /// permission/default references. diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs b/DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs similarity index 70% rename from SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs rename to DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs index 87b4be85..e73c6d65 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs +++ b/DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs @@ -1,12 +1,18 @@ -using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.Extensions.Logging; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.FilesystemTriggers; /// /// Trigger source that fires when files under a watched path are created, /// changed, deleted, or renamed via . +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. The persisted trigger-key value +/// () remains +/// "FileChanged" so existing binding rows continue to round-trip. +/// /// public sealed class FileChangedTriggerSource( ILogger logger) : ITaskTriggerSource, IAsyncDisposable @@ -14,7 +20,7 @@ public sealed class FileChangedTriggerSource( private readonly List _watchers = []; private IReadOnlyList _contexts = []; - public string TriggerKey => WellKnownTriggerKeys.FileChanged; + public string TriggerKey => FilesystemTriggerKeys.FileChanged; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { @@ -23,7 +29,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel foreach (var ctx in contexts) { - var path = ctx.Definition.WatchPath; + var path = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath); if (string.IsNullOrWhiteSpace(path)) { logger.LogWarning( @@ -33,7 +39,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel } var dir = Path.GetDirectoryName(path) ?? path; - var pattern = ctx.Definition.FilePattern ?? "*"; + var pattern = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FilePattern) ?? "*"; if (!Directory.Exists(dir)) { @@ -48,7 +54,9 @@ public Task StartAsync(IReadOnlyList contexts, Cancel EnableRaisingEvents = false, }; - var events = ctx.Definition.FileEvents == 0 ? FileWatchEvent.Any : ctx.Definition.FileEvents; + var rawEvents = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FileEvents); + var events = ParseFileEvents(rawEvents); + if (events == 0) events = FileWatchEvent.Any; if (events.HasFlag(FileWatchEvent.Created)) watcher.Created += (_, _) => Fire(ctx); if (events.HasFlag(FileWatchEvent.Changed)) watcher.Changed += (_, _) => Fire(ctx); @@ -69,6 +77,12 @@ public Task StopAsync() return Task.CompletedTask; } + /// + public string? GetBindingValue(TaskTriggerDefinition def) => + def.TriggerKey == FilesystemTriggerKeys.FileChanged + ? def.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath) + : null; + public ValueTask DisposeAsync() { StopWatchers(); @@ -104,4 +118,7 @@ private void Fire(ITaskTriggerSourceContext ctx) => ctx.TaskDefinitionId); } }); + + private static FileWatchEvent ParseFileEvents(string? raw) => + Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : 0; } diff --git a/SharpClaw.Contracts/Tasks/FileWatchEvent.cs b/DefaultModules/FilesystemTriggers/FileWatchEvent.cs similarity index 84% rename from SharpClaw.Contracts/Tasks/FileWatchEvent.cs rename to DefaultModules/FilesystemTriggers/FileWatchEvent.cs index 2ecf2e2c..3e320cd6 100644 --- a/SharpClaw.Contracts/Tasks/FileWatchEvent.cs +++ b/DefaultModules/FilesystemTriggers/FileWatchEvent.cs @@ -1,4 +1,4 @@ -namespace SharpClaw.Contracts.Tasks; +namespace SharpClaw.Modules.FilesystemTriggers; /// /// File-system events that the [OnFileChanged] trigger watches for. diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs new file mode 100644 index 00000000..829b8a8e --- /dev/null +++ b/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs @@ -0,0 +1,39 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.FilesystemTriggers; + +/// +/// Module-owned for +/// [OnFileChanged]. Behavior preserved verbatim from the legacy +/// core parser switch. +/// +internal static class FilesystemTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnFileChanged"] = new OnFileChangedHandler(), + }; + + private sealed class OnFileChangedHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var watchPath = context.GetStringArg(0); + if (!string.IsNullOrEmpty(watchPath)) + p[FilesystemTriggerKeys.WatchPath] = watchPath; + var pattern = context.GetNamedStringArg("Pattern"); + if (!string.IsNullOrEmpty(pattern)) + p[FilesystemTriggerKeys.FilePattern] = pattern; + var events = context.GetNamedEnumArg("Events") ?? FileWatchEvent.Any; + if (events != default) + p[FilesystemTriggerKeys.FileEvents] = events.ToString(); + return new TaskTriggerDefinition + { + TriggerKey = FilesystemTriggerKeys.FileChanged, + Parameters = p, + }; + } + } +} diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs new file mode 100644 index 00000000..bfabced8 --- /dev/null +++ b/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs @@ -0,0 +1,17 @@ +namespace SharpClaw.Modules.FilesystemTriggers; + +/// +/// Trigger and parameter keys owned by the filesystem-triggers module. +/// String values are persisted verbatim in binding rows and serialized +/// scripts. +/// +public static class FilesystemTriggerKeys +{ + /// Trigger-key value persisted in TaskTriggerBindingDB.Kind. + public const string FileChanged = "FileChanged"; + + // Parameter names — must match TaskTriggerDefinition property names. + public const string WatchPath = "WatchPath"; + public const string FilePattern = "FilePattern"; + public const string FileEvents = "FileEvents"; +} diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs new file mode 100644 index 00000000..dcbf8b44 --- /dev/null +++ b/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.FilesystemTriggers; + +/// +/// Default module that owns the FileChanged task trigger. +/// Scaffolded in Phase 1 of the trigger-extraction plan; the +/// FileChangedTriggerSource implementation is moved into this +/// module in Phase 3. +/// +public sealed class FilesystemTriggersModule : ISharpClawModule, ITaskParserAware +{ + public ITaskParserModuleExtension ParserExtension => FilesystemTriggersParserExtension.Instance; + + public string Id => "sharpclaw_filesystem_triggers"; + public string DisplayName => "Filesystem Triggers"; + public string ToolPrefix => "fstrigger"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public IReadOnlyList GetToolDefinitions() => []; + + public Task ExecuteToolAsync( + string toolName, + JsonElement parameters, + AgentJobContext job, + IServiceProvider scopedServices, + CancellationToken ct) => + throw new InvalidOperationException( + $"FilesystemTriggers module has no job-pipeline tools. Unknown: '{toolName}'."); +} diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs new file mode 100644 index 00000000..35c0f312 --- /dev/null +++ b/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs @@ -0,0 +1,21 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.FilesystemTriggers; + +/// Parser extension exposing module-owned trigger-attribute handlers. +public sealed class FilesystemTriggersParserExtension : ITaskParserModuleExtension +{ + public static readonly FilesystemTriggersParserExtension Instance = new(); + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + FilesystemTriggerAttributeHandlers.All; +} diff --git a/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj b/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj new file mode 100644 index 00000000..fc6c8441 --- /dev/null +++ b/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/DefaultModules/FilesystemTriggers/module.json b/DefaultModules/FilesystemTriggers/module.json new file mode 100644 index 00000000..c8252584 --- /dev/null +++ b/DefaultModules/FilesystemTriggers/module.json @@ -0,0 +1,17 @@ +{ + "id": "sharpclaw_filesystem_triggers", + "displayName": "Filesystem Triggers", + "version": "1.0.0", + "toolPrefix": "fstrigger", + "entryAssembly": "SharpClaw.Modules.FilesystemTriggers", + "minHostVersion": "1.0.0", + "author": "SharpClaw Team", + "description": "Owns the FileChanged task trigger and the underlying FileSystemWatcher-based trigger source.", + "license": "AGPL-3.0", + "platforms": null, + "enabled": true, + "defaultEnabled": true, + "executionTimeoutSeconds": 60, + "exports": [], + "requires": [] +} diff --git a/DefaultModules/Http/HttpModule.cs b/DefaultModules/Http/HttpModule.cs new file mode 100644 index 00000000..34d1de0a --- /dev/null +++ b/DefaultModules/Http/HttpModule.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Http; + +/// +/// Default module that owns the task-script HTTP request step +/// (HttpGet/HttpPost/HttpPut/HttpDelete → http_request). +/// +public sealed class HttpModule : ISharpClawModule, ITaskParserAware +{ + public ITaskParserModuleExtension ParserExtension => HttpParserExtension.Instance; + + public string Id => "sharpclaw_http"; + public string DisplayName => "HTTP"; + public string ToolPrefix => "http"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + public IReadOnlyList GetToolDefinitions() => []; + + public Task ExecuteToolAsync( + string toolName, + JsonElement parameters, + AgentJobContext job, + IServiceProvider scopedServices, + CancellationToken ct) => + throw new InvalidOperationException( + $"HTTP module has no job-pipeline tools. Unknown: '{toolName}'."); +} diff --git a/DefaultModules/Http/HttpParserExtension.cs b/DefaultModules/Http/HttpParserExtension.cs new file mode 100644 index 00000000..97fee14d --- /dev/null +++ b/DefaultModules/Http/HttpParserExtension.cs @@ -0,0 +1,21 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Http; + +/// Parser extension exposing module-owned trigger-attribute handlers. +public sealed class HttpParserExtension : ITaskParserModuleExtension +{ + public static readonly HttpParserExtension Instance = new(); + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + HttpTriggerAttributeHandlers.All; +} diff --git a/DefaultModules/Http/HttpStepDescriptorProvider.cs b/DefaultModules/Http/HttpStepDescriptorProvider.cs new file mode 100644 index 00000000..64f199a9 --- /dev/null +++ b/DefaultModules/Http/HttpStepDescriptorProvider.cs @@ -0,0 +1,55 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Http; + +/// +/// Contributes the HTTP-request step descriptors owned by the HTTP module +/// to the central task step registry. The four script verbs (HttpGet, +/// HttpPost, HttpPut, HttpDelete) all share a single step key. +/// +public sealed class HttpStepDescriptorProvider : ITaskStepDescriptorProvider +{ + public string ModuleId => "sharpclaw_http"; + + public IReadOnlyList Descriptors { get; } = Build(); + + private static TaskStepDescriptor[] Build() + { + const string owner = "sharpclaw_http"; + return + [ + new TaskStepDescriptor + { + MethodName = "HttpGet", + StepKey = HttpStepKeys.HttpRequest, + OwnerId = owner, + PrefixArgument = "GET", + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "HttpPost", + StepKey = HttpStepKeys.HttpRequest, + OwnerId = owner, + PrefixArgument = "POST", + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "HttpPut", + StepKey = HttpStepKeys.HttpRequest, + OwnerId = owner, + PrefixArgument = "PUT", + FirstArgIsExpression = true, + }, + new TaskStepDescriptor + { + MethodName = "HttpDelete", + StepKey = HttpStepKeys.HttpRequest, + OwnerId = owner, + PrefixArgument = "DELETE", + FirstArgIsExpression = true, + }, + ]; + } +} diff --git a/DefaultModules/Http/HttpStepKeys.cs b/DefaultModules/Http/HttpStepKeys.cs new file mode 100644 index 00000000..1ae2a81a --- /dev/null +++ b/DefaultModules/Http/HttpStepKeys.cs @@ -0,0 +1,16 @@ +namespace SharpClaw.Modules.Http; + +/// +/// Stable well-known step keys owned by the HTTP module. +/// +/// IMPORTANT: The literal string value intentionally matches the legacy +/// core.http_request value so existing serialized task scripts +/// continue to parse. Only the C# location of the constant changes; the +/// wire format does not. +/// +/// +public static class HttpStepKeys +{ + /// Make an HTTP request (shared by HttpGet/HttpPost/HttpPut/HttpDelete). + public const string HttpRequest = "core.http_request"; +} diff --git a/DefaultModules/Http/HttpTaskStepExecutor.cs b/DefaultModules/Http/HttpTaskStepExecutor.cs new file mode 100644 index 00000000..06cbb64f --- /dev/null +++ b/DefaultModules/Http/HttpTaskStepExecutor.cs @@ -0,0 +1,71 @@ +using System.Net.Http; +using System.Text; + +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Http; + +/// +/// Module-side executor for the HTTP request task step +/// (). Resolves +/// from the running task's scoped +/// services and performs the request without any direct dependency on +/// Core or Infrastructure types. +/// +/// +/// The HTTP step descriptors declare the verb via +/// TaskStepDescriptor.PrefixArgument; the parser prepends it to +/// the parsed step's argument list so the host-side step contract +/// stays free of HTTP-specific properties. +/// +public sealed class HttpTaskStepExecutor : ITaskStepExecutorExtension +{ + public string ModuleId => "sharpclaw_http"; + + public bool CanExecute(string moduleStepKey) => + moduleStepKey == HttpStepKeys.HttpRequest; + + public async Task ExecuteAsync( + string moduleStepKey, + ITaskStepExecutionContext context, + IReadOnlyList? arguments, + string? expression, + string? resultVariable) + { + if (moduleStepKey != HttpStepKeys.HttpRequest) + return false; + + var url = expression ?? string.Empty; + + // The parser prepends descriptor.PrefixArgument (the HTTP verb) as + // the first argument; arguments[1..] are the call's resolved + // positional arguments (arg[1] = URL, arg[2] = body when present). + var method = arguments is { Count: > 0 } ? arguments[0].ToUpperInvariant() : "GET"; + + var factory = context.Services.GetRequiredService(); + using var client = factory.CreateClient("TaskOrchestrator"); + + using var request = new HttpRequestMessage(new HttpMethod(method), url); + if (method is "POST" or "PUT") + { + var body = arguments is { Count: > 2 } + ? arguments[2] + : arguments is { Count: > 1 } + ? arguments[1] + : string.Empty; + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + using var response = await client.SendAsync(request, context.CancellationToken); + var content = await response.Content.ReadAsStringAsync(context.CancellationToken); + + await context.AppendLogAsync($"HTTP {method} {url} → {(int)response.StatusCode}"); + + if (resultVariable is not null) + context.Variables[resultVariable] = content; + + return true; + } +} diff --git a/DefaultModules/Http/HttpTriggerAttributeHandlers.cs b/DefaultModules/Http/HttpTriggerAttributeHandlers.cs new file mode 100644 index 00000000..a50761ce --- /dev/null +++ b/DefaultModules/Http/HttpTriggerAttributeHandlers.cs @@ -0,0 +1,41 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Http; + +/// +/// Module-owned for +/// [OnWebhook]. Behavior preserved verbatim from the legacy core +/// parser switch. The cross-attribute [WebhookSecret]/[OnWebhook] +/// presence check (TASK428) remains in the core parser because it spans +/// multiple attributes on the class. +/// +internal static class HttpTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnWebhook"] = new OnWebhookHandler(), + }; + + private sealed class OnWebhookHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var route = context.GetStringArg(0); + if (!string.IsNullOrEmpty(route)) + p[HttpTriggerKeys.WebhookRoute] = route; + var secret = context.GetNamedStringArg("Secret"); + if (!string.IsNullOrEmpty(secret)) + p[HttpTriggerKeys.WebhookSecretEnvVar] = secret; + var sigHeader = context.GetNamedStringArg("SignatureHeader"); + if (!string.IsNullOrEmpty(sigHeader)) + p[HttpTriggerKeys.WebhookSignatureHeader] = sigHeader; + return new TaskTriggerDefinition + { + TriggerKey = HttpTriggerKeys.Webhook, + Parameters = p, + }; + } + } +} diff --git a/DefaultModules/Http/HttpTriggerKeys.cs b/DefaultModules/Http/HttpTriggerKeys.cs new file mode 100644 index 00000000..fb924c15 --- /dev/null +++ b/DefaultModules/Http/HttpTriggerKeys.cs @@ -0,0 +1,17 @@ +namespace SharpClaw.Modules.Http; + +/// +/// Trigger and parameter keys owned by the HTTP module. +/// String values are persisted verbatim in binding rows and serialized +/// scripts. +/// +public static class HttpTriggerKeys +{ + /// Trigger-key value persisted in TaskTriggerBindingDB.Kind. + public const string Webhook = "Webhook"; + + // Parameter names — must match TaskTriggerDefinition property names. + public const string WebhookRoute = "WebhookRoute"; + public const string WebhookSecretEnvVar = "WebhookSecretEnvVar"; + public const string WebhookSignatureHeader = "WebhookSignatureHeader"; +} diff --git a/DefaultModules/Http/SharpClaw.Modules.Http.csproj b/DefaultModules/Http/SharpClaw.Modules.Http.csproj new file mode 100644 index 00000000..9a0929a2 --- /dev/null +++ b/DefaultModules/Http/SharpClaw.Modules.Http.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + true + + + + + + + + + + + + + + + diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs b/DefaultModules/Http/WebhookTriggerSource.cs similarity index 83% rename from SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs rename to DefaultModules/Http/WebhookTriggerSource.cs index 123c63a1..0d8c13b0 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs +++ b/DefaultModules/Http/WebhookTriggerSource.cs @@ -1,22 +1,27 @@ -using SharpClaw.Application.Infrastructure.Tasks; using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; + using Microsoft.Extensions.Logging; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.Http; /// -/// Trigger source for bindings. +/// Trigger source for bindings. /// Registers one dynamic POST route per active binding at startup via /// , and maintains an in-memory /// registry so that disabled bindings return 404 without requiring a /// route to be unregistered (minimal-API routes cannot be removed at /// runtime). +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// /// -public sealed class WebhookTriggerSource : ITaskTriggerSource +public sealed class WebhookTriggerSource : ITaskTriggerSource, IWebhookTriggerHost { private readonly ILogger _logger; private IWebhookRouteRegistrar? _routeRegistrar; @@ -46,7 +51,7 @@ public void SetRouteRegistrar(IWebhookRouteRegistrar registrar) // ── ITaskTriggerSource ──────────────────────────────────────── /// - public string TriggerKey => WellKnownTriggerKeys.Webhook; + public string TriggerKey => HttpTriggerKeys.Webhook; /// public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) @@ -56,7 +61,8 @@ public Task StartAsync(IReadOnlyList contexts, Cancel foreach (var ctx in contexts) { var def = ctx.Definition; - if (string.IsNullOrWhiteSpace(def.WebhookRoute)) + var route = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute); + if (string.IsNullOrWhiteSpace(route)) { _logger.LogWarning( "Webhook binding for task {TaskId} has no WebhookRoute — skipping.", @@ -64,7 +70,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel continue; } - var routePath = NormalizeRoute(def.WebhookRoute); + var routePath = NormalizeRoute(route); _activeRoutes[routePath] = ctx; // Register the route once; subsequent bindings to the same path @@ -87,6 +93,12 @@ public Task StopAsync() return Task.CompletedTask; } + /// + public string? GetBindingValue(TaskTriggerDefinition def) => + def.TriggerKey == HttpTriggerKeys.Webhook + ? def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute) + : null; + // ── Public helpers ──────────────────────────────────────────── /// @@ -105,20 +117,22 @@ public async Task HandleRequestAsync( var def = ctx.Definition; // HMAC-SHA256 validation - if (!string.IsNullOrWhiteSpace(def.WebhookSecretEnvVar)) + var secretEnvVar = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSecretEnvVar); + if (!string.IsNullOrWhiteSpace(secretEnvVar)) { - var secret = Environment.GetEnvironmentVariable(def.WebhookSecretEnvVar); + var secret = Environment.GetEnvironmentVariable(secretEnvVar); if (string.IsNullOrEmpty(secret)) { _logger.LogWarning( "Webhook {Route}: secret env var '{Var}' is not set — rejecting request.", - routePath, def.WebhookSecretEnvVar); + routePath, secretEnvVar); return 401; } - var sigHeader = string.IsNullOrWhiteSpace(def.WebhookSignatureHeader) + var configuredSigHeader = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSignatureHeader); + var sigHeader = string.IsNullOrWhiteSpace(configuredSigHeader) ? DefaultSignatureHeader - : def.WebhookSignatureHeader; + : configuredSigHeader; var providedSig = ExtractHeader(headersJson, sigHeader); if (!ValidateHmac(body, secret, providedSig)) diff --git a/DefaultModules/Http/module.json b/DefaultModules/Http/module.json new file mode 100644 index 00000000..cf58f363 --- /dev/null +++ b/DefaultModules/Http/module.json @@ -0,0 +1,17 @@ +{ + "id": "sharpclaw_http", + "displayName": "HTTP", + "version": "1.0.0", + "toolPrefix": "http", + "entryAssembly": "SharpClaw.Modules.Http", + "minHostVersion": "1.0.0", + "author": "SharpClaw Team", + "description": "Owns the task-script HTTP request step (HttpGet/HttpPost/HttpPut/HttpDelete → http_request).", + "license": "AGPL-3.0", + "platforms": null, + "enabled": true, + "defaultEnabled": true, + "executionTimeoutSeconds": 60, + "exports": [], + "requires": [] +} diff --git a/DefaultModules/Metrics/BuiltInMetricProviders.cs b/DefaultModules/Metrics/BuiltInMetricProviders.cs new file mode 100644 index 00000000..42550f8d --- /dev/null +++ b/DefaultModules/Metrics/BuiltInMetricProviders.cs @@ -0,0 +1,36 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Metrics; + +/// Reports the number of pending (Queued) agent jobs (Queue.PendingJobCount). +public sealed class PendingJobCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider +{ + public string MetricName => "Queue.PendingJobCount"; + public string Description => "Number of agent jobs currently in the Queued state."; + + public Task GetValueAsync(CancellationToken ct) => + host.GetPendingJobCountAsync(ct); +} + +/// Reports the number of pending (Queued) task instances (Queue.PendingTaskCount). +public sealed class PendingTaskCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider +{ + public string MetricName => "Queue.PendingTaskCount"; + public string Description => "Number of task instances currently in the Queued state."; + + public Task GetValueAsync(CancellationToken ct) => + host.GetPendingTaskCountAsync(ct); +} + +/// +/// Reports the number of scheduled jobs with a past NextRunAt +/// that have not yet run (Scheduler.PendingJobCount). +/// +public sealed class SchedulerPendingJobCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider +{ + public string MetricName => "Scheduler.PendingJobCount"; + public string Description => "Number of scheduled jobs past their NextRunAt time that have not been triggered."; + + public Task GetValueAsync(CancellationToken ct) => + host.GetSchedulerPendingJobCountAsync(ct); +} diff --git a/DefaultModules/Metrics/MetricTriggerKeys.cs b/DefaultModules/Metrics/MetricTriggerKeys.cs new file mode 100644 index 00000000..109dca5f --- /dev/null +++ b/DefaultModules/Metrics/MetricTriggerKeys.cs @@ -0,0 +1,16 @@ +namespace SharpClaw.Modules.Metrics; + +/// +/// Trigger and parameter keys owned by the metrics module. String values +/// are persisted verbatim in binding rows and serialized scripts. +/// +public static class MetricTriggerKeys +{ + public const string MetricThreshold = "MetricThreshold"; + + // Parameter names — must match TaskTriggerDefinition property names. + public const string Source = "MetricSource"; + public const string Threshold = "MetricThreshold"; + public const string Direction = "MetricDirection"; + public const string PollIntervalSecs = "MetricPollIntervalSecs"; +} diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs b/DefaultModules/Metrics/MetricTriggerSource.cs similarity index 74% rename from SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs rename to DefaultModules/Metrics/MetricTriggerSource.cs index f7107187..7b99537c 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs +++ b/DefaultModules/Metrics/MetricTriggerSource.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.Extensions.Logging; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.Metrics; /// /// Fires when the value of a named metric crosses a configured threshold. @@ -17,7 +17,7 @@ public sealed class MetricTriggerSource( private Task? _pollTask; private IReadOnlyList _contexts = []; - public string TriggerKey => WellKnownTriggerKeys.MetricThreshold; + public string TriggerKey => MetricTriggerKeys.MetricThreshold; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { @@ -45,6 +45,8 @@ public async Task StopAsync() _contexts = []; } + public string? GetBindingValue(TaskTriggerDefinition t) => t.Parameters.GetValueOrDefault(MetricTriggerKeys.Source); + public async ValueTask DisposeAsync() => await StopAsync(); private async Task PollAsync(CancellationToken ct) @@ -61,10 +63,11 @@ private async Task PollAsync(CancellationToken ct) foreach (var ctx in _contexts) { var def = ctx.Definition; - if (string.IsNullOrWhiteSpace(def.MetricSource)) continue; + var source = def.Parameters.GetValueOrDefault(MetricTriggerKeys.Source); + if (string.IsNullOrWhiteSpace(source)) continue; var provider = _providers.FirstOrDefault(p => - string.Equals(p.MetricName, def.MetricSource, StringComparison.OrdinalIgnoreCase)); + string.Equals(p.MetricName, source, StringComparison.OrdinalIgnoreCase)); if (provider is null) continue; @@ -76,8 +79,9 @@ private async Task PollAsync(CancellationToken ct) continue; } - var threshold = def.MetricThreshold ?? 0; - var crossed = def.MetricDirection switch + var threshold = TryParseDouble(def.Parameters.GetValueOrDefault(MetricTriggerKeys.Threshold)) ?? 0; + var direction = ParseDirection(def.Parameters.GetValueOrDefault(MetricTriggerKeys.Direction)); + var crossed = direction switch { ThresholdDirection.Above => value > threshold, ThresholdDirection.Below => value < threshold, @@ -109,4 +113,13 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx) "MetricTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId); } } + + private static double? TryParseDouble(string? value) => + double.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : null; + + private static ThresholdDirection ParseDirection(string? value) => + Enum.TryParse(value, ignoreCase: true, out var parsed) + ? parsed + : default; } diff --git a/DefaultModules/Metrics/MetricsModule.cs b/DefaultModules/Metrics/MetricsModule.cs new file mode 100644 index 00000000..85c514f7 --- /dev/null +++ b/DefaultModules/Metrics/MetricsModule.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Metrics; + +/// +/// Default module that owns the MetricThreshold task trigger and the +/// built-in implementations. Scaffolded in +/// Phase 1 of the trigger-extraction plan; MetricTriggerSource and the +/// three providers from BuiltInMetricProviders.cs move here in Phase 4. +/// +public sealed class MetricsModule : ISharpClawModule, ITaskParserAware +{ + public ITaskParserModuleExtension ParserExtension => MetricsParserExtension.Instance; + + public string Id => "sharpclaw_metrics"; + public string DisplayName => "Metrics"; + public string ToolPrefix => "metric"; + + public void ConfigureServices(IServiceCollection services) + { + // Built-in providers consume IHostQueueMetrics (forwarded from the host) + // so the module does not depend on SharpClawDbContext directly. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + public IReadOnlyList GetToolDefinitions() => []; + + public Task ExecuteToolAsync( + string toolName, + JsonElement parameters, + AgentJobContext job, + IServiceProvider scopedServices, + CancellationToken ct) => + throw new InvalidOperationException( + $"Metrics module has no job-pipeline tools. Unknown: '{toolName}'."); +} diff --git a/DefaultModules/Metrics/MetricsParserExtension.cs b/DefaultModules/Metrics/MetricsParserExtension.cs new file mode 100644 index 00000000..bc1190f7 --- /dev/null +++ b/DefaultModules/Metrics/MetricsParserExtension.cs @@ -0,0 +1,21 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Metrics; + +/// Parser extension exposing module-owned trigger-attribute handlers. +public sealed class MetricsParserExtension : ITaskParserModuleExtension +{ + public static readonly MetricsParserExtension Instance = new(); + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + MetricsTriggerAttributeHandlers.All; +} diff --git a/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs b/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs new file mode 100644 index 00000000..01a35a2d --- /dev/null +++ b/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs @@ -0,0 +1,41 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.Metrics; + +/// +/// Module-owned for +/// [OnMetricThreshold]. Behavior preserved verbatim from the legacy +/// core parser switch. +/// +internal static class MetricsTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnMetricThreshold"] = new OnMetricThresholdHandler(), + }; + + private sealed class OnMetricThresholdHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var source = context.GetStringArg(0); + if (!string.IsNullOrEmpty(source)) + p[MetricTriggerKeys.Source] = source; + var threshold = context.GetNamedDoubleArg("Threshold"); + if (threshold.HasValue) + p[MetricTriggerKeys.Threshold] = threshold.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + var direction = context.GetNamedEnumArg("Direction") ?? ThresholdDirection.Either; + p[MetricTriggerKeys.Direction] = direction.ToString(); + var pollInterval = context.GetNamedIntArg("PollInterval"); + if (pollInterval.HasValue) + p[MetricTriggerKeys.PollIntervalSecs] = pollInterval.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new TaskTriggerDefinition + { + TriggerKey = MetricTriggerKeys.MetricThreshold, + Parameters = p, + }; + } + } +} diff --git a/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj b/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj new file mode 100644 index 00000000..3b476f27 --- /dev/null +++ b/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/DefaultModules/Metrics/module.json b/DefaultModules/Metrics/module.json new file mode 100644 index 00000000..59e23aa7 --- /dev/null +++ b/DefaultModules/Metrics/module.json @@ -0,0 +1,17 @@ +{ + "id": "sharpclaw_metrics", + "displayName": "Metrics", + "version": "1.0.0", + "toolPrefix": "metric", + "entryAssembly": "SharpClaw.Modules.Metrics", + "minHostVersion": "1.0.0", + "author": "SharpClaw Team", + "description": "Owns the MetricThreshold task trigger and the built-in metric providers.", + "license": "AGPL-3.0", + "platforms": null, + "enabled": true, + "defaultEnabled": true, + "executionTimeoutSeconds": 60, + "exports": [], + "requires": [] +} diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs b/DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs similarity index 74% rename from SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs rename to DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs index a70ce50c..5f2c58b1 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs +++ b/DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs @@ -1,13 +1,19 @@ using System.Net.NetworkInformation; using System.Net.Sockets; + using Microsoft.Extensions.Logging; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.NetworkTriggers; /// /// Fires when a monitored host becomes reachable or unreachable, checked via /// TCP connect or ICMP ping on a timer. +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// /// public sealed class HostProbeTriggerSource( ILogger logger) : ITaskTriggerSource, IAsyncDisposable @@ -17,7 +23,7 @@ public sealed class HostProbeTriggerSource( private IReadOnlyList _contexts = []; public IReadOnlyList TriggerKeys { get; } = - [WellKnownTriggerKeys.HostReachable, WellKnownTriggerKeys.HostUnreachable]; + [NetworkTriggerKeys.HostReachable, NetworkTriggerKeys.HostUnreachable]; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { @@ -45,6 +51,14 @@ public async Task StopAsync() _contexts = []; } + /// + public string? GetBindingValue(TaskTriggerDefinition def) => def.TriggerKey switch + { + NetworkTriggerKeys.HostReachable or NetworkTriggerKeys.HostUnreachable => + def.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName), + _ => null, + }; + public async ValueTask DisposeAsync() => await StopAsync(); private async Task ProbeLoopAsync(CancellationToken ct) @@ -60,20 +74,20 @@ private async Task ProbeLoopAsync(CancellationToken ct) foreach (var ctx in _contexts) { - var host = ctx.Definition.HostName; + var host = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName); if (string.IsNullOrWhiteSpace(host)) continue; - var port = ctx.Definition.HostPort; + var port = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostPort)); var key = port.HasValue ? $"{host}:{port}" : host; var reachable = await IsReachableAsync(host, port, ct); var wasReachable = lastState.GetValueOrDefault(key, !reachable); lastState[key] = reachable; - if (ctx.Definition.TriggerKey == WellKnownTriggerKeys.HostReachable && !wasReachable && reachable) + if (ctx.Definition.TriggerKey == NetworkTriggerKeys.HostReachable && !wasReachable && reachable) await FireAsync(ctx); - if (ctx.Definition.TriggerKey == WellKnownTriggerKeys.HostUnreachable && wasReachable && !reachable) + if (ctx.Definition.TriggerKey == NetworkTriggerKeys.HostUnreachable && wasReachable && !reachable) await FireAsync(ctx); } } @@ -118,4 +132,8 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx) "HostProbeTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId); } } + + private static int? TryParseInt(string? value) => + int.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null; } diff --git a/SharpClaw.Contracts/Tasks/NetworkState.cs b/DefaultModules/NetworkTriggers/NetworkState.cs similarity index 77% rename from SharpClaw.Contracts/Tasks/NetworkState.cs rename to DefaultModules/NetworkTriggers/NetworkState.cs index a91e9926..e69dc949 100644 --- a/SharpClaw.Contracts/Tasks/NetworkState.cs +++ b/DefaultModules/NetworkTriggers/NetworkState.cs @@ -1,4 +1,4 @@ -namespace SharpClaw.Contracts.Tasks; +namespace SharpClaw.Modules.NetworkTriggers; /// Network availability state used by the [OnNetworkChanged] trigger. public enum NetworkState { Any, Connected, Disconnected } diff --git a/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs b/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs new file mode 100644 index 00000000..10c88436 --- /dev/null +++ b/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs @@ -0,0 +1,59 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.NetworkTriggers; + +/// +/// Module-owned implementations +/// for the network trigger-attribute family: +/// [OnHostReachable], [OnHostUnreachable], +/// [OnNetworkChanged]. Behavior preserved verbatim from the legacy +/// core parser switch. +/// +internal static class NetworkTriggerAttributeHandlers +{ + public static IReadOnlyDictionary All { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["OnHostReachable"] = new HostHandler(NetworkTriggerKeys.HostReachable), + ["OnHostUnreachable"] = new HostHandler(NetworkTriggerKeys.HostUnreachable), + ["OnNetworkChanged"] = new NetworkChangedHandler(), + }; + + private sealed class HostHandler(string triggerKey) : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var host = context.GetStringArg(0); + if (!string.IsNullOrEmpty(host)) + p[NetworkTriggerKeys.HostName] = host; + var port = context.GetNamedIntArg("Port"); + if (port.HasValue) + p[NetworkTriggerKeys.HostPort] = port.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new TaskTriggerDefinition + { + TriggerKey = triggerKey, + Parameters = p, + }; + } + } + + private sealed class NetworkChangedHandler : ITaskTriggerAttributeHandler + { + public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) + { + var p = new Dictionary(StringComparer.Ordinal); + var ssid = context.GetNamedStringArg("Ssid"); + if (!string.IsNullOrEmpty(ssid)) + p[NetworkTriggerKeys.NetworkSsid] = ssid; + var state = context.GetNamedEnumArg("State") ?? NetworkState.Any; + if (state != default) + p[NetworkTriggerKeys.NetworkState] = state.ToString(); + return new TaskTriggerDefinition + { + TriggerKey = NetworkTriggerKeys.NetworkChanged, + Parameters = p, + }; + } + } +} diff --git a/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs b/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs new file mode 100644 index 00000000..9ab4e580 --- /dev/null +++ b/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs @@ -0,0 +1,19 @@ +namespace SharpClaw.Modules.NetworkTriggers; + +/// +/// Trigger and parameter keys owned by the network-triggers module. +/// String values are persisted verbatim in binding rows and serialized +/// scripts. +/// +public static class NetworkTriggerKeys +{ + public const string HostReachable = "HostReachable"; + public const string HostUnreachable = "HostUnreachable"; + public const string NetworkChanged = "NetworkChanged"; + + // Parameter names — must match TaskTriggerDefinition property names. + public const string HostName = "HostName"; + public const string HostPort = "HostPort"; + public const string NetworkSsid = "NetworkSsid"; + public const string NetworkState = "NetworkState"; +} diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs b/DefaultModules/NetworkTriggers/NetworkTriggerSource.cs similarity index 73% rename from SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs rename to DefaultModules/NetworkTriggers/NetworkTriggerSource.cs index da4da09a..2a9c892a 100644 --- a/SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs +++ b/DefaultModules/NetworkTriggers/NetworkTriggerSource.cs @@ -1,13 +1,18 @@ -using SharpClaw.Application.Infrastructure.Tasks; using System.Net.NetworkInformation; + using Microsoft.Extensions.Logging; + using SharpClaw.Contracts.Tasks; -namespace SharpClaw.Application.Core.Services.Triggers.Sources; +namespace SharpClaw.Modules.NetworkTriggers; /// /// Fires when network availability changes, matching the optional SSID and /// declared on the binding. +/// +/// Moved out of SharpClaw.Application.Core by the trigger-extraction +/// plan; behavior is preserved verbatim. +/// /// public sealed class NetworkTriggerSource( ILogger logger) : ITaskTriggerSource, IDisposable @@ -15,7 +20,7 @@ public sealed class NetworkTriggerSource( private IReadOnlyList _contexts = []; private bool _subscribed; - public string TriggerKey => WellKnownTriggerKeys.NetworkChanged; + public string TriggerKey => NetworkTriggerKeys.NetworkChanged; public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) { @@ -42,6 +47,12 @@ public Task StopAsync() return Task.CompletedTask; } + /// + public string? GetBindingValue(TaskTriggerDefinition def) => + def.TriggerKey == NetworkTriggerKeys.NetworkChanged + ? def.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid) + : null; + public void Dispose() => StopAsync().GetAwaiter().GetResult(); private void OnAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) @@ -51,13 +62,13 @@ private void OnAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs { foreach (var ctx in _contexts) { - var requiredState = ctx.Definition.NetworkState; + var requiredState = ParseState(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkState)); if (requiredState == NetworkState.Connected && !connected) continue; if (requiredState == NetworkState.Disconnected && connected) continue; // SSID filtering is platform-specific; skip if not checkable - var ssid = ctx.Definition.NetworkSsid; + var ssid = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid); if (!string.IsNullOrWhiteSpace(ssid) && !TryMatchSsid(ssid)) continue; @@ -78,4 +89,7 @@ private static bool TryMatchSsid(string expectedSsid) // Return true so the trigger is not silently suppressed on unsupported platforms. return true; } + + private static NetworkState ParseState(string? value) => + Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : default; } diff --git a/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs b/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs new file mode 100644 index 00000000..6496d76b --- /dev/null +++ b/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.NetworkTriggers; + +/// +/// Default module that owns the HostReachable, HostUnreachable, +/// and NetworkChanged task triggers. Scaffolded in Phase 1 of the +/// trigger-extraction plan; trigger source implementations move here in +/// Phase 3. +/// +public sealed class NetworkTriggersModule : ISharpClawModule, ITaskParserAware +{ + public ITaskParserModuleExtension ParserExtension => NetworkTriggersParserExtension.Instance; + + public string Id => "sharpclaw_network_triggers"; + public string DisplayName => "Network Triggers"; + public string ToolPrefix => "nettrigger"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + public IReadOnlyList GetToolDefinitions() => []; + + public Task ExecuteToolAsync( + string toolName, + JsonElement parameters, + AgentJobContext job, + IServiceProvider scopedServices, + CancellationToken ct) => + throw new InvalidOperationException( + $"NetworkTriggers module has no job-pipeline tools. Unknown: '{toolName}'."); +} diff --git a/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs b/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs new file mode 100644 index 00000000..5c1b1a27 --- /dev/null +++ b/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs @@ -0,0 +1,21 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Modules.NetworkTriggers; + +/// Parser extension exposing module-owned trigger-attribute handlers. +public sealed class NetworkTriggersParserExtension : ITaskParserModuleExtension +{ + public static readonly NetworkTriggersParserExtension Instance = new(); + + public IReadOnlyDictionary StepKeyMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlyDictionary EventTriggerMappings { get; } = + new Dictionary(StringComparer.Ordinal); + + public IReadOnlySet SingleArgExpressionMethods { get; } = + new HashSet(StringComparer.Ordinal); + + public IReadOnlyDictionary TriggerAttributeHandlers { get; } = + NetworkTriggerAttributeHandlers.All; +} diff --git a/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj b/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj new file mode 100644 index 00000000..f8f6b55f --- /dev/null +++ b/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/DefaultModules/NetworkTriggers/module.json b/DefaultModules/NetworkTriggers/module.json new file mode 100644 index 00000000..74329cfe --- /dev/null +++ b/DefaultModules/NetworkTriggers/module.json @@ -0,0 +1,17 @@ +{ + "id": "sharpclaw_network_triggers", + "displayName": "Network Triggers", + "version": "1.0.0", + "toolPrefix": "nettrigger", + "entryAssembly": "SharpClaw.Modules.NetworkTriggers", + "minHostVersion": "1.0.0", + "author": "SharpClaw Team", + "description": "Owns the HostReachable, HostUnreachable, and NetworkChanged task triggers.", + "license": "AGPL-3.0", + "platforms": null, + "enabled": true, + "defaultEnabled": true, + "executionTimeoutSeconds": 60, + "exports": [], + "requires": [] +} diff --git a/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs b/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs index 57154f84..240809cd 100644 --- a/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs +++ b/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; namespace SharpClaw.Modules.Providers.Google.Clients; diff --git a/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs b/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs index 4dd33c66..dff70779 100644 --- a/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs +++ b/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs @@ -2,6 +2,7 @@ using SharpClaw.Contracts.DTOs.LocalModels; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Modules.Providers.LlamaSharp.Services; namespace SharpClaw.Modules.Providers.LlamaSharp.Cli; diff --git a/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs b/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs index 38b9b092..138daa73 100644 --- a/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs +++ b/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs @@ -1,4 +1,5 @@ using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; namespace SharpClaw.Modules.Transcription.Clients; diff --git a/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs b/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs index 9d90fb78..8a9a87c6 100644 --- a/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs +++ b/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Modules.SystemAudio.Audio; namespace SharpClaw.Modules.Transcription.Clients; diff --git a/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj b/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj index 46416c82..1a564fb4 100644 --- a/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj +++ b/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj @@ -16,6 +16,7 @@ + diff --git a/SharpClaw.Application.API/Cli/CliDispatcher.cs b/SharpClaw.Application.API/Cli/CliDispatcher.cs index 7e1dfb06..628fe748 100644 --- a/SharpClaw.Application.API/Cli/CliDispatcher.cs +++ b/SharpClaw.Application.API/Cli/CliDispatcher.cs @@ -28,6 +28,7 @@ using SharpClaw.Utils.Security; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Application.Core.Services.Triggers; using SharpClaw.Contracts.Tasks; @@ -2309,9 +2310,7 @@ private static int ParseDaysFlag(string[] args, int startIndex) "task schedule ... Manage cron scheduled jobs (see: task schedule)", "task trigger-sources List registered task trigger sources", "task triggers enable Enable all trigger bindings for a task", - "task triggers disable Disable all trigger bindings for a task", - "task shortcuts install Create or refresh OS shortcut for a task", - "task shortcuts remove Remove OS shortcut files for a task"); + "task triggers disable Disable all trigger bindings for a task"); return Results.Ok(); } @@ -2320,10 +2319,24 @@ private static int ParseDaysFlag(string[] args, int startIndex) var chatSvc = sp.GetRequiredService(); if (sub == "schedule") - return await HandleTaskScheduleCommand(args, sp); + { + // Forward "task schedule …" to the module-owned top-level + // "schedule" command (registered by the AgentOrchestration + // module). The legacy "task schedule" alias is preserved for + // back-compat; the handler is module code. + var registry = sp.GetRequiredService(); + var resolved = registry.TryResolveTopLevelCommandWithModule("schedule"); + if (resolved is null) + { + Console.Error.WriteLine("Scheduled-job CLI not registered. Is the AgentOrchestration module loaded?"); + return Results.Ok(); + } - if (sub == "shortcuts") - return await HandleTaskShortcutsCommand(args, sp); + // Drop the leading "task" verb so the module sees args[0] = "schedule". + var forwarded = args.AsSpan(1).ToArray(); + await InvokeModuleCliHandlerAsync(resolved.Value.ModuleId, resolved.Value.Command, forwarded, sp, registry); + return Results.Ok(); + } if (sub == "triggers") return await HandleTaskTriggersCommand(args, sp); @@ -2453,166 +2466,6 @@ private static IResult HandleTaskTriggerSources(IServiceProvider sp) return TaskTriggerHandlers.ListTriggerSources(sources); } - private static async Task HandleTaskShortcutsCommand( - string[] args, IServiceProvider sp) - { - // args[0] = "task", args[1] = "shortcuts", args[2] = sub-command - if (args.Length < 3) - { - PrintUsage( - "task shortcuts install Create or refresh OS shortcut for a task", - "task shortcuts remove Remove OS shortcut files for a task"); - return Results.Ok(); - } - - var sub = args[2].ToLowerInvariant(); - var svc = sp.GetRequiredService(); - var shortcuts = sp.GetRequiredService(); - - switch (sub) - { - case "install" when args.Length >= 4: - { - var taskId = CliIdMap.Resolve(args[3]); - var result = await TaskShortcutHandlers.Install(taskId, svc, shortcuts, default); - if (result is Microsoft.AspNetCore.Http.HttpResults.NotFound) - Console.Error.WriteLine("Task not found."); - else if (result is Microsoft.AspNetCore.Http.HttpResults.UnprocessableEntity ue) - Console.Error.WriteLine(ue.Value); - else - Console.WriteLine("Shortcut installed."); - return Results.Ok(); - } - - case "install": - return UsageResult("task shortcuts install "); - - case "remove" when args.Length >= 4: - { - var taskId = CliIdMap.Resolve(args[3]); - var result = await TaskShortcutHandlers.Remove(taskId, svc, shortcuts, default); - if (result is Microsoft.AspNetCore.Http.HttpResults.NotFound) - Console.Error.WriteLine("Task not found."); - else - Console.WriteLine("Shortcut removed."); - return Results.Ok(); - } - - case "remove": - return UsageResult("task shortcuts remove "); - - default: - return UsageResult($"Unknown sub-command: task shortcuts {sub}. Try 'task shortcuts' for usage."); - } - } - - private static async Task HandleTaskScheduleCommand( - string[] args, IServiceProvider sp) - { - // args[0] = "task", args[1] = "schedule", args[2] = sub-command - var svc = sp.GetRequiredService(); - - if (args.Length < 3) - { - PrintUsage( - "task schedule list List all scheduled jobs", - "task schedule get Show a scheduled job", - "task schedule create --cron [--timezone ] [--name ]", - " Create a cron scheduled job", - "task schedule update --cron [--timezone ]", - " Update cron expression / timezone", - "task schedule pause Pause a scheduled job", - "task schedule resume Resume a paused job", - "task schedule delete Delete a scheduled job", - "task schedule preview [--timezone ] [--count N]", - " Preview next occurrences of a cron expression"); - return Results.Ok(); - } - - var sub = args[2].ToLowerInvariant(); - - switch (sub) - { - case "list": - return Results.Ok(await svc.ListAsync()); - - case "get" when args.Length >= 4: - return await ScheduledJobHandlers.GetById(CliIdMap.Resolve(args[3]), svc, default); - - case "get": - return UsageResult("task schedule get "); - - case "create": - { - var flags = ParseFlags(args, 3); - if (!flags.TryGetValue("cron", out var cronExpr)) - return UsageResult("task schedule create --cron [--timezone ] [--name ]"); - - Guid? taskId = args.Length >= 4 && Guid.TryParse(args[3], out var tid) ? tid : null; - flags.TryGetValue("timezone", out var tz); - flags.TryGetValue("name", out var name); - - var request = new CreateScheduledJobRequest( - Name: name ?? cronExpr, - TaskDefinitionId: taskId, - CronExpression: cronExpr, - CronTimezone: tz); - - return await ScheduledJobHandlers.Create(request, svc, default); - } - - case "update" when args.Length >= 4: - { - var jobId = CliIdMap.Resolve(args[3]); - var flags = ParseFlags(args, 4); - flags.TryGetValue("cron", out var cronExpr); - flags.TryGetValue("timezone", out var tz); - - var request = new UpdateScheduledJobRequest( - CronExpression: cronExpr, - CronTimezone: tz); - - return await ScheduledJobHandlers.Update(jobId, request, svc, default); - } - - case "update": - return UsageResult("task schedule update --cron [--timezone ]"); - - case "pause" when args.Length >= 4: - return await ScheduledJobHandlers.Pause(CliIdMap.Resolve(args[3]), svc, default); - - case "pause": - return UsageResult("task schedule pause "); - - case "resume" when args.Length >= 4: - return await ScheduledJobHandlers.Resume(CliIdMap.Resolve(args[3]), svc, default); - - case "resume": - return UsageResult("task schedule resume "); - - case "delete" when args.Length >= 4: - return await ScheduledJobHandlers.Delete(CliIdMap.Resolve(args[3]), svc, default); - - case "delete": - return UsageResult("task schedule delete "); - - case "preview" when args.Length >= 4: - { - var expr = args[3]; - var flags = ParseFlags(args, 4); - flags.TryGetValue("timezone", out var tz); - int count = flags.TryGetValue("count", out var cStr) && int.TryParse(cStr, out var c) ? c : 10; - return ScheduledJobHandlers.PreviewExpression(expr, tz, count); - } - - case "preview": - return UsageResult("task schedule preview [--timezone ] [--count N]"); - - default: - return UsageResult($"Unknown sub-command: task schedule {sub}. Try 'task schedule' for usage."); - } - } - private static async Task HandleTaskCreate(string sourceFilePath, TaskService svc) { if (!File.Exists(sourceFilePath)) @@ -3527,7 +3380,6 @@ task outputs [--since ] task preflight [--param key=value ...] task schedule list|get|create|update|pause|resume|delete|preview [args] task triggers enable|disable - task shortcuts install|remove task trigger-sources Resource: resource (add, get, list, update, delete, sync) diff --git a/SharpClaw.Application.API/Handlers/ModuleHandlers.cs b/SharpClaw.Application.API/Handlers/ModuleHandlers.cs index dceb9efa..93e7f1e3 100644 --- a/SharpClaw.Application.API/Handlers/ModuleHandlers.cs +++ b/SharpClaw.Application.API/Handlers/ModuleHandlers.cs @@ -5,7 +5,7 @@ using SharpClaw.Application.Core.Modules; using SharpClaw.Application.API.Routing; using SharpClaw.Application.Infrastructure.Logging; -using SharpClaw.Application.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Application.Services; using SharpClaw.Infrastructure.Persistence; diff --git a/SharpClaw.Application.API/Handlers/ScheduledJobHandlers.cs b/SharpClaw.Application.API/Handlers/ScheduledJobHandlers.cs deleted file mode 100644 index 2fb34aa7..00000000 --- a/SharpClaw.Application.API/Handlers/ScheduledJobHandlers.cs +++ /dev/null @@ -1,116 +0,0 @@ -using Microsoft.AspNetCore.Http; -using SharpClaw.Application.API.Routing; -using SharpClaw.Application.Services; -using SharpClaw.Contracts.DTOs.Tasks; - -namespace SharpClaw.Application.API.Handlers; - -// ═══════════════════════════════════════════════════════════════════ -// Scheduled jobs /scheduled-jobs -// ═══════════════════════════════════════════════════════════════════ - -[RouteGroup("/scheduled-jobs")] -public static class ScheduledJobHandlers -{ - [MapPost] - public static async Task Create( - CreateScheduledJobRequest request, ScheduledJobService svc, - CancellationToken ct) - { - try - { - var result = await svc.CreateAsync(request, ct); - return Results.Ok(result); - } - catch (InvalidOperationException ex) - { - return Results.UnprocessableEntity(new { error = ex.Message }); - } - } - - [MapGet] - public static async Task List(ScheduledJobService svc, CancellationToken ct) - => Results.Ok(await svc.ListAsync(ct)); - - [MapGet("/{jobId:guid}")] - public static async Task GetById(Guid jobId, ScheduledJobService svc, - CancellationToken ct) - { - var job = await svc.GetByIdAsync(jobId, ct); - return job is not null ? Results.Ok(job) : Results.NotFound(); - } - - [MapPut("/{jobId:guid}")] - public static async Task Update( - Guid jobId, UpdateScheduledJobRequest request, - ScheduledJobService svc, CancellationToken ct) - { - try - { - var result = await svc.UpdateAsync(jobId, request, ct); - return result is not null ? Results.Ok(result) : Results.NotFound(); - } - catch (InvalidOperationException ex) - { - return Results.UnprocessableEntity(new { error = ex.Message }); - } - } - - [MapDelete("/{jobId:guid}")] - public static async Task Delete(Guid jobId, ScheduledJobService svc, - CancellationToken ct) - => await svc.DeleteAsync(jobId, ct) ? Results.NoContent() : Results.NotFound(); - - // ── Pause / Resume ───────────────────────────────────────────── - - [MapPost("/{jobId:guid}/pause")] - public static async Task Pause(Guid jobId, ScheduledJobService svc, - CancellationToken ct) - { - var result = await svc.PauseAsync(jobId, ct); - return result is not null ? Results.Ok(result) : Results.NotFound(); - } - - [MapPost("/{jobId:guid}/resume")] - public static async Task Resume(Guid jobId, ScheduledJobService svc, - CancellationToken ct) - { - var result = await svc.ResumeAsync(jobId, ct); - return result is not null ? Results.Ok(result) : Results.NotFound(); - } - - // ── Preview endpoints ───────────────────────────────────────── - - /// - /// Returns the next N occurrences for an existing job's cron expression. - /// Query: ?count=10 - /// - [MapGet("/{jobId:guid}/preview")] - public static async Task PreviewJob( - Guid jobId, int count, ScheduledJobService svc, CancellationToken ct) - { - count = count <= 0 ? 10 : Math.Min(count, 100); - var result = await svc.PreviewJobAsync(jobId, count, ct); - return result is not null ? Results.Ok(result) : Results.NotFound(); - } - - /// - /// Stateless preview — validates and evaluates the given expression. - /// Query: ?expression=…&timezone=…&count=10 - /// - [MapGet("/preview")] - public static IResult PreviewExpression( - string expression, string? timezone, int count) - { - count = count <= 0 ? 10 : Math.Min(count, 100); - try - { - var result = ScheduledJobService.PreviewExpression(expression, timezone, count); - return Results.Ok(result); - } - catch (InvalidOperationException ex) - { - return Results.UnprocessableEntity(new { error = ex.Message }); - } - } -} diff --git a/SharpClaw.Application.API/Handlers/TaskHandlers.cs b/SharpClaw.Application.API/Handlers/TaskHandlers.cs index e6763411..ecddec2e 100644 --- a/SharpClaw.Application.API/Handlers/TaskHandlers.cs +++ b/SharpClaw.Application.API/Handlers/TaskHandlers.cs @@ -208,56 +208,6 @@ public static async Task StreamEvents( } } -// ═══════════════════════════════════════════════════════════════════ -// Task shortcuts /tasks/{taskId}/shortcuts -// ═══════════════════════════════════════════════════════════════════ - -[RouteGroup("/tasks/{taskId:guid}/shortcuts")] -public static class TaskShortcutHandlers -{ - /// - /// Refreshes (or creates) the OS shortcut for the given task's first - /// OsShortcut trigger definition. - /// - [MapPost("/install")] - public static async Task Install( - Guid taskId, - TaskService svc, - IShortcutLauncherService shortcuts, - CancellationToken ct) - { - var definition = await svc.GetDefinitionAsync(taskId, ct); - if (definition is null) return Results.NotFound(); - - var triggers = await svc.GetTriggersAsync(taskId, ct); - var shortcutTrigger = triggers?.FirstOrDefault( - t => t.TriggerKey == "OsShortcut"); - - if (shortcutTrigger is null) - return Results.UnprocessableEntity("Task has no OsShortcut trigger defined."); - - await shortcuts.RefreshShortcutsAsync(shortcutTrigger, definition.Name, ct); - return Results.NoContent(); - } - - /// - /// Removes the OS shortcut files for the given task. - /// - [MapDelete] - public static async Task Remove( - Guid taskId, - TaskService svc, - IShortcutLauncherService shortcuts, - CancellationToken ct) - { - var definition = await svc.GetDefinitionAsync(taskId, ct); - if (definition is null) return Results.NotFound(); - - await shortcuts.RemoveShortcutsAsync(definition.Name, ct); - return Results.NoContent(); - } -} - // ═══════════════════════════════════════════════════════════════════ // Trigger sources /tasks/trigger-sources // ═══════════════════════════════════════════════════════════════════ diff --git a/SharpClaw.Application.API/Program.cs b/SharpClaw.Application.API/Program.cs index d174785b..2fc63018 100644 --- a/SharpClaw.Application.API/Program.cs +++ b/SharpClaw.Application.API/Program.cs @@ -14,25 +14,28 @@ using SharpClaw.Application.Core.Clients; using SharpClaw.Application.Core.Modules; using SharpClaw.Application.Infrastructure.Tasks; -using SharpClaw.Application.Core.Services.Triggers.Sources; +using SharpClaw.Application.Core.Services.Triggers; using SharpClaw.Application.Core.Services; using SharpClaw.Application.Services; using SharpClaw.Application.Infrastructure.Logging; using SharpClaw.Application.Infrastructure.Tasks.Parsing; +using SharpClaw.Application.Infrastructure.Tasks.Registry; using SharpClaw.Application.Services.Auth; +using SharpClaw.Contracts.Chat; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Persistence; using SharpClaw.Contracts.Providers; using SharpClaw.Infrastructure; using SharpClaw.Infrastructure.Configuration; +using SharpClaw.Infrastructure.Persistence.JSON; using SharpClaw.Utils.Logging; using SharpClaw.Utils.Instances; using Microsoft.EntityFrameworkCore; using SharpClaw.Utils.Security; using Serilog.Events; +using SharpClaw.Contracts.Permissions; using SharpClaw.Contracts.Tasks; -using SharpClaw.Application.Core.Services.Triggers; -using SharpClaw.Infrastructure.Persistence.JSON; +using SharpClaw.Contracts.Services; var dataDir = Environment.GetEnvironmentVariable("SHARPCLAW_DATA_DIR"); var instanceRoot = Environment.GetEnvironmentVariable("SHARPCLAW_INSTANCE_ROOT"); @@ -251,7 +254,7 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -263,32 +266,26 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(sp => sp.GetRequiredService()); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Trigger host service + built-in sources builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => sp.GetRequiredService()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + // Host-side metric probes consumed by the Metrics module's built-in providers. + builder.Services.AddSingleton(); // Module system builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(sp => { @@ -308,13 +305,29 @@ bundledModule.ConfigureServices(builder.Services); if (bundledModule is ITaskParserAware parserAware) TaskScriptParser.RegisterModule(parserAware.ParserExtension); + + // Discover and register task-step descriptor providers from the + // module's assembly so the central registry is populated before + // any task script is parsed. + var providerTypes = bundledModule.GetType().Assembly.GetTypes() + .Where(t => !t.IsAbstract + && !t.IsInterface + && typeof(ITaskStepDescriptorProvider).IsAssignableFrom(t) + && t.GetConstructor(Type.EmptyTypes) is not null); + foreach (var providerType in providerTypes) + { + var provider = (ITaskStepDescriptorProvider)Activator.CreateInstance(providerType)!; + foreach (var descriptor in provider.Descriptors) + TaskStepRegistry.Default.Register(descriptor); + } } builder.Services.AddSingleton(moduleLoader); builder.Services.AddScoped(); // Background tasks - builder.Services.AddHostedService(); + // (Scheduled-job loop now lives in DefaultModules/AgentOrchestration as + // ScheduledJobWorker, started from the module's InitializeAsync.) builder.Services.AddSingleton(); @@ -602,7 +615,7 @@ // Webhook trigger routes — registered lazily after ApplicationStarted so // that TaskTriggerHostService has loaded its first binding set. - var webhookSource = app.Services.GetRequiredService(); + var webhookSource = app.Services.GetRequiredService(); var webhookRegistry = new WebhookRouteRegistry( app, webhookSource, diff --git a/SharpClaw.Application.API/SharpClaw.Application.API.csproj b/SharpClaw.Application.API/SharpClaw.Application.API.csproj index 6582ceca..c13a8f8a 100644 --- a/SharpClaw.Application.API/SharpClaw.Application.API.csproj +++ b/SharpClaw.Application.API/SharpClaw.Application.API.csproj @@ -26,6 +26,7 @@ + @@ -63,9 +64,15 @@ false + + false + false + + false + false diff --git a/SharpClaw.Application.API/Webhooks/WebhookRouteRegistry.cs b/SharpClaw.Application.API/Webhooks/WebhookRouteRegistry.cs index 1351a022..683bcef8 100644 --- a/SharpClaw.Application.API/Webhooks/WebhookRouteRegistry.cs +++ b/SharpClaw.Application.API/Webhooks/WebhookRouteRegistry.cs @@ -4,8 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using SharpClaw.Application.Infrastructure.Tasks; -using SharpClaw.Application.Core.Services.Triggers.Sources; -using SharpClaw.Application.Core.Services.Triggers; +using SharpClaw.Contracts.Tasks; namespace SharpClaw.Application.API.Webhooks; @@ -15,14 +14,14 @@ namespace SharpClaw.Application.API.Webhooks; /// so that tasks can receive webhook calls. /// /// Routes are registered at most once per path; active/inactive state is -/// managed by via its internal route map. -/// The registry is populated during +/// managed by the module-owned webhook trigger source via its internal +/// route map. The registry is populated during /// after /// has loaded its first set of bindings. /// public sealed class WebhookRouteRegistry( WebApplication app, - WebhookTriggerSource triggerSource, + IWebhookTriggerHost triggerSource, ILogger logger) : IWebhookRouteRegistrar { private readonly ConcurrentDictionary _registered = new( diff --git a/SharpClaw.Application.Core/Clients/CompletionParameterSpec.cs b/SharpClaw.Application.Core/Clients/CompletionParameterSpec.cs index 933eab16..0dd527cd 100644 --- a/SharpClaw.Application.Core/Clients/CompletionParameterSpec.cs +++ b/SharpClaw.Application.Core/Clients/CompletionParameterSpec.cs @@ -1,4 +1,5 @@ using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; namespace SharpClaw.Application.Core.Clients; diff --git a/SharpClaw.Application.Core/Clients/ProviderApiClientFactory.cs b/SharpClaw.Application.Core/Clients/ProviderApiClientFactory.cs index 3d55d8da..bf7b59da 100644 --- a/SharpClaw.Application.Core/Clients/ProviderApiClientFactory.cs +++ b/SharpClaw.Application.Core/Clients/ProviderApiClientFactory.cs @@ -1,5 +1,6 @@ using SharpClaw.Application.Core.Modules; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; namespace SharpClaw.Application.Core.Clients; diff --git a/SharpClaw.Application.Core/Modules/ExternalModuleHost.cs b/SharpClaw.Application.Core/Modules/ExternalModuleHost.cs index 57119cda..30560274 100644 --- a/SharpClaw.Application.Core/Modules/ExternalModuleHost.cs +++ b/SharpClaw.Application.Core/Modules/ExternalModuleHost.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Persistence; +using SharpClaw.Contracts.Services; using SharpClaw.Modules.Hosting; using SharpClaw.Utils.Security; @@ -95,6 +96,7 @@ public static ExternalModuleHost Load( ForwardSingleton(hostServices, services); ForwardSingleton(hostServices, services); ForwardSingleton(hostServices, services); + ForwardSingleton(hostServices, services); // Scoped host services must be forwarded through a scope-aware // resolver so the external module's scope reaches into the host. diff --git a/SharpClaw.Application.Core/Modules/HostAgentJobController.cs b/SharpClaw.Application.Core/Modules/HostAgentJobController.cs index 64633fee..6059d721 100644 --- a/SharpClaw.Application.Core/Modules/HostAgentJobController.cs +++ b/SharpClaw.Application.Core/Modules/HostAgentJobController.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models.Jobs; +using SharpClaw.Contracts.Entities.Core.Jobs; using SharpClaw.Application.Services; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.Enums; diff --git a/SharpClaw.Application.Core/Modules/HostModuleContractAdapters.cs b/SharpClaw.Application.Core/Modules/HostModuleContractAdapters.cs index e38fbe55..7d5ec3a7 100644 --- a/SharpClaw.Application.Core/Modules/HostModuleContractAdapters.cs +++ b/SharpClaw.Application.Core/Modules/HostModuleContractAdapters.cs @@ -1,16 +1,16 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Jobs; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Jobs; using SharpClaw.Application.Services; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.DTOs.Agents; using SharpClaw.Contracts.DTOs.Threads; using SharpClaw.Contracts.Modules; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; using SharpClaw.Contracts.Enums; @@ -217,80 +217,6 @@ public async Task ResolveOrCreateAsync(Guid channelId, CancellationToken c } } -public sealed class HostContextDataReader(SharpClawDbContext db) : IContextDataReader -{ - public Task ThreadExistsAsync(Guid threadId, Guid channelId, CancellationToken ct = default) => - db.ChatThreads.AnyAsync(t => t.Id == threadId && t.ChannelId == channelId, ct); - - public async Task> GetThreadMessagesAsync( - Guid threadId, int maxMessages, CancellationToken ct = default) - { - return await db.ChatMessages - .Where(m => m.ThreadId == threadId) - .OrderByDescending(m => m.CreatedAt) - .Take(maxMessages) - .OrderBy(m => m.CreatedAt) - .Select(m => new ChatMessageSummary( - m.Role, - m.Content, - m.SenderUsername ?? m.SenderAgentName ?? "unknown", - m.CreatedAt)) - .ToListAsync(ct); - } - - public async Task> GetAccessibleThreadsAsync( - Guid agentId, Guid currentChannelId, CancellationToken ct = default) - { - var agentWithRole = await db.Agents - .Include(a => a.Role) - .ThenInclude(r => r!.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .FirstOrDefaultAsync(a => a.Id == agentId, ct); - - var agentPs = agentWithRole?.Role?.PermissionSet; - if (agentPs is null || !agentPs.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory")) - return []; - - var isIndependent = (agentPs.GlobalFlags - .FirstOrDefault(f => f.FlagKey == "CanReadCrossThreadHistory") - ?.Clearance ?? PermissionClearance.Unset) == PermissionClearance.Independent; - - var channels = await db.Channels - .Include(c => c.AllowedAgents) - .Include(c => c.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .Include(c => c.AgentContext) - .ThenInclude(ctx => ctx!.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .Where(c => c.Id != currentChannelId) - .Where(c => c.AgentId == agentId || c.AllowedAgents.Any(a => a.Id == agentId)) - .ToListAsync(ct); - - if (!isIndependent) - { - channels = channels - .Where(c => - { - var effectivePs = c.PermissionSet ?? c.AgentContext?.PermissionSet; - return effectivePs?.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory") == true; - }) - .ToList(); - } - - if (channels.Count == 0) - return []; - - var channelIds = channels.Select(c => c.Id).ToList(); - var channelTitles = channels.ToDictionary(c => c.Id, c => c.Title); - - return await db.ChatThreads - .Where(t => channelIds.Contains(t.ChannelId)) - .OrderByDescending(t => t.UpdatedAt) - .Select(t => new ThreadSummary(t.Id, t.Name, t.ChannelId, channelTitles[t.ChannelId])) - .ToListAsync(ct); - } -} - public sealed class HostContainerProvisioner(SharpClawDbContext db) : IContainerProvisioner { public async Task CreateOwnerRoleAsync( @@ -326,7 +252,7 @@ public async Task CreateOwnerRoleAsync( IsDefault = true, }); - var role = new Application.Infrastructure.Models.Clearance.RoleDB + var role = new RoleDB { Name = $"Container Owner: {containerName}", PermissionSet = permissionSet, diff --git a/SharpClaw.Application.Core/Modules/ModuleConfigStore.cs b/SharpClaw.Application.Core/Modules/ModuleConfigStore.cs index f22b7ba4..7aabd22b 100644 --- a/SharpClaw.Application.Core/Modules/ModuleConfigStore.cs +++ b/SharpClaw.Application.Core/Modules/ModuleConfigStore.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Contracts.Modules; using SharpClaw.Infrastructure.Persistence; diff --git a/SharpClaw.Application.Core/Modules/ModuleEventDispatcher.cs b/SharpClaw.Application.Core/Modules/ModuleEventDispatcher.cs index 24f0c582..694970cd 100644 --- a/SharpClaw.Application.Core/Modules/ModuleEventDispatcher.cs +++ b/SharpClaw.Application.Core/Modules/ModuleEventDispatcher.cs @@ -11,7 +11,7 @@ namespace SharpClaw.Application.Core.Modules; /// public sealed class ModuleEventDispatcher( IServiceProvider rootServices, - ILogger logger) + ILogger logger) : ISharpClawEventSinkRegistry { /// Cached sink list — rebuilt when modules are enabled/disabled. private IReadOnlyList? _sinks; @@ -56,6 +56,9 @@ public void InvalidateSinkCache() lock (_sinkLock) { _sinks = null; } } + /// + void ISharpClawEventSinkRegistry.InvalidateCache() => InvalidateSinkCache(); + private IReadOnlyList GetSinks() { lock (_sinkLock) diff --git a/SharpClaw.Application.Core/Services/AgentActionService.cs b/SharpClaw.Application.Core/Services/AgentActionService.cs index b6d515b7..e47f094d 100644 --- a/SharpClaw.Application.Core/Services/AgentActionService.cs +++ b/SharpClaw.Application.Core/Services/AgentActionService.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.Enums; @@ -90,7 +90,7 @@ private async Task EvaluateGlobalFlagAsync( /// /// Check whether a permission set has a grant for a specific resource - /// in the unified collection. + /// in the unified collection. /// Replaces 18 typed GrantCheckMap lambdas with a single method. /// See Module-System-Design §3.10.5. /// @@ -102,7 +102,7 @@ private static bool HasResourceGrant( /// /// Evaluate a per-resource grant using the unified - /// collection. + /// collection. /// See Module-System-Design §3.10.5. /// private async Task EvaluateResourceAccessAsync( diff --git a/SharpClaw.Application.Core/Services/AgentJobService.cs b/SharpClaw.Application.Core/Services/AgentJobService.cs index 4886910b..dc60f130 100644 --- a/SharpClaw.Application.Core/Services/AgentJobService.cs +++ b/SharpClaw.Application.Core/Services/AgentJobService.cs @@ -10,16 +10,16 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Jobs; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.DTOs.Chat; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Modules; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/AgentService.cs b/SharpClaw.Application.Core/Services/AgentService.cs index 80ecb682..b0be0924 100644 --- a/SharpClaw.Application.Core/Services/AgentService.cs +++ b/SharpClaw.Application.Core/Services/AgentService.cs @@ -2,8 +2,8 @@ using Microsoft.Extensions.Configuration; using SharpClaw.Application.Core.Clients; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.Agents; using SharpClaw.Contracts.DTOs.Auth; @@ -11,7 +11,8 @@ using SharpClaw.Contracts.Models; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Providers; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Providers.Common; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/Auth/AuthService.cs b/SharpClaw.Application.Core/Services/Auth/AuthService.cs index febf2c2d..66d05d00 100644 --- a/SharpClaw.Application.Core/Services/Auth/AuthService.cs +++ b/SharpClaw.Application.Core/Services/Auth/AuthService.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using SharpClaw.Contracts.DTOs.Auth; using SharpClaw.Contracts.DTOs.Users; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Application.Core/Services/ChannelService.cs b/SharpClaw.Application.Core/Services/ChannelService.cs index 43a3cd9f..6f8b7204 100644 --- a/SharpClaw.Application.Core/Services/ChannelService.cs +++ b/SharpClaw.Application.Core/Services/ChannelService.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.DTOs.Agents; using SharpClaw.Contracts.DTOs.Channels; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/ChatProcessingBridge.cs b/SharpClaw.Application.Core/Services/ChatProcessingBridge.cs new file mode 100644 index 00000000..f21de2a4 --- /dev/null +++ b/SharpClaw.Application.Core/Services/ChatProcessingBridge.cs @@ -0,0 +1,55 @@ +using SharpClaw.Contracts.Chat; +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Providers; + +namespace SharpClaw.Application.Services; + +/// +/// Default implementation. +/// Fans out to every registered , +/// aggregates their contributions, and de-duplicates results so core +/// (, ) never +/// names a module-owned permission key inline. +/// +/// Modules contribute by registering their own +/// in DI; this aggregator picks +/// them up automatically through IEnumerable<IChatProcessingContributor>. +/// +public sealed class ChatProcessingBridge( + IEnumerable contributors) : IChatProcessingBridge +{ + public async Task> GetExtraToolsAsync( + Guid agentId, CancellationToken ct = default) + { + var aggregated = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var contributor in contributors) + { + var tools = await contributor.GetExtraToolsAsync(agentId, ct); + foreach (var tool in tools) + { + if (seen.Add(tool.Name)) + aggregated.Add(tool); + } + } + return aggregated; + } + + public async Task> GetAccessibleThreadsAsync( + Guid agentId, Guid currentChannelId, CancellationToken ct = default) + { + var aggregated = new List(); + var seen = new HashSet(); + foreach (var contributor in contributors) + { + var threads = await contributor.GetAccessibleThreadsAsync( + agentId, currentChannelId, ct); + foreach (var t in threads) + { + if (seen.Add(t.ThreadId)) + aggregated.Add(t); + } + } + return aggregated; + } +} diff --git a/SharpClaw.Application.Core/Services/ChatService.cs b/SharpClaw.Application.Core/Services/ChatService.cs index 344026e0..92b30cb2 100644 --- a/SharpClaw.Application.Core/Services/ChatService.cs +++ b/SharpClaw.Application.Core/Services/ChatService.cs @@ -7,21 +7,23 @@ using Microsoft.Extensions.Logging; using SharpClaw.Application.Core.Clients; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Chat; using SharpClaw.Contracts.Modules; using Microsoft.Extensions.DependencyInjection; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.DTOs.Chat; using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Contracts.Models; using SharpClaw.Contracts.Tasks; using SharpClaw.Contracts.Persistence; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; using SharpClaw.Contracts.Enums; @@ -43,6 +45,7 @@ public sealed class ChatService( ILogger logger, IServiceScopeFactory serviceScopeFactory, IServiceProvider serviceProvider, + IChatProcessingBridge chatProcessingBridge, IConfiguration configuration) { private const int MaxHistoryMessages = 50; @@ -623,7 +626,8 @@ private async Task AppendAgentSuffixAsync( sb.Append(" | policy: unlisted-resource/GUID=denied; disclose gaps to user"); - var accessibleThreads = await GetAccessibleThreadsAsync(agentId, channelId, ct); + var accessibleThreads = await chatProcessingBridge.GetAccessibleThreadsAsync( + agentId, channelId, ct); if (accessibleThreads.Count > 0) { sb.Append(" | accessible-threads: "); @@ -648,67 +652,6 @@ private async Task AppendAgentSuffixAsync( sb.AppendLine("]"); } - /// - /// Finds threads on other channels that the agent can read via - /// cross-thread history. Used by the chat header to populate the - /// accessible-threads section. - /// - private async Task> - GetAccessibleThreadsAsync(Guid agentId, Guid currentChannelId, CancellationToken ct) - { - var agentWithRole = await db.Agents - .Include(a => a.Role) - .ThenInclude(r => r!.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .FirstOrDefaultAsync(a => a.Id == agentId, ct); - - var agentPs = agentWithRole?.Role?.PermissionSet; - if (agentPs is null || !agentPs.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory")) - return []; - - var isIndependent = (agentPs.GlobalFlags - .FirstOrDefault(f => f.FlagKey == "CanReadCrossThreadHistory") - ?.Clearance ?? PermissionClearance.Unset) == PermissionClearance.Independent; - - var channels = await db.Channels - .Include(c => c.AllowedAgents) - .Include(c => c.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .Include(c => c.AgentContext) - .ThenInclude(ctx => ctx!.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .Where(c => c.Id != currentChannelId) - .Where(c => c.AgentId == agentId || c.AllowedAgents.Any(a => a.Id == agentId)) - .ToListAsync(ct); - - if (!isIndependent) - { - channels = channels - .Where(c => - { - var effectivePs = c.PermissionSet ?? c.AgentContext?.PermissionSet; - return effectivePs?.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory") == true; - }) - .ToList(); - } - - if (channels.Count == 0) - return []; - - var channelIds = channels.Select(c => c.Id).ToList(); - var threads = await db.ChatThreads - .Where(t => channelIds.Contains(t.ChannelId)) - .OrderByDescending(t => t.UpdatedAt) - .Select(t => new { t.Id, t.Name, t.ChannelId }) - .ToListAsync(ct); - - var channelTitles = channels.ToDictionary(c => c.Id, c => c.Title); - - return threads - .Select(t => (t.Id, t.Name, t.ChannelId, channelTitles[t.ChannelId])) - .ToList(); - } - /// /// Collects grant names with enumerated resource IDs for the chat /// header (both user and agent sections). When a wildcard grant @@ -1227,7 +1170,8 @@ private async Task CanSessionUserApproveAsync( /// /// Returns the effective tool list for a chat call. When a task context is /// present, task-scoped tools (shared data, output, custom hooks) are appended. - /// When the agent has , active + /// When the agent has the CanInvokeTasksAsTool global flag (owned by + /// the agent-orchestration module), active /// task definitions are exposed as platform-level tools. /// The tool-awareness filter is applied last so it can suppress any tool by name. /// @@ -1247,20 +1191,15 @@ private async Task> GetEffectiveToolsAsync( baseTools.AddRange(store.GetToolDefinitions()); } - // Platform-level task tools: expose active task definitions when the agent - // has CanInvokeTasksAsTool and we are not already inside a task context. + // Platform-level extra tools contributed by modules through + // IChatProcessingBridge. Each contributor evaluates its own policy + // (e.g. agent-orchestration's CanInvokeTasksAsTool) so this method + // never names a module-owned permission key inline. if (agentId.HasValue && taskContext is null) { - var actionSvc = serviceProvider.GetService(); - var taskToolProvider = serviceProvider.GetService(); - if (actionSvc is not null && taskToolProvider is not null) - { - var caller = new ActionCaller(AgentId: agentId.Value); - var result = await actionSvc.EvaluateGlobalFlagByKeyAsync( - TaskPermissionKeys.CanInvokeTasksAsTool, agentId.Value, caller, ct: ct); - if (result.Verdict == ClearanceVerdict.Approved) - baseTools.AddRange(await taskToolProvider.GetToolDefinitionsAsync(ct)); - } + var extraTools = await chatProcessingBridge.GetExtraToolsAsync(agentId.Value, ct); + if (extraTools.Count > 0) + baseTools.AddRange(extraTools); } if (toolAwareness is null or { Count: 0 }) @@ -1443,12 +1382,6 @@ private async Task HandleInlineModuleToolAsync( } } - private static JsonElement BuildJsonSchema(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - /// /// Patches entries whose IDs appear in /// with the correct @@ -1952,14 +1885,6 @@ private readonly record struct ToolLoopResult( private static string FormatToolNotation(AgentJobResponse job) => $"\n⚙ [{job.ActionKey ?? "unknown"}] → {job.Status}"; - /// - /// Formats a tool call notation line for a job that required - /// approval, showing the final outcome. - /// Format: \n⏳ [ActionKey] awaiting approval → Status - /// - private static string FormatApprovalNotation(AgentJobResponse job) - => $"\n⏳ [{job.ActionKey ?? "unknown"}] awaiting approval → {job.Status}"; - /// /// Formats a tool call notation line for an inline tool (wait, /// list_accessible_threads, etc.) that does not go through the diff --git a/SharpClaw.Application.Core/Services/ContextService.cs b/SharpClaw.Application.Core/Services/ContextService.cs index b689d50c..9b2fe8d0 100644 --- a/SharpClaw.Application.Core/Services/ContextService.cs +++ b/SharpClaw.Application.Core/Services/ContextService.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.DTOs.Contexts; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/DefaultResourceSetService.cs b/SharpClaw.Application.Core/Services/DefaultResourceSetService.cs index e604547a..684e915a 100644 --- a/SharpClaw.Application.Core/Services/DefaultResourceSetService.cs +++ b/SharpClaw.Application.Core/Services/DefaultResourceSetService.cs @@ -1,6 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.DTOs.DefaultResources; using SharpClaw.Infrastructure.Persistence; @@ -14,7 +14,7 @@ namespace SharpClaw.Application.Services; /// public sealed class DefaultResourceSetService(SharpClawDbContext db, ModuleRegistry moduleRegistry) { - // ── Reads ────────────────────────────────────────────────────── + // -- Reads ------------------------------------------------------ /// /// Gets the default resources for a channel. Falls through to the @@ -50,7 +50,7 @@ public sealed class DefaultResourceSetService(SharpClawDbContext db, ModuleRegis : EmptyResponse(Guid.Empty); } - // ── Bulk writes ──────────────────────────────────────────────── + // -- Bulk writes ------------------------------------------------ /// /// Sets the default resources for a channel (creates or replaces). @@ -96,7 +96,7 @@ public sealed class DefaultResourceSetService(SharpClawDbContext db, ModuleRegis return ToResponse(ctx.DefaultResourceSet); } - // ── Per-key operations ───────────────────────────────────────── + // -- Per-key operations ----------------------------------------- /// /// Returns when is a @@ -179,7 +179,7 @@ public bool IsValidKey(string key) => return ToResponse(ctx.DefaultResourceSet); } - // ── Helpers ──────────────────────────────────────────────────── + // -- Helpers ---------------------------------------------------- private async Task CreateAndAttachAsync( Action assignId, CancellationToken ct) diff --git a/SharpClaw.Application.Core/Services/GlobalFlagEvaluator.cs b/SharpClaw.Application.Core/Services/GlobalFlagEvaluator.cs new file mode 100644 index 00000000..04db87eb --- /dev/null +++ b/SharpClaw.Application.Core/Services/GlobalFlagEvaluator.cs @@ -0,0 +1,23 @@ +using SharpClaw.Contracts.DTOs.AgentActions; +using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Permissions; + +namespace SharpClaw.Application.Services; + +/// +/// Host-side implementation of that +/// adapts +/// down to a single Approved/Denied verdict for module callers. +/// +public sealed class GlobalFlagEvaluator(AgentActionService agentActions) + : IGlobalFlagEvaluator +{ + public async Task IsApprovedAsync( + string flagKey, Guid agentId, CancellationToken ct = default) + { + var caller = new ActionCaller(AgentId: agentId); + var verdict = await agentActions.EvaluateGlobalFlagByKeyAsync( + flagKey, agentId, caller, ct: ct); + return verdict.Verdict == ClearanceVerdict.Approved; + } +} diff --git a/SharpClaw.Application.Core/Services/HeaderTagProcessor.cs b/SharpClaw.Application.Core/Services/HeaderTagProcessor.cs index 36155273..08de3dce 100644 --- a/SharpClaw.Application.Core/Services/HeaderTagProcessor.cs +++ b/SharpClaw.Application.Core/Services/HeaderTagProcessor.cs @@ -5,14 +5,14 @@ using Microsoft.EntityFrameworkCore; using SharpClaw.Application.Core.Clients; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts; +using SharpClaw.Contracts.Chat; using SharpClaw.Contracts.Providers; using SharpClaw.Contracts.Attributes; using SharpClaw.Contracts.Entities; -using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; @@ -39,6 +39,7 @@ namespace SharpClaw.Application.Services; public sealed partial class HeaderTagProcessor( SharpClawDbContext db, ModuleRegistry moduleRegistry, + IChatProcessingBridge chatProcessingBridge, IServiceProvider serviceProvider) { // ── Tag regex ──────────────────────────────────────────────── @@ -320,46 +321,17 @@ private static string FormatReasoningEffortNotice(HeaderContext ctx) private async Task FormatAccessibleThreadsAsync(HeaderContext ctx, CancellationToken ct) { - if (ctx.AgentPs is null || !ctx.AgentPs.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory")) - return "(none)"; - - var isIndependent = ctx.AgentPs.GlobalFlags - .FirstOrDefault(f => f.FlagKey == "CanReadCrossThreadHistory") - ?.Clearance == PermissionClearance.Independent; - - var agentId = ctx.Agent.Id; - var currentChannelId = ctx.Channel.Id; + var threads = await chatProcessingBridge.GetAccessibleThreadsAsync( + ctx.Agent.Id, ctx.Channel.Id, ct); - var accessibleChannels = await db.Channels - .Include(c => c.Threads) - .Include(c => c.PermissionSet).ThenInclude(ps => ps!.GlobalFlags) - .Include(c => c.AgentContext).ThenInclude(ctx2 => ctx2!.PermissionSet) - .ThenInclude(ps => ps!.GlobalFlags) - .Where(c => c.Id != currentChannelId - && (c.AgentId == agentId || c.AllowedAgents.Any(a => a.Id == agentId))) - .ToListAsync(ct); - - // When not Independent, only include channels that opt in. - if (!isIndependent) - { - accessibleChannels = accessibleChannels - .Where(c => - { - var effectivePs = c.PermissionSet ?? c.AgentContext?.PermissionSet; - return effectivePs is not null - && effectivePs.GlobalFlags.Any(f => f.FlagKey == "CanReadCrossThreadHistory"); - }) - .ToList(); - } + if (threads.Count == 0) + return "(none)"; - var entries = new List(); - foreach (var ch in accessibleChannels) - { - foreach (var thread in ch.Threads) - entries.Add($"{thread.Name} [{ch.Title}] ({thread.Id:D})"); - } + var entries = threads + .Select(t => $"{t.ThreadName} [{t.ChannelTitle}] ({t.ThreadId:D})") + .ToList(); - return entries.Count > 0 ? string.Join(", ", entries) : "(none)"; + return string.Join(", ", entries); } // ═══════════════════════════════════════════════════════════════ @@ -429,7 +401,6 @@ private async Task FormatAccessibleThreadsAsync(HeaderContext ctx, Cance "threads" => Cast(await db.ChatThreads.ToListAsync(ct)), "roles" => Cast(await db.Roles.ToListAsync(ct)), "users" => Cast(await db.Users.ToListAsync(ct)), - "scheduledtasks" or "scheduledjobs" => Cast(await db.ScheduledTasks.ToListAsync(ct)), "tasks" or "taskdefinitions" => Cast(await db.TaskDefinitions.ToListAsync(ct)), _ => null }; diff --git a/SharpClaw.Application.Core/Services/HostAgentBridge.cs b/SharpClaw.Application.Core/Services/HostAgentBridge.cs new file mode 100644 index 00000000..90e83840 --- /dev/null +++ b/SharpClaw.Application.Core/Services/HostAgentBridge.cs @@ -0,0 +1,423 @@ +using System.Text; +using System.Text.Json; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Tasks; +using SharpClaw.Application.Infrastructure.Tasks; +using SharpClaw.Application.Infrastructure.Tasks.Models; +using SharpClaw.Contracts; +using SharpClaw.Contracts.DTOs.Chat; +using SharpClaw.Contracts.DTOs.Roles; +using SharpClaw.Contracts.DTOs.Tasks; +using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Tasks; +using SharpClaw.Infrastructure.Persistence; + +namespace SharpClaw.Application.Services; + +/// +/// Default implementation. Owns the chat, +/// parsing, and provisioning flows that previously lived in +/// . Any module (currently the Agent +/// Orchestration module) calls these methods through the contracts +/// interface so it stays free of SharpClawDbContext and other +/// Core/Infrastructure types. +/// +public sealed class HostAgentBridge( + SharpClawDbContext db, + TaskService taskService, + ChatService chatService, + IServiceScopeFactory scopeFactory) : IHostAgentBridge +{ + public async Task ChatAsync( + Guid instanceId, string taskName, string message, Guid? agentId, CancellationToken ct) + { + var channelId = await GetInstanceChannelIdAsync(instanceId, ct); + var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, + TaskContext: new TaskChatContext(instanceId, taskName)); + var response = await chatService.SendMessageAsync(channelId, request, ct: ct); + await taskService.AppendLogAsync( + instanceId, $"Chat → {response.AssistantMessage.Content?.Length ?? 0} chars", ct: ct); + return response.AssistantMessage.Content; + } + + public async Task ChatStreamAsync( + Guid instanceId, string taskName, string message, Guid? agentId, CancellationToken ct) + { + var channelId = await GetInstanceChannelIdAsync(instanceId, ct); + var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, + TaskContext: new TaskChatContext(instanceId, taskName)); + var sb = new StringBuilder(); + await foreach (var evt in chatService.SendMessageStreamAsync( + channelId, request, AutoApproveAsync, ct: ct)) + { + if (evt.Type == ChatStreamEventType.TextDelta && evt.Delta is not null) + sb.Append(evt.Delta); + } + await taskService.AppendLogAsync(instanceId, $"ChatStream → {sb.Length} chars", ct: ct); + return sb.ToString(); + } + + public async Task ChatToThreadAsync( + Guid instanceId, string taskName, Guid threadId, string message, Guid? agentId, CancellationToken ct) + { + var channelId = await GetInstanceChannelIdAsync(instanceId, ct); + var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, + TaskContext: new TaskChatContext(instanceId, taskName)); + var response = await chatService.SendMessageAsync(channelId, request, threadId: threadId, ct: ct); + await taskService.AppendLogAsync( + instanceId, $"ChatToThread {threadId} → {response.AssistantMessage.Content?.Length ?? 0} chars", ct: ct); + return response.AssistantMessage.Content; + } + + public string ParseStructuredResponse(Guid instanceId, string text, string? typeName) + { + // The script definition is needed to validate the parsed shape against + // the task's declared data types. We resolve it from the running task + // instance's source text by re-loading the compiled plan via the task + // service is overkill — instead we parse and validate against the + // currently-running plan only when we have access to the definition. + // For now, just validate JSON shape; the orchestrator-level definition + // check is preserved by routing through the script engine. + var jsonText = ExtractJsonObject(text) + ?? throw new InvalidOperationException("ParseResponse expected a JSON object in the source text."); + + using var doc = JsonDocument.Parse(jsonText); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("ParseResponse expected a JSON object payload."); + + if (!string.IsNullOrWhiteSpace(typeName)) + { + var instance = db.TaskInstances + .Include(i => i.TaskDefinition) + .FirstOrDefault(i => i.Id == instanceId); + if (instance?.TaskDefinition is not null) + { + var compileResult = TaskScriptEngine.ProcessScript(instance.TaskDefinition.SourceText, null); + if (compileResult.Plan is not null) + { + var dataType = compileResult.Plan.Definition.DataTypes + .FirstOrDefault(dt => dt.Name == typeName); + if (dataType is not null) + ValidateParsedResponseShape(doc.RootElement, dataType); + } + } + } + + return JsonSerializer.Serialize(doc.RootElement); + } + + public async Task FindModelAsync(string search, CancellationToken ct) + => (await db.Models.FirstOrDefaultAsync(m => m.CustomId == search || m.Name == search, ct))?.Id; + + public async Task FindProviderAsync(string search, CancellationToken ct) + => (await db.Providers.FirstOrDefaultAsync(p => p.CustomId == search || p.Name == search, ct))?.Id; + + public async Task FindAgentAsync(string search, CancellationToken ct) + => (await db.Agents.FirstOrDefaultAsync(a => a.CustomId == search || a.Name == search, ct))?.Id; + + public async Task FindRoleAsync(string search, CancellationToken ct) + => (await db.Roles.FirstOrDefaultAsync(r => r.Name == search, ct))?.Id; + + public async Task FindChannelAsync(string search, CancellationToken ct) + => (await db.Channels.FirstOrDefaultAsync(c => c.CustomId == search || c.Title == search, ct))?.Id; + + public async Task CreateAgentAsync( + Guid instanceId, string name, Guid modelId, string? systemPrompt, string? customId, CancellationToken ct) + { + SharpClaw.Contracts.Entities.Core.AgentDB? agentEntity = null; + if (!string.IsNullOrEmpty(customId)) + { + agentEntity = await db.Agents + .Where(a => a.CustomId == customId) + .OrderByDescending(a => a.CreatedAt) + .FirstOrDefaultAsync(ct); + } + + if (agentEntity is not null) + { + agentEntity.Name = name; + agentEntity.ModelId = modelId; + agentEntity.SystemPrompt = systemPrompt; + await db.SaveChangesAsync(ct); + } + else + { + agentEntity = new SharpClaw.Contracts.Entities.Core.AgentDB + { + Name = name, + ModelId = modelId, + SystemPrompt = systemPrompt, + CustomId = customId, + }; + db.Agents.Add(agentEntity); + await db.SaveChangesAsync(ct); + } + + if (await TryGetInstanceChannelIdAsync(instanceId, ct) is { } channelId) + { + var channel = await db.Channels + .Include(c => c.AllowedAgents) + .FirstOrDefaultAsync(c => c.Id == channelId, ct); + if (channel is not null && !channel.AllowedAgents.Any(a => a.Id == agentEntity.Id)) + { + channel.AllowedAgents.Add(agentEntity); + await db.SaveChangesAsync(ct); + } + } + + await taskService.AppendLogAsync(instanceId, $"CreateAgent '{name}' → {agentEntity.Id}", ct: ct); + return agentEntity.Id; + } + + public async Task CreateThreadAsync( + Guid instanceId, Guid? channelId, string? threadName, CancellationToken ct) + { + var resolvedChannelId = channelId + ?? await GetInstanceChannelIdAsync(instanceId, ct); + + var thread = new ChatThreadDB + { + Name = threadName ?? $"Task Thread {DateTimeOffset.UtcNow:HH:mm}", + ChannelId = resolvedChannelId, + }; + db.ChatThreads.Add(thread); + await db.SaveChangesAsync(ct); + await taskService.AppendLogAsync(instanceId, $"CreateThread '{thread.Name}' → {thread.Id}", ct: ct); + return thread.Id; + } + + public async Task CreateRoleAsync(string roleName, CancellationToken ct) + { + var existing = await db.Roles.FirstOrDefaultAsync(r => r.Name == roleName, ct); + if (existing is not null) + return existing.Id; + + using var scope = scopeFactory.CreateScope(); + var roleService = scope.ServiceProvider.GetRequiredService(); + var created = await roleService.CreateAsync(roleName, ct); + return created.Id; + } + + public async Task SetRolePermissionsAsync(Guid roleId, string requestJson, CancellationToken ct) + { + var permRequest = !string.IsNullOrWhiteSpace(requestJson) + ? JsonSerializer.Deserialize( + requestJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new SetRolePermissionsRequest() + : new SetRolePermissionsRequest(); + + var role = await db.Roles + .Include(r => r.PermissionSet) + .FirstOrDefaultAsync(r => r.Id == roleId, ct) + ?? throw new InvalidOperationException($"SetRolePermissions: role '{roleId}' not found."); + + var permissionSet = role.PermissionSet; + if (permissionSet is null) + { + permissionSet = new PermissionSetDB(); + db.PermissionSets.Add(permissionSet); + await db.SaveChangesAsync(ct); + role.PermissionSetId = permissionSet.Id; + } + + await db.GlobalFlags + .Where(f => f.PermissionSetId == permissionSet.Id) + .ExecuteDeleteCompatAsync(db, ct); + + await db.ResourceAccesses + .Where(a => a.PermissionSetId == permissionSet.Id && a.ResourceId != WellKnownIds.AllResources) + .ExecuteDeleteCompatAsync(db, ct); + + foreach (var (flagKey, clearance) in permRequest.GlobalFlags ?? new Dictionary()) + { + db.GlobalFlags.Add(new GlobalFlagDB + { + PermissionSetId = permissionSet.Id, + FlagKey = flagKey, + Clearance = clearance, + }); + } + + foreach (var (resourceType, grants) in permRequest.ResourceGrants ?? new Dictionary>()) + { + foreach (var grant in grants) + { + var existingWildcard = grant.ResourceId == WellKnownIds.AllResources + ? await db.ResourceAccesses.FirstOrDefaultAsync(a => + a.PermissionSetId == permissionSet.Id && + a.ResourceType == resourceType && + a.ResourceId == WellKnownIds.AllResources, ct) + : null; + + if (existingWildcard is not null) + { + existingWildcard.Clearance = grant.Clearance; + continue; + } + + db.ResourceAccesses.Add(new ResourceAccessDB + { + PermissionSetId = permissionSet.Id, + ResourceType = resourceType, + ResourceId = grant.ResourceId, + Clearance = grant.Clearance, + }); + } + } + + await db.SaveChangesAsync(ct); + } + + public async Task AssignRoleAsync(Guid agentId, Guid roleId, CancellationToken ct) + { + var agentEntity = await db.Agents.FirstOrDefaultAsync(a => a.Id == agentId, ct) + ?? throw new InvalidOperationException($"AssignRole: agent '{agentId}' not found."); + if (!await db.Roles.AnyAsync(r => r.Id == roleId, ct)) + throw new InvalidOperationException($"AssignRole: role '{roleId}' not found."); + + agentEntity.RoleId = roleId; + await db.SaveChangesAsync(ct); + } + + public async Task CreateChannelAsync( + Guid instanceId, string title, Guid agentId, string? customId, CancellationToken ct) + { + var instanceContextId = await db.TaskInstances + .Where(i => i.Id == instanceId) + .Select(i => i.ContextId) + .FirstOrDefaultAsync(ct); + + ChannelDB? existing = !string.IsNullOrEmpty(customId) + ? await db.Channels.FirstOrDefaultAsync(c => c.CustomId == customId, ct) + : await db.Channels.FirstOrDefaultAsync(c => c.Title == title, ct); + + Guid channelId; + if (existing is not null) + { + existing.Title = title; + existing.AgentId = agentId; + if (!string.IsNullOrEmpty(customId)) + existing.CustomId = customId; + if (instanceContextId.HasValue) + existing.AgentContextId = instanceContextId; + await db.SaveChangesAsync(ct); + channelId = existing.Id; + } + else + { + using var scope = scopeFactory.CreateScope(); + var channelService = scope.ServiceProvider.GetRequiredService(); + var resp = await channelService.CreateAsync( + new SharpClaw.Contracts.DTOs.Channels.CreateChannelRequest( + AgentId: agentId, + Title: title, + CustomId: customId, + ContextId: instanceContextId), + ct); + channelId = resp.Id; + } + + // Adopt as instance channel when none is set yet + var inst = await db.TaskInstances.FindAsync([instanceId], ct); + if (inst is not null && inst.ChannelId is null) + { + inst.ChannelId = channelId; + await db.SaveChangesAsync(ct); + } + + await taskService.AppendLogAsync(instanceId, $"CreateChannel '{title}' → {channelId}", ct: ct); + return channelId; + } + + public async Task AddAllowedAgentAsync( + Guid instanceId, Guid agentId, Guid? channelId, CancellationToken ct) + { + var agentEntity = await db.Agents.FirstOrDefaultAsync(a => a.Id == agentId, ct) + ?? throw new InvalidOperationException($"AddAllowedAgent: agent '{agentId}' not found."); + + var targetChannelId = channelId + ?? await GetInstanceChannelIdAsync(instanceId, ct); + + var channel = await db.Channels + .Include(c => c.AllowedAgents) + .FirstOrDefaultAsync(c => c.Id == targetChannelId, ct) + ?? throw new InvalidOperationException($"AddAllowedAgent: channel '{targetChannelId}' not found."); + + if (!channel.AllowedAgents.Any(a => a.Id == agentEntity.Id)) + { + channel.AllowedAgents.Add(agentEntity); + await db.SaveChangesAsync(ct); + } + + await taskService.AppendLogAsync( + instanceId, $"AddAllowedAgent agent={agentId} → channel={targetChannelId}", ct: ct); + } + + // ── Helpers ───────────────────────────────────────────────────── + + private async Task GetInstanceChannelIdAsync(Guid instanceId, CancellationToken ct) + => await TryGetInstanceChannelIdAsync(instanceId, ct) + ?? throw new InvalidOperationException( + $"Task instance {instanceId} has no channel yet. " + + "Call CreateChannel before using Chat, CreateThread, or other channel-dependent steps."); + + private async Task TryGetInstanceChannelIdAsync(Guid instanceId, CancellationToken ct) + => await db.TaskInstances + .Where(i => i.Id == instanceId) + .Select(i => i.ChannelId) + .FirstOrDefaultAsync(ct); + + private static Task AutoApproveAsync( + SharpClaw.Contracts.DTOs.AgentActions.AgentJobResponse _, CancellationToken __) => + Task.FromResult(false); + + private static string? ExtractJsonObject(string text) + { + var jsonStart = text.IndexOf('{'); + var jsonEnd = text.LastIndexOf('}'); + return jsonStart >= 0 && jsonEnd > jsonStart + ? text[jsonStart..(jsonEnd + 1)] + : null; + } + + private static void ValidateParsedResponseShape(JsonElement element, TaskDataTypeDefinition dataType) + { + foreach (var property in dataType.Properties) + { + if (!element.TryGetProperty(property.Name, out var propertyElement)) + throw new InvalidOperationException( + $"ParseResponse<{dataType.Name}> missing property '{property.Name}'."); + + if (property.IsCollection) + { + if (propertyElement.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException( + $"Property '{property.Name}' must be a JSON array."); + continue; + } + + if (!IsCompatibleJsonValue(propertyElement, property.TypeName)) + throw new InvalidOperationException( + $"Property '{property.Name}' does not match declared type '{property.TypeName}'."); + } + } + + private static bool IsCompatibleJsonValue(JsonElement value, string typeName) + { + var normalizedType = typeName.TrimEnd('?'); + return normalizedType switch + { + "string" => value.ValueKind == JsonValueKind.String, + "int" or "long" or "double" or "decimal" => value.ValueKind == JsonValueKind.Number, + "bool" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "Guid" or "DateTime" or "DateTimeOffset" or "TimeSpan" => value.ValueKind == JsonValueKind.String, + _ => value.ValueKind == JsonValueKind.Object + }; + } +} diff --git a/SharpClaw.Application.Core/Services/ModelService.cs b/SharpClaw.Application.Core/Services/ModelService.cs index 13a1a7eb..705f5f57 100644 --- a/SharpClaw.Application.Core/Services/ModelService.cs +++ b/SharpClaw.Application.Core/Services/ModelService.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using SharpClaw.Contracts.DTOs.Models; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/ModuleService.cs b/SharpClaw.Application.Core/Services/ModuleService.cs index dbedb85d..4cfee965 100644 --- a/SharpClaw.Application.Core/Services/ModuleService.cs +++ b/SharpClaw.Application.Core/Services/ModuleService.cs @@ -3,8 +3,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models; -using SharpClaw.Application.Infrastructure.Models.Access; +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Access; using SharpClaw.Contracts; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Modules; diff --git a/SharpClaw.Application.Core/Services/ProviderCostService.cs b/SharpClaw.Application.Core/Services/ProviderCostService.cs index be2ae1f6..bdf288de 100644 --- a/SharpClaw.Application.Core/Services/ProviderCostService.cs +++ b/SharpClaw.Application.Core/Services/ProviderCostService.cs @@ -2,6 +2,7 @@ using SharpClaw.Application.Core.Clients; using SharpClaw.Contracts.DTOs.Providers; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Contracts.Persistence; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Application.Core/Services/ProviderService.cs b/SharpClaw.Application.Core/Services/ProviderService.cs index 5aefada9..0fea22fb 100644 --- a/SharpClaw.Application.Core/Services/ProviderService.cs +++ b/SharpClaw.Application.Core/Services/ProviderService.cs @@ -4,8 +4,9 @@ using SharpClaw.Contracts.DTOs.Models; using SharpClaw.Contracts.DTOs.Providers; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Contracts.Persistence; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Application.Core/Services/RoleService.cs b/SharpClaw.Application.Core/Services/RoleService.cs index 12fae880..5c849b5f 100644 --- a/SharpClaw.Application.Core/Services/RoleService.cs +++ b/SharpClaw.Application.Core/Services/RoleService.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.Roles; using SharpClaw.Contracts.Enums; diff --git a/SharpClaw.Application.Core/Services/ScheduledTaskService.cs b/SharpClaw.Application.Core/Services/ScheduledTaskService.cs deleted file mode 100644 index 0c51418f..00000000 --- a/SharpClaw.Application.Core/Services/ScheduledTaskService.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Text.Json; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Contracts.DTOs.Tasks; -using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Persistence; - -namespace SharpClaw.Application.Services; - -public sealed class ScheduledTaskService( - IServiceScopeFactory scopeFactory, - IConfiguration configuration, - ILogger logger) : BackgroundService -{ - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("ScheduledTaskService started."); - - try - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await ProcessDueTasksAsync(stoppingToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "Error in task processing loop."); - } - - await Task.Delay(PollInterval, stoppingToken); - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // Normal host shutdown — nothing to do. - } - } - - private async Task ProcessDueTasksAsync(CancellationToken ct) - { - using var scope = scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - var now = DateTimeOffset.UtcNow; - - var dueTasks = await db.ScheduledTasks - .Where(t => t.Status == ScheduledTaskStatus.Pending && t.NextRunAt <= now) - .ToListAsync(ct); - - var missedThreshold = TimeSpan.FromMinutes( - configuration.GetValue("Scheduler:MissedFireThresholdMinutes", 60)); - - foreach (var task in dueTasks) - { - task.Status = ScheduledTaskStatus.Running; - task.LastRunAt = now; - await db.SaveChangesAsync(ct); - - try - { - logger.LogInformation("Executing task {TaskName} ({TaskId}).", task.Name, task.Id); - - if (task.TaskDefinitionId.HasValue) - { - var taskSvc = scope.ServiceProvider.GetRequiredService(); - var orchestrator = scope.ServiceProvider.GetRequiredService(); - - Dictionary? paramValues = null; - if (!string.IsNullOrEmpty(task.ParameterValuesJson)) - paramValues = JsonSerializer.Deserialize>( - task.ParameterValuesJson); - - var instance = await taskSvc.CreateInstanceAsync( - new StartTaskInstanceRequest( - TaskDefinitionId: task.TaskDefinitionId.Value, - ParameterValues: paramValues), - callerAgentId: task.CallerAgentId, - ct: ct); - - await orchestrator.StartAsync(instance.Id, ct); - - logger.LogInformation( - "Scheduled job {TaskName} ({TaskId}) launched task instance {InstanceId}.", - task.Name, task.Id, instance.Id); - } - - task.Status = ScheduledTaskStatus.Completed; - task.RetryCount = 0; - task.LastError = null; - - // Missed-fire: if the job fired significantly late and the policy - // is Skip, advance without a second execution. - bool wasMissed = (now - task.NextRunAt) > missedThreshold; - if (task.MissedFirePolicy == MissedFirePolicy.Skip && wasMissed) - { - AdvanceNextRunAt(task, now); - await db.SaveChangesAsync(ct); - continue; - } - - AdvanceNextRunAt(task, now); - - logger.LogInformation("Task {TaskName} ({TaskId}) completed.", task.Name, task.Id); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - task.RetryCount++; - task.LastError = ex.Message; - - if (task.RetryCount < task.MaxRetries) - { - task.Status = ScheduledTaskStatus.Pending; - task.NextRunAt = now.AddSeconds(30 * task.RetryCount); // linear backoff - logger.LogWarning(ex, "Task {TaskName} failed (attempt {Attempt}/{Max}), retrying.", - task.Name, task.RetryCount, task.MaxRetries); - } - else - { - task.Status = ScheduledTaskStatus.Failed; - logger.LogError(ex, "Task {TaskName} failed permanently after {Max} attempts.", - task.Name, task.MaxRetries); - } - } - - await db.SaveChangesAsync(ct); - } - } - - private static void AdvanceNextRunAt(ScheduledJobDB task, DateTimeOffset now) - { - if (!string.IsNullOrEmpty(task.CronExpression)) - { - var next = CronEvaluator.GetNextOccurrence( - task.CronExpression, now, task.CronTimezone); - - task.Status = next.HasValue ? ScheduledTaskStatus.Pending - : ScheduledTaskStatus.Completed; - task.NextRunAt = next ?? task.NextRunAt; - } - else if (task.RepeatInterval.HasValue) - { - task.Status = ScheduledTaskStatus.Pending; - task.NextRunAt = now.Add(task.RepeatInterval.Value); - } - // else: one-shot — caller already set Completed above, leave it. - } -} diff --git a/SharpClaw.Application.Core/Services/SeedingService.cs b/SharpClaw.Application.Core/Services/SeedingService.cs index 946665af..0ba252fe 100644 --- a/SharpClaw.Application.Core/Services/SeedingService.cs +++ b/SharpClaw.Application.Core/Services/SeedingService.cs @@ -5,12 +5,13 @@ using Microsoft.Extensions.Logging; using SharpClaw.Application.Core.Modules; using SharpClaw.Application.Core.Clients; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Providers; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Providers.Common; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Application.Core/Services/TaskInstanceLauncher.cs b/SharpClaw.Application.Core/Services/TaskInstanceLauncher.cs new file mode 100644 index 00000000..992abeab --- /dev/null +++ b/SharpClaw.Application.Core/Services/TaskInstanceLauncher.cs @@ -0,0 +1,35 @@ +using SharpClaw.Contracts.DTOs.Tasks; +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Application.Services; + +/// +/// Host implementation of . Delegates +/// to for instance creation and to +/// for execution. +/// +public sealed class TaskInstanceLauncher( + TaskService taskService, + TaskOrchestrator orchestrator) : ITaskInstanceLauncher +{ + public async Task LaunchAsync( + Guid taskDefinitionId, + IReadOnlyDictionary? parameterValues, + Guid? callerAgentId, + CancellationToken ct) + { + Dictionary? values = parameterValues is null + ? null + : new Dictionary(parameterValues, StringComparer.Ordinal); + + var instance = await taskService.CreateInstanceAsync( + new StartTaskInstanceRequest( + TaskDefinitionId: taskDefinitionId, + ParameterValues: values), + callerAgentId: callerAgentId, + ct: ct); + + await orchestrator.StartAsync(instance.Id, ct); + return instance.Id; + } +} diff --git a/SharpClaw.Application.Core/Services/TaskOrchestrator.cs b/SharpClaw.Application.Core/Services/TaskOrchestrator.cs index 9ebf13da..b1e54e40 100644 --- a/SharpClaw.Application.Core/Services/TaskOrchestrator.cs +++ b/SharpClaw.Application.Core/Services/TaskOrchestrator.cs @@ -5,14 +5,14 @@ using System.Threading.Channels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Compilation; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts; using SharpClaw.Contracts.DTOs.AgentActions; using SharpClaw.Contracts.DTOs.Chat; @@ -39,16 +39,10 @@ public sealed class TaskOrchestrator( SharpClawDbContext db, IPersistenceEntityResolver entities, TaskService taskService, - ChatService chatService, - AgentJobService agentJobService, - IHttpClientFactory httpClientFactory, IServiceScopeFactory scopeFactory, TaskRuntimeHost runtimeHost, - ModuleRegistry moduleRegistry, IEnumerable stepExtensions) { - private readonly ChatService _chatService = chatService; - private readonly AgentJobService _agentJobService = agentJobService; private readonly IReadOnlyList _stepExtensions = [.. stepExtensions]; private readonly IServiceScopeFactory _scopeFactory = scopeFactory; @@ -165,7 +159,10 @@ private async Task ExecutePlanAsync( var chatService = scope.ServiceProvider.GetService(); var agentJobService = scope.ServiceProvider.GetService(); - var context = new TaskExecutionContext(instanceId, plan, runtime, ct); + var context = new TaskExecutionContext(instanceId, plan, runtime, ct) + { + Services = scope.ServiceProvider, + }; // ── Set up shared data store for inter-agent communication ── var store = TaskSharedData.GetOrCreate(instanceId); @@ -271,985 +268,39 @@ private async Task ExecuteStepAsync( context.CancellationToken.ThrowIfCancellationRequested(); await WaitIfPausedAsync(context); - return (step.StepKey ?? "") switch - { - WellKnownTaskStepKeys.DeclareVariable or WellKnownTaskStepKeys.Assign - => ExecuteDeclareOrAssignStep(step, context), - WellKnownTaskStepKeys.Log - => await ExecuteLogStepAsync(step, context, taskService), - WellKnownTaskStepKeys.Delay - => await ExecuteDelayStepAsync(step, context), - WellKnownTaskStepKeys.WaitUntilStopped - => await ExecuteWaitUntilStoppedStepAsync(context), - WellKnownTaskStepKeys.Emit - => await ExecuteEmitStepAsync(step, context, db), - WellKnownTaskStepKeys.Chat - => await ExecuteChatStepAsync(step, context, db, taskService, chatService), - WellKnownTaskStepKeys.ChatStream - => await ExecuteChatStreamStepAsync(step, context, db, taskService, chatService), - WellKnownTaskStepKeys.ParseResponse - => ExecuteParseResponseStep(step, context), - WellKnownTaskStepKeys.HttpRequest - => await ExecuteHttpRequestStepAsync(step, context, taskService), - WellKnownTaskStepKeys.FindModel - => await ExecuteFindModelStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.FindProvider - => await ExecuteFindProviderStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.FindAgent - => await ExecuteFindAgentStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.CreateAgent - => await ExecuteCreateAgentStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.CreateThread - => await ExecuteCreateThreadStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.ChatToThread - => await ExecuteChatToThreadStepAsync(step, context, db, taskService, chatService), - WellKnownTaskStepKeys.CreateRole - => await ExecuteCreateRoleStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.FindRole - => await ExecuteFindRoleStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.SetRolePermissions - => await ExecuteSetRolePermissionsStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.AssignRole - => await ExecuteAssignRoleStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.CreateChannel - => await ExecuteCreateChannelStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.FindChannel - => await ExecuteFindChannelStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.AddAllowedAgent - => await ExecuteAddAllowedAgentStepAsync(step, context, db, taskService), - WellKnownTaskStepKeys.Conditional - => await ExecuteConditionalStepAsync(step, context, db, taskService, chatService, agentJobService), - WellKnownTaskStepKeys.Loop - => await ExecuteLoopStepAsync(step, context, db, taskService, chatService, agentJobService), - WellKnownTaskStepKeys.EventHandler - => await ExecuteEventHandlerStepAsync(step, context, taskService), - WellKnownTaskStepKeys.Return - => TaskStepExecutionResult.Return, - WellKnownTaskStepKeys.Evaluate - => ExecuteEvaluateStep(step, context), - _ => await ExecuteModuleStepAsync(step, context, taskService), - }; - } - - // ── Core step executors ───────────────────────────────────────── - - private static TaskStepExecutionResult ExecuteDeclareOrAssignStep( - TaskStepDefinition step, TaskExecutionContext context) - { - context.Variables[step.VariableName ?? ""] = step.Expression; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteLogStepAsync( - TaskStepDefinition step, TaskExecutionContext context, TaskService taskService) - { - var logMessage = ResolveExpression(step.Expression, context); - await taskService.AppendLogAsync(context.InstanceId, logMessage, ct: context.CancellationToken); - await context.Runtime.WriteEventAsync(TaskOutputEventType.Log, logMessage); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteDelayStepAsync( - TaskStepDefinition step, TaskExecutionContext context) - { - if (int.TryParse(step.Expression, out var delayMs)) - await DelayWithPauseAsync(delayMs, context); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteWaitUntilStoppedStepAsync( - TaskExecutionContext context) - { - try - { - await Task.Delay(Timeout.Infinite, context.CancellationToken); - } - catch (OperationCanceledException) - { - // Expected — task was stopped - } - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteEmitStepAsync( - TaskStepDefinition step, TaskExecutionContext context, SharpClawDbContext db) - { - var outputJson = ResolveExpression(step.Expression, context); - await EmitOutputAsync(context.InstanceId, outputJson, db, context.Runtime); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteChatStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService, ChatService? chatService) - { - var requiredChatService = chatService - ?? throw new InvalidOperationException("ChatService is not available for task chat steps."); - var channelId = await GetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db); - var message = ResolveExpression(step.Expression, context); - Guid? agentId = step.Arguments is { Count: > 0 } - ? Guid.TryParse(ResolveExpression(step.Arguments[0], context), out var aid) ? aid : null - : null; - - var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, - TaskContext: new TaskChatContext(context.InstanceId, context.Plan.TaskName)); - var response = await requiredChatService.SendMessageAsync(channelId, request, ct: context.CancellationToken); - - await taskService.AppendLogAsync( - context.InstanceId, $"Chat → {response.AssistantMessage.Content?.Length ?? 0} chars", - ct: context.CancellationToken); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = response.AssistantMessage.Content; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteChatStreamStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService, ChatService? chatService) - { - var requiredChatService = chatService - ?? throw new InvalidOperationException("ChatService is not available for task chat stream steps."); - var channelId = await GetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db); - var message = ResolveExpression(step.Expression, context); - Guid? agentId = step.Arguments is { Count: > 0 } - ? Guid.TryParse(ResolveExpression(step.Arguments[0], context), out var aid) ? aid : null - : null; - - var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, - TaskContext: new TaskChatContext(context.InstanceId, context.Plan.TaskName)); - var sb = new StringBuilder(); - - await foreach (var evt in requiredChatService.SendMessageStreamAsync( - channelId, request, AutoApproveAsync, ct: context.CancellationToken)) - { - if (evt.Type == ChatStreamEventType.TextDelta && evt.Delta is not null) - sb.Append(evt.Delta); - } - - await taskService.AppendLogAsync( - context.InstanceId, $"ChatStream → {sb.Length} chars", - ct: context.CancellationToken); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = sb.ToString(); - return TaskStepExecutionResult.Continue; - } - - private static TaskStepExecutionResult ExecuteParseResponseStep( - TaskStepDefinition step, TaskExecutionContext context) - { - var text = ResolveExpression(step.Expression, context); - var parsed = ParseStructuredResponse(text, step.TypeName, context.Plan.Definition); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = parsed; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteHttpRequestStepAsync( - TaskStepDefinition step, TaskExecutionContext context, TaskService taskService) - { - var url = ResolveExpression(step.Expression, context); - var method = step.HttpMethod?.ToUpperInvariant() ?? "GET"; - - using var client = httpClientFactory.CreateClient("TaskOrchestrator"); - using var request = CreateHttpRequestMessage(step, context, method, url); - using var httpResponse = await client.SendAsync(request, context.CancellationToken); - - var content = await httpResponse.Content.ReadAsStringAsync(context.CancellationToken); - - await taskService.AppendLogAsync( - context.InstanceId, $"HTTP {method} {url} → {(int)httpResponse.StatusCode}", - ct: context.CancellationToken); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = content; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteFindModelStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var search = ResolveExpression(step.Expression, context); - var model = await db.Models - .FirstOrDefaultAsync(m => m.CustomId == search || m.Name == search, context.CancellationToken); - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = model?.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"FindModel '{search}' → {(model is not null ? model.Id : "not found")}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteFindProviderStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var search = ResolveExpression(step.Expression, context); - var provider = await db.Providers - .FirstOrDefaultAsync(p => p.CustomId == search || p.Name == search, context.CancellationToken); - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = provider?.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"FindProvider '{search}' → {(provider is not null ? provider.Id : "not found")}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteFindAgentStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var search = ResolveExpression(step.Expression, context); - var agent = await db.Agents - .FirstOrDefaultAsync(a => a.CustomId == search || a.Name == search, context.CancellationToken); - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = agent?.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"FindAgent '{search}' → {(agent is not null ? agent.Id : "not found")}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteCreateAgentStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [0]=name, [1]=modelId, optional [2]=systemPrompt, optional [3]=customId - var name = step.Arguments is { Count: > 0 } - ? ResolveExpression(step.Arguments[0], context) : "Task Agent"; - var modelIdStr = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - var systemPrompt = step.Arguments is { Count: > 2 } - ? ResolveExpression(step.Arguments[2], context) : null; - var customId = step.Arguments is { Count: > 3 } - ? ResolveExpression(step.Arguments[3], context) : null; - - Guid modelId = Guid.TryParse(modelIdStr, out var mid) ? mid : Guid.Empty; - - // Upsert: if a customId is provided and an agent with that customId already - // exists, update it instead of creating a duplicate. - SharpClaw.Infrastructure.Models.AgentDB? agentEntity = null; - if (!string.IsNullOrEmpty(customId)) - { - agentEntity = await db.Agents - .Where(a => a.CustomId == customId) - .OrderByDescending(a => a.CreatedAt) - .FirstOrDefaultAsync(context.CancellationToken); - } - - if (agentEntity is not null) - { - agentEntity.Name = name; - agentEntity.ModelId = modelId; - agentEntity.SystemPrompt = systemPrompt; - await db.SaveChangesAsync(context.CancellationToken); - } - else - { - agentEntity = new SharpClaw.Infrastructure.Models.AgentDB - { - Name = name, - ModelId = modelId, - SystemPrompt = systemPrompt, - CustomId = customId, - }; - db.Agents.Add(agentEntity); - await db.SaveChangesAsync(context.CancellationToken); - } - - // Auto-add the new agent to the task's channel AllowedAgents so subsequent - // Chat/ChatStream steps can use it. Context-only setup tasks may create - // the agent before creating their channel, so this is only applicable once - // a channel exists. - if (await TryGetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db) is { } createAgentChannelId) - { - var createAgentChannel = await db.Channels - .Include(c => c.AllowedAgents) - .FirstOrDefaultAsync(c => c.Id == createAgentChannelId, context.CancellationToken); - if (createAgentChannel is not null && - !createAgentChannel.AllowedAgents.Any(a => a.Id == agentEntity.Id)) - { - createAgentChannel.AllowedAgents.Add(agentEntity); - await db.SaveChangesAsync(context.CancellationToken); - } - } - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = agentEntity.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"CreateAgent '{name}' → {agentEntity.Id}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteCreateThreadStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [0]=channelId (or any non-Guid string to use instance channel), optional [1]=name - var channelIdStr = step.Arguments is { Count: > 0 } - ? ResolveExpression(step.Arguments[0], context) : null; - var threadName = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - - Guid threadChannelId = Guid.TryParse(channelIdStr, out var cid) - ? cid - : await GetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db); - - var threadEntity = new ChatThreadDB - { - Name = threadName ?? $"Task Thread {DateTimeOffset.UtcNow:HH:mm}", - ChannelId = threadChannelId, - }; - db.ChatThreads.Add(threadEntity); - await db.SaveChangesAsync(context.CancellationToken); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = threadEntity.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"CreateThread '{threadEntity.Name}' → {threadEntity.Id}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteChatToThreadStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService, ChatService? chatService) - { - var requiredChatService = chatService - ?? throw new InvalidOperationException("ChatService is not available for task thread chat steps."); - // Arguments: [0]=threadId, optional [2]=agentId; Expression=message - var threadIdStr = step.Arguments is { Count: > 0 } - ? ResolveExpression(step.Arguments[0], context) : null; - var message = ResolveExpression(step.Expression, context); - Guid? agentId = step.Arguments is { Count: > 2 } - ? Guid.TryParse(ResolveExpression(step.Arguments[2], context), out var aid) ? aid : null - : null; - - if (!Guid.TryParse(threadIdStr, out var threadId)) - throw new InvalidOperationException($"Invalid thread ID: {threadIdStr}"); - - var channelId = await GetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db); - var request = new ChatRequest(message, agentId, WellKnownClientKeys.Api, - TaskContext: new TaskChatContext(context.InstanceId, context.Plan.TaskName)); - var response = await requiredChatService.SendMessageAsync( - channelId, request, threadId: threadId, ct: context.CancellationToken); - - await taskService.AppendLogAsync( - context.InstanceId, - $"ChatToThread {threadId} → {response.AssistantMessage.Content?.Length ?? 0} chars", - ct: context.CancellationToken); - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = response.AssistantMessage.Content; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteCreateRoleStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var roleName = ResolveExpression(step.Expression, context); - - // Upsert by name: reuse if a role with this name already exists. - var existing = await db.Roles.FirstOrDefaultAsync( - r => r.Name == roleName, context.CancellationToken); - Guid roleId; - if (existing is not null) - { - roleId = existing.Id; - } - else - { - using var roleScope = _scopeFactory.CreateScope(); - var roleService = roleScope.ServiceProvider.GetRequiredService(); - var created = await roleService.CreateAsync(roleName, context.CancellationToken); - roleId = created.Id; - } - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = roleId.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"CreateRole '{roleName}' → {roleId}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteFindRoleStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var search = ResolveExpression(step.Expression, context); - var role = await db.Roles.FirstOrDefaultAsync( - r => r.Name == search, context.CancellationToken); - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = role?.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"FindRole '{search}' → {(role is not null ? role.Id : "not found")}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteSetRolePermissionsStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [1]=flags json (SetRolePermissionsRequest serialized); Expression=roleId - var roleIdStr = ResolveExpression(step.Expression, context); - var flagsJson = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - - if (!Guid.TryParse(roleIdStr, out var setPermRoleId)) - throw new InvalidOperationException($"SetRolePermissions: invalid role ID '{roleIdStr}'."); - - SharpClaw.Contracts.DTOs.Roles.SetRolePermissionsRequest permRequest = !string.IsNullOrWhiteSpace(flagsJson) - ? JsonSerializer.Deserialize( - flagsJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) - ?? new SharpClaw.Contracts.DTOs.Roles.SetRolePermissionsRequest() - : new SharpClaw.Contracts.DTOs.Roles.SetRolePermissionsRequest(); - - var adminUser = await db.Users.FirstOrDefaultAsync(u => u.IsUserAdmin, context.CancellationToken) - ?? throw new InvalidOperationException("SetRolePermissions: no admin user found in database."); - - await SetRolePermissionsAsync(db, setPermRoleId, permRequest, context.CancellationToken); - - await taskService.AppendLogAsync( - context.InstanceId, $"SetRolePermissions {setPermRoleId}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private static async Task SetRolePermissionsAsync( - SharpClawDbContext db, - Guid roleId, - SharpClaw.Contracts.DTOs.Roles.SetRolePermissionsRequest request, - CancellationToken ct) - { - var role = await db.Roles - .Include(r => r.PermissionSet) - .FirstOrDefaultAsync(r => r.Id == roleId, ct) - ?? throw new InvalidOperationException($"SetRolePermissions: role '{roleId}' not found."); - - var permissionSet = role.PermissionSet; - if (permissionSet is null) - { - permissionSet = new SharpClaw.Application.Infrastructure.Models.Clearance.PermissionSetDB(); - db.PermissionSets.Add(permissionSet); - await db.SaveChangesAsync(ct); - role.PermissionSetId = permissionSet.Id; - } - - await db.GlobalFlags - .Where(f => f.PermissionSetId == permissionSet.Id) - .ExecuteDeleteCompatAsync(db, ct); - - await db.ResourceAccesses - .Where(a => a.PermissionSetId == permissionSet.Id && a.ResourceId != WellKnownIds.AllResources) - .ExecuteDeleteCompatAsync(db, ct); - - foreach (var (flagKey, clearance) in request.GlobalFlags ?? new Dictionary()) - { - db.GlobalFlags.Add(new GlobalFlagDB - { - PermissionSetId = permissionSet.Id, - FlagKey = flagKey, - Clearance = clearance, - }); - } - - foreach (var (resourceType, grants) in request.ResourceGrants ?? new Dictionary>()) - { - foreach (var grant in grants) - { - var existingWildcard = grant.ResourceId == WellKnownIds.AllResources - ? await db.ResourceAccesses.FirstOrDefaultAsync(a => - a.PermissionSetId == permissionSet.Id && - a.ResourceType == resourceType && - a.ResourceId == WellKnownIds.AllResources, ct) - : null; - - if (existingWildcard is not null) - { - existingWildcard.Clearance = grant.Clearance; - continue; - } - - db.ResourceAccesses.Add(new ResourceAccessDB - { - PermissionSetId = permissionSet.Id, - ResourceType = resourceType, - ResourceId = grant.ResourceId, - Clearance = grant.Clearance, - }); - } - } - - await db.SaveChangesAsync(ct); - } - - private async Task ExecuteAssignRoleStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [1]=roleId; Expression=agentId - var agentIdStr = ResolveExpression(step.Expression, context); - var roleIdStr = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - - if (!Guid.TryParse(agentIdStr, out var assignAgentId)) - throw new InvalidOperationException($"AssignRole: invalid agent ID '{agentIdStr}'."); - if (!Guid.TryParse(roleIdStr, out var assignRoleId)) - throw new InvalidOperationException($"AssignRole: invalid role ID '{roleIdStr}'."); - - var agentEntity = await db.Agents.FirstOrDefaultAsync(a => a.Id == assignAgentId, context.CancellationToken) - ?? throw new InvalidOperationException($"AssignRole: agent '{assignAgentId}' not found."); - var roleExists = await db.Roles.AnyAsync(r => r.Id == assignRoleId, context.CancellationToken); - if (!roleExists) - throw new InvalidOperationException($"AssignRole: role '{assignRoleId}' not found."); - - agentEntity.RoleId = assignRoleId; - await db.SaveChangesAsync(context.CancellationToken); - - await taskService.AppendLogAsync( - context.InstanceId, $"AssignRole agent={assignAgentId} role={assignRoleId}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteCreateChannelStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [1]=agentId, optional [2]=customId; Expression=title - var channelTitle = ResolveExpression(step.Expression, context); - var channelAgentIdStr = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - var channelCustomId = step.Arguments is { Count: > 2 } - ? ResolveExpression(step.Arguments[2], context) : null; - - if (!Guid.TryParse(channelAgentIdStr, out var channelAgentId)) - throw new InvalidOperationException($"CreateChannel: invalid agent ID '{channelAgentIdStr}'."); - - // Inherit the context from the task instance when set, so the channel - // is linked to the same context the task was started against. - Guid? instanceContextId = await db.TaskInstances - .Where(i => i.Id == context.InstanceId) - .Select(i => i.ContextId) - .FirstOrDefaultAsync(context.CancellationToken); - - // Upsert by customId when provided; otherwise upsert by title. - ChannelDB? existingChannel = !string.IsNullOrEmpty(channelCustomId) - ? await db.Channels.FirstOrDefaultAsync(c => c.CustomId == channelCustomId, context.CancellationToken) - : await db.Channels.FirstOrDefaultAsync(c => c.Title == channelTitle, context.CancellationToken); - - Guid channelResultId; - if (existingChannel is not null) - { - existingChannel.Title = channelTitle; - existingChannel.AgentId = channelAgentId; - if (!string.IsNullOrEmpty(channelCustomId)) - existingChannel.CustomId = channelCustomId; - if (instanceContextId.HasValue) - existingChannel.AgentContextId = instanceContextId; - await db.SaveChangesAsync(context.CancellationToken); - channelResultId = existingChannel.Id; - } - else - { - using var channelScope = _scopeFactory.CreateScope(); - var channelService = channelScope.ServiceProvider.GetRequiredService(); - var channelResp = await channelService.CreateAsync( - new SharpClaw.Contracts.DTOs.Channels.CreateChannelRequest( - AgentId: channelAgentId, - Title: channelTitle, - CustomId: channelCustomId, - ContextId: instanceContextId), - context.CancellationToken); - channelResultId = channelResp.Id; - } - - // When the task instance has no channel yet (context-mode start), adopt - // this channel as the instance channel so Chat and other steps work. - if (context.ChannelId == Guid.Empty) - { - var inst = await db.TaskInstances.FindAsync([context.InstanceId], context.CancellationToken); - if (inst is not null && inst.ChannelId is null) - { - inst.ChannelId = channelResultId; - await db.SaveChangesAsync(context.CancellationToken); - } - context.ChannelId = channelResultId; - } - - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = channelResultId.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"CreateChannel '{channelTitle}' → {channelResultId}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteFindChannelStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - var search = ResolveExpression(step.Expression, context); - var channel = await db.Channels.FirstOrDefaultAsync( - c => c.CustomId == search || c.Title == search, context.CancellationToken); - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = channel?.Id.ToString(); - await taskService.AppendLogAsync( - context.InstanceId, $"FindChannel '{search}' → {(channel is not null ? channel.Id : "not found")}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteAddAllowedAgentStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService) - { - // Arguments: [0]=agentId (Expression), optional [1]=channelId (Arguments[1]) - // When channelId is omitted or not a valid Guid, the instance channel is used. - var agentIdStr = ResolveExpression(step.Expression, context); - var channelIdStr = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) : null; - - if (!Guid.TryParse(agentIdStr, out var addAgentId)) - throw new InvalidOperationException($"AddAllowedAgent: invalid agent ID '{agentIdStr}'."); - - var agentEntity = await db.Agents - .FirstOrDefaultAsync(a => a.Id == addAgentId, context.CancellationToken) - ?? throw new InvalidOperationException($"AddAllowedAgent: agent '{addAgentId}' not found."); - - Guid targetChannelId = Guid.TryParse(channelIdStr, out var cid) - ? cid - : await GetInstanceChannelIdAsync(context.InstanceId, context.CancellationToken, db); - - var targetChannel = await db.Channels - .Include(c => c.AllowedAgents) - .FirstOrDefaultAsync(c => c.Id == targetChannelId, context.CancellationToken); - if (targetChannel is null) - throw new InvalidOperationException($"AddAllowedAgent: channel '{targetChannelId}' not found."); - - if (!targetChannel.AllowedAgents.Any(a => a.Id == agentEntity.Id)) - { - targetChannel.AllowedAgents.Add(agentEntity); - await db.SaveChangesAsync(context.CancellationToken); - } - - await taskService.AppendLogAsync( - context.InstanceId, $"AddAllowedAgent agent={addAgentId} → channel={targetChannelId}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteConditionalStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService, - ChatService? chatService, AgentJobService? agentJobService) - { - var branch = EvaluateCondition(step.Expression, context) ? step.Body : step.ElseBody; - if (branch is not null) - { - foreach (var childStep in branch) - { - context.CancellationToken.ThrowIfCancellationRequested(); - var childResult = await ExecuteStepAsync(childStep, context, db, taskService, chatService, agentJobService); - if (childResult == TaskStepExecutionResult.Return) - return TaskStepExecutionResult.Return; - } - } - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteLoopStepAsync( - TaskStepDefinition step, TaskExecutionContext context, - SharpClawDbContext db, TaskService taskService, - ChatService? chatService, AgentJobService? agentJobService) - { - var loopKind = step.LoopKind ?? (step.VariableName is not null ? TaskLoopKind.ForEach : TaskLoopKind.While); - if (loopKind == TaskLoopKind.ForEach) - { - foreach (var item in EnumerateLoopValues(step, context)) - { - context.CancellationToken.ThrowIfCancellationRequested(); - await WaitIfPausedAsync(context); - if (step.VariableName is not null) - context.Variables[step.VariableName] = item; - - if (step.Body is null) continue; - - foreach (var childStep in step.Body) - { - context.CancellationToken.ThrowIfCancellationRequested(); - var childResult = await ExecuteStepAsync(childStep, context, db, taskService, chatService, agentJobService); - if (childResult == TaskStepExecutionResult.Return) - return TaskStepExecutionResult.Return; - } - } - return TaskStepExecutionResult.Continue; - } - - while (EvaluateCondition(step.Expression, context)) - { - context.CancellationToken.ThrowIfCancellationRequested(); - await WaitIfPausedAsync(context); - if (step.Body is not null) - { - foreach (var childStep in step.Body) - { - context.CancellationToken.ThrowIfCancellationRequested(); - var childResult = await ExecuteStepAsync(childStep, context, db, taskService, chatService, agentJobService); - if (childResult == TaskStepExecutionResult.Return) - return TaskStepExecutionResult.Return; - } - } - } - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteEventHandlerStepAsync( - TaskStepDefinition step, TaskExecutionContext context, TaskService taskService) - { - if (step.TriggerKind is null) - throw new InvalidOperationException("EventHandler step requires an explicit TriggerKind."); - context.EventHandlers.Add(new RegisteredEventHandler( - step.TriggerKind.Value, - step.ModuleTriggerKey, - step.HandlerParameter, - step.Body ?? [])); - await taskService.AppendLogAsync( - context.InstanceId, $"Registered event handler: {step.TriggerKind}", - ct: context.CancellationToken); - return TaskStepExecutionResult.Continue; - } - - private static TaskStepExecutionResult ExecuteEvaluateStep( - TaskStepDefinition step, TaskExecutionContext context) - { - if (step.ResultVariable is not null) - context.Variables[step.ResultVariable] = step.Expression; - return TaskStepExecutionResult.Continue; - } - - private async Task ExecuteModuleStepAsync( - TaskStepDefinition step, TaskExecutionContext context, TaskService taskService) - { var stepKey = step.StepKey ?? ""; var executor = _stepExtensions.FirstOrDefault(e => e.CanExecute(stepKey)); - if (executor is not null) - { - var moduleCtx = new TaskStepContextAdapter(context, this, taskService); - var resolvedArgs = step.Arguments?.Select(a => ResolveExpression(a, context)).ToList(); - var resolvedExpr = step.Expression is not null ? ResolveExpression(step.Expression, context) : null; - await executor.ExecuteAsync(stepKey, moduleCtx, resolvedArgs, resolvedExpr, step.ResultVariable); - } - return TaskStepExecutionResult.Continue; - } - - // ═══════════════════════════════════════════════════════════════ - // Helpers - // ═══════════════════════════════════════════════════════════════ - - private static IEnumerable EnumerateLoopValues(TaskStepDefinition step, TaskExecutionContext context) - { - var resolved = ResolveExpression(step.Expression, context); - if (string.IsNullOrWhiteSpace(resolved)) - { - yield break; - } - - if (context.Variables.TryGetValue(resolved, out var variableValue)) - { - foreach (var item in EnumerateValue(variableValue)) - { - yield return item; - } - - yield break; - } - - foreach (var item in EnumerateValue(resolved)) - { - yield return item; - } - } - - private static IEnumerable EnumerateValue(object? value) - { - if (value is null) - { - yield break; - } - - if (value is string text) - { - if (TryEnumerateJsonArray(text, out var items)) - { - foreach (var item in items) - { - yield return item; - } - - yield break; - } - - yield return text; - yield break; - } - - if (value is System.Collections.IEnumerable enumerable and not string) - { - foreach (var item in enumerable) - { - yield return item; - } - } - } - - private static bool TryEnumerateJsonArray(string text, out List values) - { - values = []; - try - { - using var doc = JsonDocument.Parse(text); - if (doc.RootElement.ValueKind != JsonValueKind.Array) - { - return false; - } - - foreach (var item in doc.RootElement.EnumerateArray()) - { - values.Add(item.ValueKind == JsonValueKind.String - ? item.GetString() - : item.GetRawText()); - } - - return true; - } - catch (JsonException) - { - return false; - } - } - - private static string ParseStructuredResponse(string text, string? typeName, TaskScriptDefinition definition) - { - var jsonText = ExtractJsonObject(text); - if (jsonText is null) - { - throw new InvalidOperationException("ParseResponse expected a JSON object in the source text."); - } - - using var doc = JsonDocument.Parse(jsonText); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - throw new InvalidOperationException("ParseResponse expected a JSON object payload."); - } - - ValidateParsedResponseShape(doc.RootElement, typeName, definition); - return JsonSerializer.Serialize(doc.RootElement); - } - - private static string? ExtractJsonObject(string text) - { - var jsonStart = text.IndexOf('{'); - var jsonEnd = text.LastIndexOf('}'); - return jsonStart >= 0 && jsonEnd > jsonStart - ? text[jsonStart..(jsonEnd + 1)] - : null; - } - - private static void ValidateParsedResponseShape(JsonElement element, string? typeName, TaskScriptDefinition definition) - { - if (string.IsNullOrWhiteSpace(typeName)) - { - return; - } - - var dataType = definition.DataTypes.FirstOrDefault(dt => dt.Name == typeName); - if (dataType is null) - { - return; - } - - foreach (var property in dataType.Properties) - { - if (!element.TryGetProperty(property.Name, out var propertyElement)) - { - throw new InvalidOperationException($"ParseResponse<{typeName}> missing property '{property.Name}'."); - } + if (executor is null) + return TaskStepExecutionResult.Continue; - ValidateParsedProperty(propertyElement, property); - } - } + var moduleCtx = new TaskStepContextAdapter(context, this, taskService); - private static void ValidateParsedProperty(JsonElement value, TaskPropertyDefinition property) - { - if (property.IsCollection) + // Invocation-aware path: raw step access for control-flow primitives. + if (executor is ITaskStepInvocationExecutor invocationExecutor) { - if (value.ValueKind != JsonValueKind.Array) - { - throw new InvalidOperationException($"Property '{property.Name}' must be a JSON array."); - } - - return; + var result = await invocationExecutor.ExecuteInvocationAsync(step, moduleCtx); + return result == TaskStepResult.Return + ? TaskStepExecutionResult.Return + : TaskStepExecutionResult.Continue; } - if (!IsCompatibleJsonValue(value, property.TypeName)) + // Resolved-argument path: traditional module steps that consume runtime values. + var resolvedArgs = step.Arguments?.Select(a => ResolveExpression(a, context)).ToList(); + if (step.TypeName is not null) { - throw new InvalidOperationException($"Property '{property.Name}' does not match declared type '{property.TypeName}'."); + resolvedArgs ??= []; + resolvedArgs.Insert(0, step.TypeName); } + var resolvedExpr = step.Expression is not null ? ResolveExpression(step.Expression, context) : null; + var keepGoing = await executor.ExecuteAsync(stepKey, moduleCtx, resolvedArgs, resolvedExpr, step.ResultVariable); + return keepGoing ? TaskStepExecutionResult.Continue : TaskStepExecutionResult.Return; } - private static bool IsCompatibleJsonValue(JsonElement value, string typeName) - { - var normalizedType = typeName.TrimEnd('?'); - return normalizedType switch - { - "string" => value.ValueKind == JsonValueKind.String, - "int" or "long" or "double" or "decimal" => value.ValueKind == JsonValueKind.Number, - "bool" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, - "Guid" or "DateTime" or "DateTimeOffset" or "TimeSpan" => value.ValueKind == JsonValueKind.String, - _ => value.ValueKind == JsonValueKind.Object - }; - } - - private static HttpRequestMessage CreateHttpRequestMessage(TaskStepDefinition step, TaskExecutionContext context, string method, string url) - { - var request = new HttpRequestMessage(new HttpMethod(method), url); - if (method is "POST" or "PUT") - { - var body = step.Arguments is { Count: > 1 } - ? ResolveExpression(step.Arguments[1], context) - : step.Arguments is { Count: > 0 } - ? ResolveExpression(step.Arguments[0], context) - : string.Empty; - request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - } - - return request; - } + // ── Helpers ───────────────────────────────────────────────────── private static Task WaitIfPausedAsync(TaskExecutionContext context) => context.Runtime.WaitIfPausedAsync(context.CancellationToken); - private async Task DelayWithPauseAsync(int delayMs, TaskExecutionContext context) - { - const int chunkMs = 250; - var remaining = delayMs; - while (remaining > 0) - { - await WaitIfPausedAsync(context); - var nextDelay = Math.Min(chunkMs, remaining); - await Task.Delay(nextDelay, context.CancellationToken); - remaining -= nextDelay; - } - } - private async Task EmitOutputAsync(Guid instanceId, string? outputJson, SharpClawDbContext db, TaskRuntimeInstance runtime) { // Persist the latest output snapshot @@ -1422,45 +473,6 @@ private static bool EvaluateCondition(string? expression, TaskExecutionContext c return !string.IsNullOrEmpty(resolved); } - // ═══════════════════════════════════════════════════════════════ - // Instance helpers - // ═══════════════════════════════════════════════════════════════ - - /// - /// Resolve the for a running instance. - /// When the instance was started with a context instead of a direct channel the - /// channel may not be set yet — the task is expected to call CreateChannel - /// first. This method throws only when a step that truly requires a channel is - /// reached before one has been established. - /// - private async Task GetInstanceChannelIdAsync(Guid instanceId, CancellationToken ct, SharpClawDbContext db) - { - var channelId = await TryGetInstanceChannelIdAsync(instanceId, ct, db); - - return channelId - ?? throw new InvalidOperationException( - $"Task instance {instanceId} has no channel yet. " + - "Call CreateChannel before using Chat, CreateThread, or other channel-dependent steps. " + - "Alternatively, start the instance with a ChannelId instead of a ContextId."); - } - - private static async Task TryGetInstanceChannelIdAsync(Guid instanceId, CancellationToken ct, SharpClawDbContext db) - => await db.TaskInstances - .Where(i => i.Id == instanceId) - .Select(i => i.ChannelId) - .FirstOrDefaultAsync(ct); - - /// - /// Auto-deny tool-call jobs that require approval during task execution. - /// Tasks only use permissions pre-granted in the channel; when a job - /// needs additional approval it is automatically cancelled. The - /// cancelled status propagates as a tool-result error — if the task - /// script does not handle it the orchestrator marks the instance as - /// failed. - /// - private static Task AutoApproveAsync(AgentJobResponse _, CancellationToken __) => - Task.FromResult(false); - // ═══════════════════════════════════════════════════════════════ // Execution context // ═══════════════════════════════════════════════════════════════ @@ -1478,13 +490,20 @@ private sealed class TaskExecutionContext( public CancellationToken CancellationToken { get; } = cancellationToken; public Dictionary Variables { get; } = new(StringComparer.Ordinal); public List EventHandlers { get; } = []; + + /// + /// Scoped service provider for the running execution scope. Set by + /// the orchestrator before stepping begins; module step executors + /// resolve services from this provider via + /// . + /// + public IServiceProvider Services { get; set; } = default!; } private sealed record RegisteredEventHandler( - TaskTriggerKind TriggerKind, - string? ModuleTriggerKey, + string ModuleTriggerKey, string? ParameterName, - IReadOnlyList Body); + IReadOnlyList Body); // ── Module extension adapters ──────────────────────────────────── @@ -1500,6 +519,7 @@ private sealed class TaskStepContextAdapter( public Guid InstanceId => ctx.InstanceId; public Guid ChannelId => ctx.ChannelId; public CancellationToken CancellationToken => ctx.CancellationToken; + public IServiceProvider Services => ctx.Services; public IDictionary Variables => ctx.Variables; public IReadOnlyList EventHandlers => @@ -1512,6 +532,53 @@ public string ResolveExpression(string expression) => public Task AppendLogAsync(string message) => taskService.AppendLogAsync(ctx.InstanceId, message, ct: ctx.CancellationToken); + + public async Task WriteOutputAsync(string? outputJson) + { + using var scope = orchestrator._scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await orchestrator.EmitOutputAsync(ctx.InstanceId, outputJson, db, ctx.Runtime); + } + + public void SetChannelId(Guid channelId) => ctx.ChannelId = channelId; + + public Task WaitIfPausedAsync() => + ctx.Runtime.WaitIfPausedAsync(ctx.CancellationToken); + + public bool EvaluateCondition(string? expression) => + TaskOrchestrator.EvaluateCondition(expression, ctx); + + public void RegisterEventHandler( + string moduleTriggerKey, + string? parameterName, + IReadOnlyList body) + { + ctx.EventHandlers.Add(new RegisteredEventHandler( + moduleTriggerKey, parameterName, body)); + } + + public async Task ExecuteStepsAsync( + IReadOnlyList steps, + CancellationToken cancellationToken) + { + using var scope = orchestrator._scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var ts = scope.ServiceProvider.GetRequiredService(); + var chat = scope.ServiceProvider.GetService(); + var jobs = scope.ServiceProvider.GetService(); + + foreach (var step in steps) + { + cancellationToken.ThrowIfCancellationRequested(); + if (step is not TaskStepDefinition tsd) + throw new InvalidOperationException( + $"Unsupported step invocation type: {step.GetType().FullName}"); + var result = await orchestrator.ExecuteStepAsync(tsd, ctx, db, ts, chat, jobs); + if (result == TaskStepExecutionResult.Return) + return TaskStepResult.Return; + } + return TaskStepResult.Continue; + } } /// @@ -1525,7 +592,6 @@ private sealed class EventHandlerAdapter( TaskOrchestrator orchestrator, TaskService taskService) : ITaskEventHandler { - public TaskTriggerKind TriggerKind => handler.TriggerKind; public string? ModuleTriggerKey => handler.ModuleTriggerKey; public string? ParameterName => handler.ParameterName; @@ -1542,7 +608,8 @@ public async Task ExecuteBodyAsync(CancellationToken ct) foreach (var step in handler.Body) { ct.ThrowIfCancellationRequested(); - var result = await orchestrator.ExecuteStepAsync(step, ctx, db, ts, chat, jobs); + if (step is not TaskStepDefinition tsd) continue; + var result = await orchestrator.ExecuteStepAsync(tsd, ctx, db, ts, chat, jobs); if (result == TaskStepExecutionResult.Return) break; } } diff --git a/SharpClaw.Application.Core/Services/TaskPreflightChecker.cs b/SharpClaw.Application.Core/Services/TaskPreflightChecker.cs index ea186038..8daa5a57 100644 --- a/SharpClaw.Application.Core/Services/TaskPreflightChecker.cs +++ b/SharpClaw.Application.Core/Services/TaskPreflightChecker.cs @@ -5,6 +5,7 @@ using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Application.Services; diff --git a/SharpClaw.Application.Core/Services/TaskRuntimeHost.cs b/SharpClaw.Application.Core/Services/TaskRuntimeHost.cs index 1252b95d..db1adcdb 100644 --- a/SharpClaw.Application.Core/Services/TaskRuntimeHost.cs +++ b/SharpClaw.Application.Core/Services/TaskRuntimeHost.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Enums; using SharpClaw.Infrastructure.Persistence; diff --git a/SharpClaw.Application.Core/Services/TaskService.cs b/SharpClaw.Application.Core/Services/TaskService.cs index cdc64216..4183fd51 100644 --- a/SharpClaw.Application.Core/Services/TaskService.cs +++ b/SharpClaw.Application.Core/Services/TaskService.cs @@ -1,8 +1,9 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Application.Infrastructure.Tasks; +using SharpClaw.Application.Core.Services.Triggers; using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Tasks; @@ -15,7 +16,13 @@ namespace SharpClaw.Application.Services; /// Definitions are parsed on creation so validation errors surface /// immediately rather than at execution time. /// -public sealed class TaskService(SharpClawDbContext db, IPersistenceEntityResolver entities, TaskPreflightChecker preflight, TaskTriggerRegistrar? triggerRegistrar = null) +public sealed class TaskService( + SharpClawDbContext db, + IPersistenceEntityResolver entities, + TaskPreflightChecker preflight, + TaskTriggerRegistrar? triggerRegistrar = null, + TaskTriggerHostService? triggerHostService = null, + ITaskTriggerSourceRegistry? triggerSourceRegistry = null) { /// /// Parse and validate a task definition without persisting it. @@ -93,8 +100,8 @@ public async Task CreateDefinitionAsync( if (bindingsChanged) { await db.SaveChangesAsync(ct); - if (triggerRegistrar.HostService is { } host) - await host.NotifyBindingsChangedAsync(); + if (triggerHostService is not null) + await triggerHostService.NotifyBindingsChangedAsync(); } } @@ -199,8 +206,8 @@ public async Task> ListDefinitionsAsync( if (bindingsChanged) { await db.SaveChangesAsync(ct); - if (triggerRegistrar.HostService is { } host) - await host.NotifyBindingsChangedAsync(); + if (triggerHostService is not null) + await triggerHostService.NotifyBindingsChangedAsync(); } } @@ -219,8 +226,8 @@ public async Task DeleteDefinitionAsync(Guid id, CancellationToken ct = de { await triggerRegistrar.RemoveTriggersAsync(id, ct); await db.SaveChangesAsync(ct); - if (triggerRegistrar.HostService is { } host) - await host.NotifyBindingsChangedAsync(); + if (triggerHostService is not null) + await triggerHostService.NotifyBindingsChangedAsync(); } db.TaskDefinitions.Remove(entity); @@ -501,7 +508,7 @@ private static TaskDiagnosticResponse ToDiagnosticResponse(TaskDiagnostic diagno diagnostic.Line, diagnostic.Column); - private static TaskDefinitionResponse ToDefinitionResponse( + private TaskDefinitionResponse ToDefinitionResponse( TaskDefinitionDB entity, IReadOnlyList parameters, IReadOnlyList requirements, @@ -531,31 +538,19 @@ private static TaskDefinitionResponse ToDefinitionResponse( entity.CustomId); } - private static string? TriggerValueFor(TaskTriggerDefinition t) - { - return t.TriggerKey switch - { - WellKnownTriggerKeys.Cron => t.CronExpression, - WellKnownTriggerKeys.Event => t.EventType, - WellKnownTriggerKeys.FileChanged => t.WatchPath, - WellKnownTriggerKeys.Webhook => t.WebhookRoute, - WellKnownTriggerKeys.HostReachable - or WellKnownTriggerKeys.HostUnreachable => t.HostName, - WellKnownTriggerKeys.TaskCompleted - or WellKnownTriggerKeys.TaskFailed => t.SourceTaskName, - WellKnownTriggerKeys.NetworkChanged => t.NetworkSsid, - WellKnownTriggerKeys.MetricThreshold => t.MetricSource, - // Module-owned: use the filter field the module source understands - _ => t.CustomSourceFilter ?? t.ProcessName ?? t.HotkeyCombo - ?? t.SqlQuery ?? t.ShortcutLabel ?? t.DeviceClass, - }; - } + /// + /// Computes the response-shaped TriggerValue by delegating to the + /// owning via + /// . Mirrors the path used by + /// TaskTriggerRegistrar so binding rows and API responses agree. + /// Returns when no registry is wired (test + /// scenarios) or no source claims the key. + /// + private string? TriggerValueFor(TaskTriggerDefinition t) => + triggerSourceRegistry?.ResolveByKey(t.TriggerKey)?.GetBindingValue(t); - private static string? TriggerFilterFor(TaskTriggerDefinition t) - { - if (t.TriggerKey == WellKnownTriggerKeys.Event) return t.EventFilter; - return t.CustomSourceFilter; - } + private string? TriggerFilterFor(TaskTriggerDefinition t) => + triggerSourceRegistry?.ResolveByKey(t.TriggerKey)?.GetBindingFilter(t); private static TaskInstanceResponse ToInstanceResponse( TaskInstanceDB instance, diff --git a/SharpClaw.Application.Core/Services/TaskToolProvider.cs b/SharpClaw.Application.Core/Services/TaskToolProvider.cs index 50905b35..5d3c32bb 100644 --- a/SharpClaw.Application.Core/Services/TaskToolProvider.cs +++ b/SharpClaw.Application.Core/Services/TaskToolProvider.cs @@ -1,11 +1,12 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using SharpClaw.Application.Core.Clients; -using SharpClaw.Application.Infrastructure.Models.Access; +using SharpClaw.Contracts.Entities.Core.Access; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Providers; using SharpClaw.Contracts.Tasks; using SharpClaw.Infrastructure.Persistence; +using SharpClaw.Contracts.Entities.Core.Tasks; namespace SharpClaw.Application.Services; @@ -20,16 +21,21 @@ namespace SharpClaw.Application.Services; /// hooks only available while a task is executing). /// /// -public sealed class TaskToolProvider(SharpClawDbContext db) +public sealed class TaskToolProvider(SharpClawDbContext db) : ITaskToolCatalog { // Prefix all task tool names so callers can identify them as a family. internal const string ToolPrefix = "task_invoke__"; /// - /// Build tool definitions for all active task definitions. The caller - /// is responsible for checking agent permissions before adding these to - /// a chat request — use - /// with . + /// Build tool definitions for all active task definitions. + /// + /// This provider produces the schema unconditionally; the policy gate + /// (whether tasks should surface as agent tools at all) lives in the + /// chat-processing bridge contributor owned by the agent-orchestration + /// module, not here. Callers in core must invoke + /// + /// rather than calling this method directly. + /// /// public async Task> GetToolDefinitionsAsync( CancellationToken ct = default) @@ -52,7 +58,7 @@ public async Task> GetToolDefinitionsAsync( : null; private static ChatToolDefinition BuildToolDefinition( - Infrastructure.Models.Tasks.TaskDefinitionDB definition) + TaskDefinitionDB definition) { var parameters = DeserializeParameters(definition.ParametersJson); var schema = BuildSchema(parameters); diff --git a/SharpClaw.Application.Core/Services/TaskTriggerRegistrar.cs b/SharpClaw.Application.Core/Services/TaskTriggerRegistrar.cs index 4be9251c..04665814 100644 --- a/SharpClaw.Application.Core/Services/TaskTriggerRegistrar.cs +++ b/SharpClaw.Application.Core/Services/TaskTriggerRegistrar.cs @@ -1,29 +1,26 @@ using SharpClaw.Application.Infrastructure.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Jobs; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Contracts.Tasks; -using SharpClaw.Application.Core.Services.Triggers; namespace SharpClaw.Application.Services; /// /// Translates parsed entries into -/// rows (for Cron) and -/// rows (for all other kinds), keeping -/// both tables in sync whenever a task definition is created, updated, or -/// deleted. +/// rows, delegating per-kind ownership +/// (cron scheduled jobs, OS shortcut files, etc.) to module-owned +/// implementations that opt into +/// or attach a +/// matching . /// public sealed class TaskTriggerRegistrar( SharpClawDbContext db, ILogger logger, - TaskTriggerHostService? hostService = null, - IShortcutLauncherService? shortcutLauncher = null) + ITaskTriggerSourceRegistry? sourceRegistry = null) { - /// The host service that reloads trigger sources, if registered. - public TaskTriggerHostService? HostService => hostService; /// /// Upsert all trigger bindings derived from . /// Stale rows (present in the DB but absent from the new list) are removed. @@ -41,100 +38,62 @@ public async Task SyncTriggersAsync( ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(triggers); - var cronChanged = await SyncCronJobsAsync(entity, triggers, ct); + var ownedChanged = await DispatchOwnedBindingsAsync(entity, triggers, ct); var bindingChanged = await SyncBindingRowsAsync(entity, triggers, ct); - return cronChanged || bindingChanged; + return ownedChanged || bindingChanged; } - /// Remove all trigger bindings (cron jobs + binding rows) for a definition. + /// Remove all trigger bindings for a definition, including any + /// owned-persistence state contributed by module-owned sources. public async Task RemoveTriggersAsync(Guid definitionId, CancellationToken ct = default) { - await RemoveCronJobsAsync(definitionId, ct); await RemoveBindingRowsAsync(definitionId, ct); + await RemoveOwnedBindingsAsync(definitionId, ct); } // ───────────────────────────────────────────────────────────── - // Cron: ScheduledJobDB + // Module-owned persistence dispatch // ───────────────────────────────────────────────────────────── - private async Task SyncCronJobsAsync( + private async Task DispatchOwnedBindingsAsync( TaskDefinitionDB entity, IReadOnlyList triggers, CancellationToken ct) { - var incomingCron = triggers - .Where(t => t.TriggerKey == WellKnownTriggerKeys.Cron && !string.IsNullOrWhiteSpace(t.CronExpression)) - .ToList(); - - var existing = await db.ScheduledTasks - .Where(j => j.TaskDefinitionId == entity.Id && j.CronExpression != null) - .ToListAsync(ct); - - var incomingExpressions = incomingCron - .Select(t => t.CronExpression!) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (sourceRegistry is null) return false; var changed = false; + var grouped = triggers + .Where(t => !string.IsNullOrWhiteSpace(t.TriggerKey)) + .GroupBy(t => sourceRegistry.ResolveByKey(t.TriggerKey)) + .Where(g => g.Key is not null && g.Key.OwnsBindingPersistence); - // Remove stale cron jobs - foreach (var stale in existing.Where(j => !incomingExpressions.Contains(j.CronExpression!))) - { - db.ScheduledTasks.Remove(stale); - changed = true; - logger.LogDebug( - "Removed stale cron job for definition {DefinitionId}, expression '{Expression}'.", - entity.Id, stale.CronExpression); - } - - // Add new cron jobs - var existingExpressions = existing - .Select(j => j.CronExpression!) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var descriptor = new TaskDefinitionDescriptor(entity.Id, entity.Name); - foreach (var t in incomingCron.Where(t => !existingExpressions.Contains(t.CronExpression!))) + foreach (var group in grouped) { - var nextRun = CronEvaluator.GetNextOccurrence( - t.CronExpression!, - DateTimeOffset.UtcNow, - t.CronTimezone); - - if (nextRun is null) - { - logger.LogWarning( - "Cron expression '{Expression}' for definition {DefinitionId} has no future occurrences; skipping.", - t.CronExpression, entity.Id); - continue; - } - - db.ScheduledTasks.Add(new ScheduledJobDB - { - Name = entity.Name, - TaskDefinitionId = entity.Id, - CronExpression = t.CronExpression, - CronTimezone = t.CronTimezone, - NextRunAt = nextRun.Value, - }); - - changed = true; - logger.LogDebug( - "Created cron job for definition {DefinitionId}, expression '{Expression}', next at {Next}.", - entity.Id, t.CronExpression, nextRun.Value); + var source = group.Key!; + var owned = group.ToList(); + var sourceChanged = await source.SyncBindingsAsync(descriptor, owned, ct); + if (sourceChanged) changed = true; } return changed; } - private async Task RemoveCronJobsAsync(Guid definitionId, CancellationToken ct) + private async Task RemoveOwnedBindingsAsync(Guid definitionId, CancellationToken ct) { - var jobs = await db.ScheduledTasks - .Where(j => j.TaskDefinitionId == definitionId && j.CronExpression != null) - .ToListAsync(ct); + if (sourceRegistry is null) return; - db.ScheduledTasks.RemoveRange(jobs); + foreach (var source in sourceRegistry.Sources) + { + if (!source.OwnsBindingPersistence) continue; + await source.RemoveBindingsAsync(definitionId, ct); + } } // ───────────────────────────────────────────────────────────── - // Non-cron: TaskTriggerBindingDB + // Default binding-row CRUD // ───────────────────────────────────────────────────────────── private async Task SyncBindingRowsAsync( @@ -142,8 +101,9 @@ private async Task SyncBindingRowsAsync( IReadOnlyList triggers, CancellationToken ct) { - var nonCron = triggers - .Where(t => t.TriggerKey != WellKnownTriggerKeys.Cron) + // Skip triggers whose owning source manages persistence itself. + var defaultPath = triggers + .Where(t => !IsOwnedBySource(t.TriggerKey)) .ToList(); var existing = await db.TaskTriggerBindings @@ -151,15 +111,17 @@ private async Task SyncBindingRowsAsync( .ToListAsync(ct); // Build a lookup key for each incoming trigger - var incomingKeys = nonCron + var incomingKeys = defaultPath .Select(t => BindingKey(entity.Id, t)) .ToHashSet(); var changed = false; - // Remove stale bindings - foreach (var stale in existing.Where(b => !incomingKeys.Contains(BindingKey(b)))) + // Remove stale bindings (default-path rows only; owned-source state + // is reconciled by the source itself in SyncBindingsAsync). + foreach (var stale in existing.Where(b => !IsOwnedBySource(b.Kind) && !incomingKeys.Contains(BindingKey(b)))) { + await FireRemovedSideEffectAsync(stale, ct); db.TaskTriggerBindings.Remove(stale); changed = true; logger.LogDebug( @@ -171,29 +133,25 @@ private async Task SyncBindingRowsAsync( var existingKeys = existing .ToDictionary(b => BindingKey(b)); - foreach (var t in nonCron) + foreach (var t in defaultPath) { var key = BindingKey(entity.Id, t); if (existingKeys.ContainsKey(key)) continue; // already present var kindColumn = t.TriggerKey ?? string.Empty; - db.TaskTriggerBindings.Add(new TaskTriggerBindingDB + var binding = new TaskTriggerBindingDB { TaskDefinitionId = entity.Id, Kind = kindColumn, TriggerValue = TriggerValueFor(t), Filter = TriggerFilterFor(t), DefinitionJson = System.Text.Json.JsonSerializer.Serialize(t), - }); - + }; + db.TaskTriggerBindings.Add(binding); changed = true; - if (t.TriggerKey == "OsShortcut" && shortcutLauncher is not null) - { - var customId = entity.Name; - await shortcutLauncher.WriteShortcutAsync(t, customId, ct); - } + await FireCreatedSideEffectAsync(entity, t, binding, ct); logger.LogDebug( "Created trigger binding for definition {DefinitionId}, kind {Kind}.", @@ -209,50 +167,74 @@ private async Task RemoveBindingRowsAsync(Guid definitionId, CancellationToken c .Where(b => b.TaskDefinitionId == definitionId) .ToListAsync(ct); - if (shortcutLauncher is not null) - { - foreach (var b in bindings.Where(b => b.Kind == "OsShortcut")) - { - var customId = b.TriggerValue ?? b.TaskDefinitionId.ToString(); - await shortcutLauncher.RemoveShortcutsAsync(customId, ct); - } - } + foreach (var b in bindings) + await FireRemovedSideEffectAsync(b, ct); db.TaskTriggerBindings.RemoveRange(bindings); } + private bool IsOwnedBySource(string? triggerKey) + { + if (string.IsNullOrWhiteSpace(triggerKey)) return false; + var source = sourceRegistry?.ResolveByKey(triggerKey); + return source is { OwnsBindingPersistence: true }; + } + + private async Task FireCreatedSideEffectAsync( + TaskDefinitionDB entity, + TaskTriggerDefinition trigger, + TaskTriggerBindingDB binding, + CancellationToken ct) + { + var sideEffect = sourceRegistry?.ResolveSideEffect(binding.Kind); + if (sideEffect is null) return; + + await sideEffect.OnBindingCreatedAsync( + new TaskDefinitionDescriptor(entity.Id, entity.Name), + trigger, + new TaskTriggerBindingDescriptor( + binding.TaskDefinitionId, + binding.Kind, + binding.TriggerValue, + binding.Filter), + ct); + } + + private async Task FireRemovedSideEffectAsync( + TaskTriggerBindingDB binding, + CancellationToken ct) + { + var sideEffect = sourceRegistry?.ResolveSideEffect(binding.Kind); + if (sideEffect is null) return; + + await sideEffect.OnBindingRemovedAsync( + new TaskTriggerBindingDescriptor( + binding.TaskDefinitionId, + binding.Kind, + binding.TriggerValue, + binding.Filter), + ct); + } + // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── - private static string BindingKey(Guid definitionId, TaskTriggerDefinition t) => + private string BindingKey(Guid definitionId, TaskTriggerDefinition t) => $"{definitionId}|{t.TriggerKey}|{TriggerValueFor(t)}"; private static string BindingKey(TaskTriggerBindingDB b) => $"{b.TaskDefinitionId}|{b.Kind}|{b.TriggerValue}"; - private static string? TriggerValueFor(TaskTriggerDefinition t) - { - return t.TriggerKey switch - { - WellKnownTriggerKeys.Event => t.EventType, - WellKnownTriggerKeys.FileChanged => t.WatchPath, - WellKnownTriggerKeys.Webhook => t.WebhookRoute, - WellKnownTriggerKeys.HostReachable - or WellKnownTriggerKeys.HostUnreachable => t.HostName, - WellKnownTriggerKeys.TaskCompleted - or WellKnownTriggerKeys.TaskFailed => t.SourceTaskName, - WellKnownTriggerKeys.NetworkChanged => t.NetworkSsid, - WellKnownTriggerKeys.MetricThreshold => t.MetricSource, - // Module-owned: use the filter field the module source understands - _ => t.CustomSourceFilter ?? t.ProcessName ?? t.HotkeyCombo - ?? t.SqlQuery ?? t.ShortcutLabel ?? t.DeviceClass, - }; - } + /// + /// Computes the TriggerValue column for a binding by delegating to + /// the owning . Returns + /// when no source is registered for the key (the binding row will then + /// carry a null discriminator). + /// + private string? TriggerValueFor(TaskTriggerDefinition t) => + sourceRegistry?.ResolveByKey(t.TriggerKey)?.GetBindingValue(t); - private static string? TriggerFilterFor(TaskTriggerDefinition t) - { - if (t.TriggerKey == WellKnownTriggerKeys.Event) return t.EventFilter; - return t.CustomSourceFilter; - } + private string? TriggerFilterFor(TaskTriggerDefinition t) => + sourceRegistry?.ResolveByKey(t.TriggerKey)?.GetBindingFilter(t); } diff --git a/SharpClaw.Application.Core/Services/ThreadService.cs b/SharpClaw.Application.Core/Services/ThreadService.cs index e15b0f9b..040b1874 100644 --- a/SharpClaw.Application.Core/Services/ThreadService.cs +++ b/SharpClaw.Application.Core/Services/ThreadService.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.DTOs.Threads; using SharpClaw.Infrastructure.Persistence; diff --git a/SharpClaw.Application.Core/Services/ToolAwarenessSetService.cs b/SharpClaw.Application.Core/Services/ToolAwarenessSetService.cs index 6d97c5fe..5e3a1d90 100644 --- a/SharpClaw.Application.Core/Services/ToolAwarenessSetService.cs +++ b/SharpClaw.Application.Core/Services/ToolAwarenessSetService.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using SharpClaw.Application.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Contracts.DTOs.Tools; using SharpClaw.Infrastructure.Persistence; diff --git a/SharpClaw.Application.Core/Services/Triggers/BuiltInMetricProviders.cs b/SharpClaw.Application.Core/Services/Triggers/BuiltInMetricProviders.cs deleted file mode 100644 index 440c6f9d..00000000 --- a/SharpClaw.Application.Core/Services/Triggers/BuiltInMetricProviders.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using SharpClaw.Contracts.Enums; -using SharpClaw.Contracts.Tasks; -using SharpClaw.Infrastructure.Persistence; - -namespace SharpClaw.Application.Core.Services.Triggers; - -/// -/// Reports the number of pending (Queued) agent jobs (Queue.PendingJobCount). -/// -public sealed class PendingJobCountMetricProvider(IServiceProvider services) : ITaskMetricProvider -{ - public string MetricName => "Queue.PendingJobCount"; - public string Description => "Number of agent jobs currently in the Queued state."; - - public async Task GetValueAsync(CancellationToken ct) - { - using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var count = await db.AgentJobs - .CountAsync(j => j.Status == AgentJobStatus.Queued, ct); - return count; - } -} - -/// -/// Reports the number of pending (Queued) task instances (Queue.PendingTaskCount). -/// -public sealed class PendingTaskCountMetricProvider(IServiceProvider services) : ITaskMetricProvider -{ - public string MetricName => "Queue.PendingTaskCount"; - public string Description => "Number of task instances currently in the Queued state."; - - public async Task GetValueAsync(CancellationToken ct) - { - using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var count = await db.TaskInstances - .CountAsync(i => i.Status == TaskInstanceStatus.Queued, ct); - return count; - } -} - -/// -/// Reports the number of scheduled jobs with a past NextRunAt -/// that have not yet run (Scheduler.PendingJobCount). -/// -public sealed class SchedulerPendingJobCountMetricProvider(IServiceProvider services) : ITaskMetricProvider -{ - public string MetricName => "Scheduler.PendingJobCount"; - public string Description => "Number of scheduled jobs past their NextRunAt time that have not been triggered."; - - public async Task GetValueAsync(CancellationToken ct) - { - using var scope = services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var now = DateTimeOffset.UtcNow; - var count = await db.ScheduledTasks - .CountAsync(j => j.Status == ScheduledTaskStatus.Pending && j.NextRunAt <= now, ct); - return count; - } -} diff --git a/SharpClaw.Application.Core/Services/Triggers/HostQueueMetrics.cs b/SharpClaw.Application.Core/Services/Triggers/HostQueueMetrics.cs new file mode 100644 index 00000000..d18e6682 --- /dev/null +++ b/SharpClaw.Application.Core/Services/Triggers/HostQueueMetrics.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Tasks; +using SharpClaw.Infrastructure.Persistence; + +namespace SharpClaw.Application.Core.Services.Triggers; + +/// +/// Host implementation of . Lives in core because +/// it depends on from Application.Infrastructure. +/// Any consumer (metric providers, health checks, dashboards) can read these +/// counts through the contract without referencing the host's persistence layer. +/// +public sealed class HostQueueMetrics(IServiceProvider services) : IHostQueueMetrics +{ + public async Task GetPendingJobCountAsync(CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.AgentJobs.CountAsync(j => j.Status == AgentJobStatus.Queued, ct); + } + + public async Task GetPendingTaskCountAsync(CancellationToken ct) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.TaskInstances.CountAsync(i => i.Status == TaskInstanceStatus.Queued, ct); + } + + public Task GetSchedulerPendingJobCountAsync(CancellationToken ct) + { + // Scheduler ownership has moved to the AgentOrchestration module; the + // host no longer tracks scheduled jobs. Modules that care can publish + // their own metric provider against their own DbContext. + return Task.FromResult(0d); + } +} diff --git a/SharpClaw.Application.Core/Services/Triggers/IWebhookRouteRegistrar.cs b/SharpClaw.Application.Core/Services/Triggers/IWebhookRouteRegistrar.cs deleted file mode 100644 index 519ad3d1..00000000 --- a/SharpClaw.Application.Core/Services/Triggers/IWebhookRouteRegistrar.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharpClaw.Application.Core.Services.Triggers; - -/// -/// Abstracts the minimal-API route registration surface so that -/// can be tested without a real -/// WebApplication. -/// -public interface IWebhookRouteRegistrar -{ - /// - /// Register a POST route at the given path if it has not already been - /// registered during this application lifetime. - /// - /// Absolute path, e.g. /webhooks/tasks/my-hook. - void EnsureRegistered(string routePath); -} diff --git a/SharpClaw.Application.Core/Services/Triggers/TaskTriggerHostService.cs b/SharpClaw.Application.Core/Services/Triggers/TaskTriggerHostService.cs index b45fc7e2..5b3c6590 100644 --- a/SharpClaw.Application.Core/Services/Triggers/TaskTriggerHostService.cs +++ b/SharpClaw.Application.Core/Services/Triggers/TaskTriggerHostService.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Application.Services; using SharpClaw.Contracts.DTOs.Tasks; using SharpClaw.Contracts.Tasks; diff --git a/SharpClaw.Application.Core/Services/Triggers/TaskTriggerSourceRegistry.cs b/SharpClaw.Application.Core/Services/Triggers/TaskTriggerSourceRegistry.cs new file mode 100644 index 00000000..71c5eb16 --- /dev/null +++ b/SharpClaw.Application.Core/Services/Triggers/TaskTriggerSourceRegistry.cs @@ -0,0 +1,49 @@ +using SharpClaw.Contracts.Tasks; + +namespace SharpClaw.Application.Core.Services.Triggers; + +/// +/// Default implementation. Resolves +/// a trigger source by matching its +/// (case-insensitive) against the requested key. +/// +public sealed class TaskTriggerSourceRegistry( + IEnumerable sources, + IEnumerable? sideEffects = null) + : ITaskTriggerSourceRegistry +{ + public IReadOnlyList Sources { get; } = + sources.ToList().AsReadOnly(); + + public IReadOnlyList SideEffects { get; } = + (sideEffects ?? []).ToList().AsReadOnly(); + + public ITaskTriggerSource? ResolveByKey(string? triggerKey) + { + if (string.IsNullOrWhiteSpace(triggerKey)) return null; + + foreach (var src in Sources) + { + foreach (var key in src.TriggerKeys) + { + if (string.Equals(key, triggerKey, StringComparison.OrdinalIgnoreCase)) + return src; + } + } + + return null; + } + + public ITaskTriggerBindingSideEffect? ResolveSideEffect(string? triggerKey) + { + if (string.IsNullOrWhiteSpace(triggerKey)) return null; + + foreach (var sideEffect in SideEffects) + { + if (string.Equals(sideEffect.TriggerKey, triggerKey, StringComparison.OrdinalIgnoreCase)) + return sideEffect; + } + + return null; + } +} diff --git a/SharpClaw.Application.Core/SharpClaw.Application.Core.csproj b/SharpClaw.Application.Core/SharpClaw.Application.Core.csproj index f7a407da..9efeaad5 100644 --- a/SharpClaw.Application.Core/SharpClaw.Application.Core.csproj +++ b/SharpClaw.Application.Core/SharpClaw.Application.Core.csproj @@ -11,7 +11,6 @@ - @@ -24,10 +23,15 @@ + + + + + diff --git a/SharpClaw.Application.Infrastructure.Tasks/Compilation/TaskScriptCompiler.cs b/SharpClaw.Application.Infrastructure.Tasks/Compilation/TaskScriptCompiler.cs index 77807865..b7fed9ca 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Compilation/TaskScriptCompiler.cs +++ b/SharpClaw.Application.Infrastructure.Tasks/Compilation/TaskScriptCompiler.cs @@ -95,9 +95,6 @@ private static IReadOnlyList NormalizeSteps(IReadOnlyList step with { - LoopKind = step.StepKey == WellKnownTaskStepKeys.Loop - ? step.LoopKind ?? (step.VariableName is not null ? TaskLoopKind.ForEach : TaskLoopKind.While) - : step.LoopKind, Body = step.Body is not null ? NormalizeSteps(step.Body) : step.Body, ElseBody = step.ElseBody is not null ? NormalizeSteps(step.ElseBody) : step.ElseBody, }) diff --git a/SharpClaw.Application.Infrastructure.Tasks/Models/TaskLoopKind.cs b/SharpClaw.Application.Infrastructure.Tasks/Models/TaskLoopKind.cs deleted file mode 100644 index 252aa026..00000000 --- a/SharpClaw.Application.Infrastructure.Tasks/Models/TaskLoopKind.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SharpClaw.Application.Infrastructure.Tasks.Models; - -/// -/// Specific loop shape used by when -/// is . -/// -public enum TaskLoopKind -{ - While, - ForEach, -} diff --git a/SharpClaw.Application.Infrastructure.Tasks/Models/TaskStepDefinition.cs b/SharpClaw.Application.Infrastructure.Tasks/Models/TaskStepDefinition.cs index 75b43133..f36b852e 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Models/TaskStepDefinition.cs +++ b/SharpClaw.Application.Infrastructure.Tasks/Models/TaskStepDefinition.cs @@ -8,12 +8,15 @@ namespace SharpClaw.Application.Infrastructure.Tasks.Models; /// a tree: event handlers, conditionals, and loops contain nested body /// steps. /// -public sealed record TaskStepDefinition +public sealed record TaskStepDefinition : ITaskStepInvocation { /// - /// Stable string key identifying this step's operation (e.g. core.chat). - /// Use constants - /// for core steps. Module steps use a module-namespaced key. + /// Stable wire-format string key identifying this step's operation + /// (e.g. core.chat). Step keys are owned by modules and exposed + /// through module-local constant classes (for example + /// TaskScriptingStepKeys, AgentOrchestrationStepKeys, and + /// HttpStepKeys); the literal values are stable across versions + /// for backward compatibility with serialized task scripts. /// public required string StepKey { get; init; } @@ -61,12 +64,9 @@ public sealed record TaskStepDefinition // ── Event handler ───────────────────────────────────────────── - /// Trigger kind for core.event_handler steps. - public TaskTriggerKind? TriggerKind { get; init; } - /// - /// Module-owned trigger key when is - /// . + /// Module-owned trigger key for core.event_handler steps. + /// Identifies which module trigger the handler is bound to. /// public string? ModuleTriggerKey { get; init; } @@ -86,14 +86,9 @@ public sealed record TaskStepDefinition /// Else branch for core.conditional steps. public IReadOnlyList? ElseBody { get; init; } - /// Specific loop shape for core.loop steps. - public TaskLoopKind? LoopKind { get; init; } - - // ── HTTP ────────────────────────────────────────────────────── + // ── ITaskStepInvocation projection ──────────────────────────── - /// - /// HTTP verb for core.http_request steps - /// ("GET", "POST", "PUT", "DELETE"). - /// - public string? HttpMethod { get; init; } + string? ITaskStepInvocation.RawExpression => Expression; + IReadOnlyList? ITaskStepInvocation.Body => Body; + IReadOnlyList? ITaskStepInvocation.ElseBody => ElseBody; } diff --git a/SharpClaw.Application.Infrastructure.Tasks/Parsing/TaskScriptParser.cs b/SharpClaw.Application.Infrastructure.Tasks/Parsing/TaskScriptParser.cs index e3bc93d2..a025df6c 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Parsing/TaskScriptParser.cs +++ b/SharpClaw.Application.Infrastructure.Tasks/Parsing/TaskScriptParser.cs @@ -29,7 +29,22 @@ public sealed class TaskScriptParser private static readonly Dictionary _moduleStepKeys = []; private static readonly Dictionary _moduleEventTriggers = []; private static readonly HashSet _moduleSingleArgMethods = []; + private static readonly Dictionary _moduleTriggerAttributeHandlers + = new(StringComparer.Ordinal); private static readonly Lock _registryLock = new(); + private static TaskParserPrimitives? _primitives; + + /// + /// Wire-format step keys for statement-shaped primitives, supplied by + /// the registering scripting module. Core defines no step-name + /// constants; the parser refuses to emit statement steps until a + /// module contributes them. + /// + internal static TaskParserPrimitives Primitives => + _primitives ?? throw new InvalidOperationException( + "Task script parser primitives have not been registered. " + + "A module implementing ITaskParserModuleExtension must supply " + + "TaskParserPrimitives via Primitives before parsing."); /// /// Register a module's parser extension. Safe to call multiple times @@ -60,7 +75,44 @@ public static void RegisterModule(ITaskParserModuleExtension extension) _moduleEventTriggers.TryAdd(method, entry); foreach (var method in extension.SingleArgExpressionMethods) _moduleSingleArgMethods.Add(method); + + foreach (var (attrName, handler) in extension.TriggerAttributeHandlers) + { + // Accept both short ("Schedule") and long ("ScheduleAttribute") + // forms for the same handler, matching how the legacy switch + // already pattern-matches attribute names. + RegisterTriggerAttributeHandler(attrName, handler); + if (!attrName.EndsWith("Attribute", StringComparison.Ordinal)) + RegisterTriggerAttributeHandler(attrName + "Attribute", handler); + } + + if (extension.Primitives is { } primitives) + { + if (_primitives is not null && !_primitives.Equals(primitives)) + { + throw new InvalidOperationException( + "Task script parser primitives have already been registered " + + "by another module. Only one module may own the scripting-language " + + "statement step keys."); + } + _primitives = primitives; + } + } + } + + private static void RegisterTriggerAttributeHandler(string attrName, ITaskTriggerAttributeHandler handler) + { + if (_moduleTriggerAttributeHandlers.TryGetValue(attrName, out var existing)) + { + if (!ReferenceEquals(existing, handler)) + { + throw new InvalidOperationException( + $"Task trigger attribute '{attrName}' is already claimed by another " + + "module-registered handler. Trigger attribute ownership is exclusive."); + } + return; } + _moduleTriggerAttributeHandlers[attrName] = handler; } /// @@ -453,8 +505,6 @@ private static IReadOnlyList ExtractTriggerDefinitions( List diagnostics) { var triggers = new List(); - TriggerConcurrency? concurrencyOverride = null; - int concurrencyCount = 0; bool hasWebhook = false; // First pass: detect [OnWebhook] presence for TASK428 check @@ -482,377 +532,21 @@ private static IReadOnlyList ExtractTriggerDefinitions( var attrName = attr.Name.ToString(); var line = GetLine(attr); - switch (attrName) + // Module-owned trigger-attribute handlers take precedence over + // the built-in switch. A handler returning null declines the + // attribute and lets the legacy switch run. + if (_moduleTriggerAttributeHandlers.TryGetValue(attrName, out var moduleHandler)) { - case "Schedule" or "ScheduleAttribute": - { - var cron = ExtractFirstStringArg(attr); - var tz = GetNamedStringArg(attr, "Timezone"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.Cron, - CronExpression = cron, - CronTimezone = tz, - Line = line, - }); - break; - } - - case "OnEvent" or "OnEventAttribute": - { - var eventType = ExtractFirstStringArg(attr); - var filter = GetNamedStringArg(attr, "Filter"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.Event, - EventType = eventType, - EventFilter = filter, - Line = line, - }); - break; - } - - case "OnFileChanged" or "OnFileChangedAttribute": - { - var path = ExtractFirstStringArg(attr); - var pattern = GetNamedStringArg(attr, "Pattern"); - var events = GetNamedEnumArg(attr, "Events") ?? FileWatchEvent.Any; - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.FileChanged, - WatchPath = path, - FilePattern = pattern, - FileEvents = events, - Line = line, - }); - break; - } - - case "OnProcessStarted" or "OnProcessStartedAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "ProcessStarted", - ProcessName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - } - - case "OnProcessStopped" or "OnProcessStoppedAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "ProcessStopped", - ProcessName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - } - - case "OnWebhook" or "OnWebhookAttribute": - { - var route = ExtractFirstStringArg(attr); - var secret = GetNamedStringArg(attr, "Secret"); - var sigHdr = GetNamedStringArg(attr, "SignatureHeader"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.Webhook, - WebhookRoute = route, - WebhookSecretEnvVar = secret, - WebhookSignatureHeader = sigHdr, - Line = line, - }); - break; - } - - case "OnHostReachable" or "OnHostReachableAttribute": - { - var host = ExtractFirstStringArg(attr); - var port = GetNamedIntArg(attr, "Port"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.HostReachable, - HostName = host, - HostPort = port, - Line = line, - }); - break; - } - - case "OnHostUnreachable" or "OnHostUnreachableAttribute": - { - var host = ExtractFirstStringArg(attr); - var port = GetNamedIntArg(attr, "Port"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.HostUnreachable, - HostName = host, - HostPort = port, - Line = line, - }); - break; - } - - case "OnTaskCompleted" or "OnTaskCompletedAttribute": - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.TaskCompleted, - SourceTaskName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - - case "OnTaskFailed" or "OnTaskFailedAttribute": - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.TaskFailed, - SourceTaskName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - - case "OnWindowFocused" or "OnWindowFocusedAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "WindowFocused", - ProcessName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - } - - case "OnWindowBlurred" or "OnWindowBlurredAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "WindowBlurred", - ProcessName = ExtractFirstStringArg(attr), - Line = line, - }); - break; - } - - case "OnHotkey" or "OnHotkeyAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - var combo = ExtractFirstStringArg(attr); - if (!IsHotkeyComboValid(combo)) - { - diagnostics.Add(new TaskDiagnostic( - TaskDiagnosticSeverity.Error, - "TASK429", - $"[OnHotkey] key combination \"{combo}\" could not be parsed. " + - "Expected format: \"Modifier+Key\" (e.g. \"Ctrl+Shift+F10\").", - line)); - } - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "Hotkey", - HotkeyCombo = combo, - Line = line, - }); - break; - } - - case "OnSystemIdle" or "OnSystemIdleAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "SystemIdle", - IdleMinutes = GetNamedIntArg(attr, "Minutes") ?? ExtractFirstIntArg(attr), - Line = line, - }); - break; - } - - case "OnSystemActive" or "OnSystemActiveAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "SystemActive", - Line = line, - }); - break; - } - - case "OnScreenLocked" or "OnScreenLockedAttribute": + var ctx = new RoslynTriggerAttributeContext(attr, attrName, line, diagnostics); + var moduleTrigger = moduleHandler.Handle(ctx); + if (moduleTrigger is not null) { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "ScreenLocked", - Line = line, - }); - break; - } - - case "OnScreenUnlocked" or "OnScreenUnlockedAttribute": - { - EmitPlatformWarningIfNeeded(attrName, line, diagnostics); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "ScreenUnlocked", - Line = line, - }); - break; - } - - case "OnNetworkChanged" or "OnNetworkChangedAttribute": - { - var ssid = GetNamedStringArg(attr, "Ssid"); - var state = GetNamedEnumArg(attr, "State") ?? NetworkState.Any; - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.NetworkChanged, - NetworkSsid = ssid, - NetworkState = state, - Line = line, - }); - break; - } - - case "OnDeviceConnected" or "OnDeviceConnectedAttribute": - { - var devClass = GetNamedStringArg(attr, "Class"); - var devPattern = GetNamedStringArg(attr, "Pattern"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "DeviceConnected", - DeviceClass = devClass, - DeviceNamePattern = devPattern, - Line = line, - }); - break; - } - - case "OnDeviceDisconnected" or "OnDeviceDisconnectedAttribute": - { - var devClass = GetNamedStringArg(attr, "Class"); - var devPattern = GetNamedStringArg(attr, "Pattern"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "DeviceDisconnected", - DeviceClass = devClass, - DeviceNamePattern = devPattern, - Line = line, - }); - break; - } - - case "OnQueryReturnsRows" or "OnQueryReturnsRowsAttribute": - { - var query = ExtractFirstStringArg(attr); - var interval = GetNamedIntArg(attr, "PollInterval"); - if (!IsSelectCountQuery(query)) - { - diagnostics.Add(new TaskDiagnostic( - TaskDiagnosticSeverity.Warning, - "TASK431", - "[OnQueryReturnsRows] query should be a SELECT COUNT(*) expression " + - "to avoid unintended side-effects.", - line)); - } - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "QueryReturnsRows", - SqlQuery = query, - QueryPollIntervalSecs = interval, - Line = line, - }); - break; - } - - case "OnMetricThreshold" or "OnMetricThresholdAttribute": - { - var source = ExtractFirstStringArg(attr); - var threshold = GetNamedDoubleArg(attr, "Threshold"); - var direction = GetNamedEnumArg(attr, "Direction") ?? ThresholdDirection.Either; - var interval = GetNamedIntArg(attr, "PollInterval"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = WellKnownTriggerKeys.MetricThreshold, - MetricSource = source, - MetricThreshold = threshold, - MetricDirection = direction, - MetricPollIntervalSecs = interval, - Line = line, - }); - break; - } - - case "OnStartup" or "OnStartupAttribute": - triggers.Add(new TaskTriggerDefinition { TriggerKey = WellKnownTriggerKeys.Startup, Line = line }); - break; - - case "OnShutdown" or "OnShutdownAttribute": - triggers.Add(new TaskTriggerDefinition { TriggerKey = WellKnownTriggerKeys.Shutdown, Line = line }); - break; - - case "OsShortcut" or "OsShortcutAttribute": - { - var label = ExtractFirstStringArg(attr); - var icon = GetNamedStringArg(attr, "Icon"); - var category = GetNamedStringArg(attr, "Category"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = "OsShortcut", - ShortcutLabel = label, - ShortcutIcon = icon, - ShortcutCategory = category, - Line = line, - }); - break; - } - - case "OnTrigger" or "OnTriggerAttribute": - { - var sourceName = ExtractFirstStringArg(attr); - var filter = GetNamedStringArg(attr, "Filter"); - triggers.Add(new TaskTriggerDefinition - { - TriggerKey = sourceName, - CustomSourceFilter = filter, - Line = line, - }); - break; - } - - case "ConcurrencyPolicy" or "ConcurrencyPolicyAttribute": - { - concurrencyCount++; - if (concurrencyCount > 1) - { - diagnostics.Add(new TaskDiagnostic( - TaskDiagnosticSeverity.Error, - "TASK421", - "More than one [ConcurrencyPolicy] attribute found on the same class.", - line)); - } - else - { - concurrencyOverride = GetNamedEnumArg(attr, "Policy") - ?? ExtractFirstEnumArg(attr); - } - break; + triggers.Add(moduleTrigger with { Line = line }); + continue; } } } - // Apply concurrency override to all collected triggers - if (concurrencyOverride.HasValue) - { - for (var i = 0; i < triggers.Count; i++) - triggers[i] = triggers[i] with { Concurrency = concurrencyOverride.Value }; - } - return triggers; } @@ -978,32 +672,16 @@ private static bool IsSelectCountQuery(string? query) return ParseEnumExpression(arg.Expression); } - private static T? ExtractFirstEnumArg(AttributeSyntax attr) where T : struct, Enum - { - var arg = attr.ArgumentList?.Arguments.FirstOrDefault(); - if (arg is null) - return null; - return ParseEnumExpression(arg.Expression); - } - - private static int? ExtractFirstIntArg(AttributeSyntax attr) - { - var arg = attr.ArgumentList?.Arguments.FirstOrDefault(); - if (arg?.Expression is LiteralExpressionSyntax lit && lit.Token.Value is int i) - return i; - return null; - } - private static T? ParseEnumExpression(ExpressionSyntax expr) where T : struct, Enum { - // Handle string literal: [ConcurrencyPolicy("Queue")] + // Handle string literal: e.g. ["Queue"] if (expr is LiteralExpressionSyntax lit && lit.Token.Value is string s) { if (Enum.TryParse(s, ignoreCase: true, out var v)) return v; return null; } - // Handle member access: TriggerConcurrency.Queue or just Queue + // Handle member access: EnumType.Member or just Member var text = expr.ToString(); var memberName = text.Contains('.') ? text[(text.LastIndexOf('.') + 1)..] : text; @@ -1313,7 +991,7 @@ private static bool IsCollectionType(string typeName, out string? elementType) { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.DeclareVariable, + StepKey = Primitives.DeclareVariable, Line = line, Column = column, VariableName = variableName, @@ -1342,7 +1020,7 @@ private static bool IsCollectionType(string typeName, out string? elementType) // Plain declaration: var x = new Foo(), var x = expr, var x = a ?? await b() return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.DeclareVariable, + StepKey = Primitives.DeclareVariable, Line = line, Column = column, VariableName = variableName, @@ -1391,7 +1069,7 @@ private static bool IsCollectionType(string typeName, out string? elementType) { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Log, + StepKey = Primitives.Log, Line = line, Column = column, Expression = ExtractFirstArgText(invocation), @@ -1410,7 +1088,7 @@ private static bool IsCollectionType(string typeName, out string? elementType) { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Assign, + StepKey = Primitives.Assign, Line = line, Column = column, VariableName = assignment.Left.ToString(), @@ -1421,7 +1099,7 @@ private static bool IsCollectionType(string typeName, out string? elementType) // Fallback: arbitrary expression (e.g. list.AddRange(...)) return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Evaluate, + StepKey = Primitives.Evaluate, Line = line, Column = column, Expression = expression.ToString() @@ -1441,7 +1119,7 @@ private static TaskStepDefinition ParseIfStatement( return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Conditional, + StepKey = Primitives.Conditional, Line = GetLine(ifStmt), Column = GetColumn(ifStmt), Expression = ifStmt.Condition.ToString(), @@ -1456,10 +1134,9 @@ private static TaskStepDefinition ParseWhileStatement( { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Loop, + StepKey = Primitives.Loop, Line = GetLine(whileStmt), Column = GetColumn(whileStmt), - LoopKind = TaskLoopKind.While, Expression = whileStmt.Condition.ToString(), Body = ParseBlock(whileStmt.Statement, diagnostics) }; @@ -1471,10 +1148,9 @@ private static TaskStepDefinition ParseForEachStatement( { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Loop, + StepKey = Primitives.Loop, Line = GetLine(forEachStmt), Column = GetColumn(forEachStmt), - LoopKind = TaskLoopKind.ForEach, VariableName = forEachStmt.Identifier.Text, TypeName = forEachStmt.Type.IsVar ? null : forEachStmt.Type.ToString(), Expression = forEachStmt.Expression.ToString(), @@ -1486,7 +1162,7 @@ private static TaskStepDefinition ParseReturnStatement(ReturnStatementSyntax ret { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Return, + StepKey = Primitives.Return, Line = GetLine(ret), Column = GetColumn(ret) }; @@ -1522,7 +1198,7 @@ private static TaskStepDefinition ParseReturnStatement(ReturnStatementSyntax ret { return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Delay, + StepKey = Primitives.Delay, Line = line, Column = column, Expression = ExtractFirstArgText(invocation), @@ -1549,17 +1225,25 @@ private static TaskStepDefinition BuildStepFromDescriptor( string? expression = null; if (descriptor.ExpressionArgIndex > 0 && args.Count > descriptor.ExpressionArgIndex) expression = args[descriptor.ExpressionArgIndex].Expression.ToString(); - else if (descriptor.FirstArgIsExpression || descriptor.HttpMethod is not null) + else if (descriptor.FirstArgIsExpression) expression = ExtractFirstArgText(invocation); + var arguments = ExtractArgumentTexts(invocation); + if (descriptor.PrefixArgument is { } prefix) + { + var prefixed = new List(arguments?.Count + 1 ?? 1) { prefix }; + if (arguments is not null) + prefixed.AddRange(arguments); + arguments = prefixed; + } + var step = new TaskStepDefinition { StepKey = descriptor.StepKey, Line = line, Column = column, - HttpMethod = descriptor.HttpMethod, Expression = expression, - Arguments = ExtractArgumentTexts(invocation) + Arguments = arguments }; if (descriptor.CapturesGenericType) @@ -1581,8 +1265,8 @@ private static TaskStepDefinition BuildStepFromDescriptor( List diagnostics) { var methodName = GetMethodName(invocation); - var triggerKind = ResolveEventTrigger(methodName); - if (triggerKind is null) + var moduleTriggerKey = ResolveModuleTriggerKey(methodName); + if (moduleTriggerKey is null) return null; // Non-lambda arguments (e.g. the job variable reference) @@ -1623,11 +1307,10 @@ private static TaskStepDefinition BuildStepFromDescriptor( return new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.EventHandler, + StepKey = Primitives.EventHandler, Line = line, Column = column, - TriggerKind = triggerKind.Value, - ModuleTriggerKey = ResolveModuleTriggerKey(methodName), + ModuleTriggerKey = moduleTriggerKey, HandlerParameter = handlerParam, Arguments = nonLambdaArgs, Body = body ?? [] @@ -1646,7 +1329,7 @@ private static IReadOnlyList ParseLambdaBody( [ new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Evaluate, + StepKey = Primitives.Evaluate, Line = GetLine(body), Column = GetColumn(body), Expression = body.ToString() @@ -1752,13 +1435,6 @@ private static string ExtractExpressionText(ExpressionSyntax expression) // ── Lookup tables ───────────────────────────────────────────── - private static TaskTriggerKind? ResolveEventTrigger(string? methodName) - { - if (methodName is null) return null; - if (methodName == "OnTimer") return TaskTriggerKind.Timer; - return _moduleEventTriggers.ContainsKey(methodName) ? TaskTriggerKind.ModuleEvent : null; - } - internal static string? ResolveModuleTriggerKey(string? methodName) => methodName is not null && _moduleEventTriggers.TryGetValue(methodName, out var entry) ? entry.TriggerKey : null; @@ -1776,4 +1452,82 @@ private static int GetColumn(SyntaxNode node) var lineSpan = node.GetLocation().GetLineSpan(); return lineSpan.StartLinePosition.Character; } + + // ── Module trigger-attribute context ────────────────────────── + + /// + /// Roslyn-backed adapter handed to module-registered + /// s. Reuses the parser's + /// existing argument-extraction helpers so handler behaviour matches the + /// legacy switch byte-for-byte. + /// + private sealed class RoslynTriggerAttributeContext : TaskTriggerAttributeContext + { + private readonly AttributeSyntax _attr; + private readonly List _diagnostics; + private readonly int _line; + private readonly string _attrName; + + public RoslynTriggerAttributeContext( + AttributeSyntax attr, + string attrName, + int line, + List diagnostics) + { + _attr = attr; + _attrName = attrName.EndsWith("Attribute", StringComparison.Ordinal) + ? attrName[..^"Attribute".Length] + : attrName; + _line = line; + _diagnostics = diagnostics; + } + + public override string AttributeName => _attrName; + public override int Line => _line; + public override int ArgumentCount => _attr.ArgumentList?.Arguments.Count ?? 0; + + public override string? GetStringArg(int index) + { + var args = _attr.ArgumentList?.Arguments; + if (args is null || index < 0 || index >= args.Value.Count) return null; + if (args.Value[index].Expression is LiteralExpressionSyntax lit && lit.Token.Value is string s) + return s; + return null; + } + + public override int? GetIntArg(int index) + { + var args = _attr.ArgumentList?.Arguments; + if (args is null || index < 0 || index >= args.Value.Count) return null; + if (args.Value[index].Expression is LiteralExpressionSyntax lit && lit.Token.Value is int i) + return i; + return null; + } + + public override string? GetNamedStringArg(string name) => TaskScriptParser.GetNamedStringArg(_attr, name); + public override int? GetNamedIntArg(string name) => TaskScriptParser.GetNamedIntArg(_attr, name); + public override double? GetNamedDoubleArg(string name) => TaskScriptParser.GetNamedDoubleArg(_attr, name); + public override T? GetNamedEnumArg(string name) where T : struct => TaskScriptParser.GetNamedEnumArg(_attr, name); + + public override string? GetRawArgText(int index) + { + var args = _attr.ArgumentList?.Arguments; + if (args is null || index < 0 || index >= args.Value.Count) return null; + return args.Value[index].Expression.ToString(); + } + + public override void Report( + TaskTriggerAttributeDiagnosticSeverity severity, + string code, + string message) + { + var mapped = severity switch + { + TaskTriggerAttributeDiagnosticSeverity.Error => TaskDiagnosticSeverity.Error, + TaskTriggerAttributeDiagnosticSeverity.Warning => TaskDiagnosticSeverity.Warning, + _ => TaskDiagnosticSeverity.Info, + }; + _diagnostics.Add(new TaskDiagnostic(mapped, code, message, _line)); + } + } } diff --git a/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepRegistry.cs b/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepRegistry.cs index 12e48c32..f4232143 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepRegistry.cs +++ b/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepRegistry.cs @@ -3,10 +3,9 @@ namespace SharpClaw.Application.Infrastructure.Tasks.Registry; /// -/// Single authoritative registry for all task step descriptors, both core -/// and module-owned. The registry is initialized once with core descriptors -/// before any parsing occurs. Modules add their own descriptors during startup -/// via . +/// Single authoritative registry for all task step descriptors. All +/// descriptors are module-owned: the registry starts empty and modules +/// populate it during startup via . /// public sealed class TaskStepRegistry { @@ -16,8 +15,21 @@ public sealed class TaskStepRegistry new(StringComparer.Ordinal); private readonly Lock _lock = new(); - /// Shared singleton initialized with all core descriptors. - public static readonly TaskStepRegistry Default = CreateWithCoreDescriptors(); + /// Shared singleton; populated by modules during startup. + public static readonly TaskStepRegistry Default = new(); + + /// + /// Clear all registered descriptors. Intended for test fixtures that + /// need to seed a deterministic descriptor set; not for production use. + /// + public void Reset() + { + lock (_lock) + { + _byMethod.Clear(); + _byKey.Clear(); + } + } /// /// Register a step descriptor. Duplicate method names or step keys from @@ -90,256 +102,4 @@ public bool IsRegisteredMethod(string methodName) lock (_lock) return _byMethod.ContainsKey(methodName); } - - private static TaskStepRegistry CreateWithCoreDescriptors() - { - var registry = new TaskStepRegistry(); - foreach (var d in CoreDescriptors()) - registry.Register(d); - return registry; - } - - private static IEnumerable CoreDescriptors() - { - const string core = TaskStepDescriptor.CoreOwner; - - // ── Agent interaction ────────────────────────────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "Chat", - StepKey = WellKnownTaskStepKeys.Chat, - OwnerId = core, - ExpressionArgIndex = 1, // second arg is the message - }; - yield return new TaskStepDescriptor - { - MethodName = "ChatStream", - StepKey = WellKnownTaskStepKeys.ChatStream, - OwnerId = core, - ExpressionArgIndex = 1, - }; - - // ── Output ──────────────────────────────────────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "Emit", - StepKey = WellKnownTaskStepKeys.Emit, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "ParseResponse", - StepKey = WellKnownTaskStepKeys.ParseResponse, - OwnerId = core, - CapturesGenericType = true, - }; - - // ── Control flow ────────────────────────────────────────────────────── - - // Note: DeclareVariable, Assign, Conditional, Loop, Return, EventHandler, - // and Evaluate are not method calls; they are registered by key only. - - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.DeclareVariable, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.Assign, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.EventHandler, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.Conditional, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.Loop, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.Return, - OwnerId = core, - }; - yield return new TaskStepDescriptor - { - StepKey = WellKnownTaskStepKeys.Evaluate, - OwnerId = core, - }; - - // Task.Delay is syntactically special (member access); registered by key only. - yield return new TaskStepDescriptor - { - MethodName = "Delay", - StepKey = WellKnownTaskStepKeys.Delay, - OwnerId = core, - FirstArgIsExpression = true, - }; - - yield return new TaskStepDescriptor - { - MethodName = "WaitUntilStopped", - StepKey = WellKnownTaskStepKeys.WaitUntilStopped, - OwnerId = core, - FirstArgIsExpression = true, - }; - - // ── Logging ─────────────────────────────────────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "Log", - StepKey = WellKnownTaskStepKeys.Log, - OwnerId = core, - FirstArgIsExpression = true, - }; - - // ── HTTP ────────────────────────────────────────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "HttpGet", - StepKey = WellKnownTaskStepKeys.HttpRequest, - OwnerId = core, - HttpMethod = "GET", - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "HttpPost", - StepKey = WellKnownTaskStepKeys.HttpRequest, - OwnerId = core, - HttpMethod = "POST", - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "HttpPut", - StepKey = WellKnownTaskStepKeys.HttpRequest, - OwnerId = core, - HttpMethod = "PUT", - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "HttpDelete", - StepKey = WellKnownTaskStepKeys.HttpRequest, - OwnerId = core, - HttpMethod = "DELETE", - FirstArgIsExpression = true, - }; - - // ── Entity lookup / creation ────────────────────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "FindModel", - StepKey = WellKnownTaskStepKeys.FindModel, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "FindProvider", - StepKey = WellKnownTaskStepKeys.FindProvider, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "FindAgent", - StepKey = WellKnownTaskStepKeys.FindAgent, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "CreateAgent", - StepKey = WellKnownTaskStepKeys.CreateAgent, - OwnerId = core, - // first arg = name (Expression), second arg = modelId (Arguments) - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "CreateThread", - StepKey = WellKnownTaskStepKeys.CreateThread, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "ChatToThread", - StepKey = WellKnownTaskStepKeys.ChatToThread, - OwnerId = core, - ExpressionArgIndex = 1, // second arg is the message - }; - - // ── Role / permission / channel provisioning ────────────────────────── - - yield return new TaskStepDescriptor - { - MethodName = "CreateRole", - StepKey = WellKnownTaskStepKeys.CreateRole, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "FindRole", - StepKey = WellKnownTaskStepKeys.FindRole, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "SetRolePermissions", - StepKey = WellKnownTaskStepKeys.SetRolePermissions, - OwnerId = core, - // first arg = roleId as Expression - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "AssignRole", - StepKey = WellKnownTaskStepKeys.AssignRole, - OwnerId = core, - // first arg = agentId as Expression - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "CreateChannel", - StepKey = WellKnownTaskStepKeys.CreateChannel, - OwnerId = core, - // first arg = title as Expression - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "FindChannel", - StepKey = WellKnownTaskStepKeys.FindChannel, - OwnerId = core, - FirstArgIsExpression = true, - }; - yield return new TaskStepDescriptor - { - MethodName = "AddAllowedAgent", - StepKey = WellKnownTaskStepKeys.AddAllowedAgent, - OwnerId = core, - // first arg = agentId (Expression), second arg = channelId (Arguments[1]) - FirstArgIsExpression = true, - }; - } } diff --git a/SharpClaw.Application.Infrastructure.Tasks/Validation/TaskScriptValidator.cs b/SharpClaw.Application.Infrastructure.Tasks/Validation/TaskScriptValidator.cs index f7d106c8..5fd86ae8 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Validation/TaskScriptValidator.cs +++ b/SharpClaw.Application.Infrastructure.Tasks/Validation/TaskScriptValidator.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using SharpClaw.Application.Infrastructure.Tasks.Models; +using SharpClaw.Application.Infrastructure.Tasks.Parsing; using SharpClaw.Contracts.Tasks; namespace SharpClaw.Application.Infrastructure.Tasks.Validation; @@ -117,7 +118,7 @@ private static void ValidateStep( List diagnostics) { // Track declared variables - if (step.StepKey == WellKnownTaskStepKeys.DeclareVariable && step.VariableName is not null) + if (step.StepKey == TaskScriptParser.Primitives.DeclareVariable && step.VariableName is not null) { if (context.DeclaredVariables.Contains(step.VariableName)) { @@ -151,37 +152,20 @@ private static void ValidateStep( context.DeclaredVariables.Add(step.ResultVariable); } - if (step.StepKey == WellKnownTaskStepKeys.Loop) + if (step.StepKey == TaskScriptParser.Primitives.Loop && step.VariableName is not null) { - var loopKind = step.LoopKind ?? (step.VariableName is not null - ? TaskLoopKind.ForEach - : TaskLoopKind.While); - - if (loopKind == TaskLoopKind.ForEach) + if (string.IsNullOrWhiteSpace(step.Expression)) { - if (string.IsNullOrWhiteSpace(step.VariableName)) - { - diagnostics.Add(new TaskDiagnostic( - TaskDiagnosticSeverity.Error, - "TASK106", - "Foreach loops must declare an iteration variable.", - step.Line, - step.Column)); - } - - if (string.IsNullOrWhiteSpace(step.Expression)) - { - diagnostics.Add(new TaskDiagnostic( - TaskDiagnosticSeverity.Error, - "TASK107", - "Foreach loops must declare a source expression.", - step.Line, - step.Column)); - } + diagnostics.Add(new TaskDiagnostic( + TaskDiagnosticSeverity.Error, + "TASK107", + "Foreach loops must declare a source expression.", + step.Line, + step.Column)); } } - if (step.StepKey == WellKnownTaskStepKeys.ParseResponse && + if (step.StepKey == TaskScriptParser.Primitives.ParseResponse && !string.IsNullOrWhiteSpace(step.TypeName) && !IsValidType(step.TypeName, context.KnownTypes)) { diff --git a/SharpClaw.Application.Infrastructure/InfrastructureServiceExtensions.cs b/SharpClaw.Application.Infrastructure/InfrastructureServiceExtensions.cs index dd8dfabf..a49b7311 100644 --- a/SharpClaw.Application.Infrastructure/InfrastructureServiceExtensions.cs +++ b/SharpClaw.Application.Infrastructure/InfrastructureServiceExtensions.cs @@ -126,6 +126,9 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(); + services.AddScoped( + sp => sp.GetRequiredService()); + return services; } diff --git a/SharpClaw.Application.Infrastructure/Models/Jobs/ScheduledJobDB.cs b/SharpClaw.Application.Infrastructure/Models/Jobs/ScheduledJobDB.cs deleted file mode 100644 index 93a5d435..00000000 --- a/SharpClaw.Application.Infrastructure/Models/Jobs/ScheduledJobDB.cs +++ /dev/null @@ -1,70 +0,0 @@ -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Tasks; -using SharpClaw.Contracts.Entities; -using SharpClaw.Contracts.Enums; - -namespace SharpClaw.Application.Infrastructure.Models.Jobs; - -public class ScheduledJobDB : BaseEntity -{ - public required string Name { get; set; } - public required DateTimeOffset NextRunAt { get; set; } - public TimeSpan? RepeatInterval { get; set; } - public int MaxRetries { get; set; } = 3; - public int RetryCount { get; set; } - public ScheduledTaskStatus Status { get; set; } = ScheduledTaskStatus.Pending; - public DateTimeOffset? LastRunAt { get; set; } - public string? LastError { get; set; } - - // ── Task definition binding ───────────────────────────────────── - - /// - /// The task definition to launch when this scheduled job fires. - /// When null the job is a legacy stub with no task dispatch. - /// - public Guid? TaskDefinitionId { get; set; } - public TaskDefinitionDB? TaskDefinition { get; set; } - - /// - /// JSON-serialised Dictionary<string, string> of parameter - /// values forwarded to each created instance. Null means no parameters. - /// - public string? ParameterValuesJson { get; set; } - - /// - /// Optional agent ID recorded as the caller on created instances, - /// used for audit and permission attribution. - /// - public Guid? CallerAgentId { get; set; } - - // ── Context / permissions ─────────────────────────────────────── - - /// - /// Optional context this task belongs to. When set, the context's - /// permission set acts as a default for this task. - /// - public Guid? AgentContextId { get; set; } - public ChannelContextDB? AgentContext { get; set; } - - /// Optional per-task permission set override. - public Guid? PermissionSetId { get; set; } - public PermissionSetDB? PermissionSet { get; set; } - - // ── Cron scheduling ──────────────────────────────────────────── - - /// - /// Standard or second-resolution cron expression that controls when - /// this job fires. Mutually exclusive with . - /// - public string? CronExpression { get; set; } - - /// - /// IANA or Windows timezone identifier used when evaluating - /// . Defaults to UTC when null. - /// - public string? CronTimezone { get; set; } - - /// Behaviour when a scheduled firing is detected to have been missed. - public MissedFirePolicy MissedFirePolicy { get; set; } = MissedFirePolicy.FireOnceAndRecompute; -} diff --git a/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFileOptions.cs b/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFileOptions.cs index 8935b8eb..ed666463 100644 --- a/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFileOptions.cs +++ b/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFileOptions.cs @@ -1,6 +1,6 @@ -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Messages; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Jobs; +using SharpClaw.Contracts.Entities.Core.Messages; +using SharpClaw.Contracts.Entities.Core.Tasks; namespace SharpClaw.Infrastructure.Persistence.JSON; diff --git a/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFilePersistenceService.cs b/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFilePersistenceService.cs index 9cb4acd2..3fb5726d 100644 --- a/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFilePersistenceService.cs +++ b/SharpClaw.Application.Infrastructure/Persistence/JSON/JsonFilePersistenceService.cs @@ -8,7 +8,8 @@ using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Persistence; using SharpClaw.Contracts.Providers; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Providers.Common; +using SharpClaw.Contracts.Entities.Core; namespace SharpClaw.Infrastructure.Persistence.JSON; diff --git a/SharpClaw.Application.Infrastructure/Persistence/SharpClawDbContext.cs b/SharpClaw.Application.Infrastructure/Persistence/SharpClawDbContext.cs index b07d0491..f368c0d4 100644 --- a/SharpClaw.Application.Infrastructure/Persistence/SharpClawDbContext.cs +++ b/SharpClaw.Application.Infrastructure/Persistence/SharpClawDbContext.cs @@ -2,26 +2,35 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Extensions.DependencyInjection; -using SharpClaw.Application.Infrastructure.Models; -using SharpClaw.Application.Infrastructure.Models.Access; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Context; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Messages; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Jobs; +using SharpClaw.Contracts.Entities.Core.Messages; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Contracts; using SharpClaw.Contracts.Entities; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using SharpClaw.Contracts.Enums; +using SharpClaw.Contracts.Persistence; using SharpClaw.Infrastructure.Persistence.JSON; -using SharpClaw.Infrastructure.Models; namespace SharpClaw.Infrastructure.Persistence; public class SharpClawDbContext( DbContextOptions options, - IServiceProvider? serviceProvider = null) : DbContext(options) + IServiceProvider? serviceProvider = null) : DbContext(options), ISharpClawDataContext { + IQueryable ISharpClawDataContext.Agents => Agents; + IQueryable ISharpClawDataContext.Channels => Channels; + IQueryable ISharpClawDataContext.AgentContexts => AgentContexts; + IQueryable ISharpClawDataContext.ChatThreads => ChatThreads; + IQueryable ISharpClawDataContext.ChatMessages => ChatMessages; + IQueryable ISharpClawDataContext.PermissionSets => PermissionSets; + IQueryable ISharpClawDataContext.GlobalFlags => GlobalFlags; + IQueryable ISharpClawDataContext.Roles => Roles; + /// /// When true, skips the JSON /// flush. Set by @@ -40,7 +49,6 @@ public class SharpClawDbContext( public DbSet Channels => Set(); public DbSet ChatThreads => Set(); public DbSet ChatMessages => Set(); - public DbSet ScheduledTasks => Set(); public DbSet ClearanceUserWhitelistEntries => Set(); public DbSet ClearanceAgentWhitelistEntries => Set(); @@ -169,10 +177,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(conv => conv.AgentContext!) .HasForeignKey(conv => conv.AgentContextId) .OnDelete(DeleteBehavior.SetNull); - e.HasMany(c => c.Tasks) - .WithOne(t => t.AgentContext!) - .HasForeignKey(t => t.AgentContextId) - .OnDelete(DeleteBehavior.SetNull); e.HasOne(c => c.PermissionSet) .WithMany() .HasForeignKey(c => c.PermissionSetId) @@ -223,20 +227,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); - // ── Scheduled Tasks ─────────────────────────────────────── - modelBuilder.Entity(e => - { - e.HasOne(t => t.PermissionSet) - .WithMany() - .HasForeignKey(t => t.PermissionSetId) - .OnDelete(DeleteBehavior.SetNull); - - e.HasOne(t => t.TaskDefinition) - .WithMany() - .HasForeignKey(t => t.TaskDefinitionId) - .OnDelete(DeleteBehavior.SetNull); - }); - // ── PermissionSets ──────────────────────────────────────── modelBuilder.Entity(e => { diff --git a/SharpClaw.Application.Infrastructure/SharpClaw.Application.Infrastructure.csproj b/SharpClaw.Application.Infrastructure/SharpClaw.Application.Infrastructure.csproj index df0513ef..ce6f0d61 100644 --- a/SharpClaw.Application.Infrastructure/SharpClaw.Application.Infrastructure.csproj +++ b/SharpClaw.Application.Infrastructure/SharpClaw.Application.Infrastructure.csproj @@ -31,6 +31,7 @@ + diff --git a/SharpClaw.Contracts/Chat/IChatProcessingBridge.cs b/SharpClaw.Contracts/Chat/IChatProcessingBridge.cs new file mode 100644 index 00000000..4a292655 --- /dev/null +++ b/SharpClaw.Contracts/Chat/IChatProcessingBridge.cs @@ -0,0 +1,37 @@ +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Providers; + +namespace SharpClaw.Contracts.Chat; + +/// +/// Bridge surface used by the host's chat pipeline (ChatService, +/// HeaderTagProcessor) to delegate module-owned decisions back into +/// modules without taking a direct dependency on Core or Infrastructure +/// types. +/// +/// Implemented in SharpClaw.Application.Core as a host aggregator +/// over all registered s; modules +/// register a contributor to participate in the chat processing pipeline. +/// Each method maps 1:1 to a module-specific concern previously hard-coded +/// inside core call sites (CanInvokeTasksAsTool, CanReadCrossThreadHistory). +/// +public interface IChatProcessingBridge +{ + /// + /// Returns extra entries contributed by + /// modules for the current chat turn (e.g. agent-orchestration exposes + /// active task definitions when the agent has CanInvokeTasksAsTool). + /// Aggregates results from every registered contributor. + /// + Task> GetExtraToolsAsync( + Guid agentId, CancellationToken ct = default); + + /// + /// Returns threads accessible to the agent from channels other than + /// , applying each contributor's + /// own cross-thread visibility policy. Aggregates and de-duplicates + /// results from every registered contributor. + /// + Task> GetAccessibleThreadsAsync( + Guid agentId, Guid currentChannelId, CancellationToken ct = default); +} diff --git a/SharpClaw.Contracts/Chat/IChatProcessingContributor.cs b/SharpClaw.Contracts/Chat/IChatProcessingContributor.cs new file mode 100644 index 00000000..43142a53 --- /dev/null +++ b/SharpClaw.Contracts/Chat/IChatProcessingContributor.cs @@ -0,0 +1,36 @@ +using SharpClaw.Contracts.Modules; +using SharpClaw.Contracts.Providers; + +namespace SharpClaw.Contracts.Chat; + +/// +/// Module-side hook for participating in core chat processing. A module +/// (or a host adapter on its behalf) implements this to inject extra tools +/// or extra accessible threads into a chat turn without core having to +/// know about the module's permissions or wire-level flag keys. +/// +/// All methods are optional; the default implementations return empty +/// collections so contributors only override what they actually contribute. +/// Registered as scoped services; the host aggregator +/// () fans out to every registration. +/// +public interface IChatProcessingContributor +{ + /// + /// Contribute extra entries for this + /// chat turn. Implementations are responsible for evaluating their own + /// permissions before returning anything. + /// + Task> GetExtraToolsAsync( + Guid agentId, CancellationToken ct = default) + => Task.FromResult>([]); + + /// + /// Contribute threads from other channels that the agent should be + /// able to read. Implementations apply their own policy (clearance, + /// channel opt-in, permission flags) before returning. + /// + Task> GetAccessibleThreadsAsync( + Guid agentId, Guid currentChannelId, CancellationToken ct = default) + => Task.FromResult>([]); +} diff --git a/SharpClaw.Contracts/DTOs/Providers/ProviderDtos.cs b/SharpClaw.Contracts/DTOs/Providers/ProviderDtos.cs index fa20c13e..488268d9 100644 --- a/SharpClaw.Contracts/DTOs/Providers/ProviderDtos.cs +++ b/SharpClaw.Contracts/DTOs/Providers/ProviderDtos.cs @@ -2,7 +2,7 @@ namespace SharpClaw.Contracts.DTOs.Providers; -/// Required for providers. Ignored for built-in types. +/// Required for custom providers. Ignored for built-in types. public sealed record CreateProviderRequest(string Name, string ProviderKey, string? ApiEndpoint = null, string? ApiKey = null); public sealed record UpdateProviderRequest(string? Name = null, string? ApiEndpoint = null); public sealed record SetApiKeyRequest(string ApiKey); diff --git a/SharpClaw.Contracts/Entities/Core/Access/GlobalFlagDB.cs b/SharpClaw.Contracts/Entities/Core/Access/GlobalFlagDB.cs index 737ac81b..8221a566 100644 --- a/SharpClaw.Contracts/Entities/Core/Access/GlobalFlagDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Access/GlobalFlagDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -namespace SharpClaw.Application.Infrastructure.Models.Access; +namespace SharpClaw.Contracts.Entities.Core.Access; /// /// A single global boolean flag grant. Each row grants a capability diff --git a/SharpClaw.Contracts/Entities/Core/Access/ResourceAccessDB.cs b/SharpClaw.Contracts/Entities/Core/Access/ResourceAccessDB.cs index 65d8cc28..64149a28 100644 --- a/SharpClaw.Contracts/Entities/Core/Access/ResourceAccessDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Access/ResourceAccessDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -namespace SharpClaw.Application.Infrastructure.Models.Access; +namespace SharpClaw.Contracts.Entities.Core.Access; /// /// A unified per-resource permission grant. Each row grants an agent (via its diff --git a/SharpClaw.Contracts/Entities/Core/AgentDB.cs b/SharpClaw.Contracts/Entities/Core/AgentDB.cs index 822c7926..45897ccf 100644 --- a/SharpClaw.Contracts/Entities/Core/AgentDB.cs +++ b/SharpClaw.Contracts/Entities/Core/AgentDB.cs @@ -1,10 +1,10 @@ using System.Text.Json; -using SharpClaw.Application.Infrastructure.Models; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.Entities; -namespace SharpClaw.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; public class AgentDB : BaseEntity { diff --git a/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceAgentWhitelistEntryDB.cs b/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceAgentWhitelistEntryDB.cs index 17184032..6c2f8038 100644 --- a/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceAgentWhitelistEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceAgentWhitelistEntryDB.cs @@ -1,7 +1,7 @@ using SharpClaw.Contracts.Entities; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Clearance; +namespace SharpClaw.Contracts.Entities.Core.Clearance; /// /// A whitelisted agent on a group. diff --git a/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceUserWhitelistEntryDB.cs b/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceUserWhitelistEntryDB.cs index 5327064d..f92af353 100644 --- a/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceUserWhitelistEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Clearance/ClearanceUserWhitelistEntryDB.cs @@ -1,7 +1,7 @@ using SharpClaw.Contracts.Entities; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Clearance; +namespace SharpClaw.Contracts.Entities.Core.Clearance; /// /// A whitelisted user on a group. diff --git a/SharpClaw.Contracts/Entities/Core/Clearance/PermissionSetDB.cs b/SharpClaw.Contracts/Entities/Core/Clearance/PermissionSetDB.cs index 75988012..f6243b00 100644 --- a/SharpClaw.Contracts/Entities/Core/Clearance/PermissionSetDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Clearance/PermissionSetDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Access; +using SharpClaw.Contracts.Entities.Core.Access; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -namespace SharpClaw.Application.Infrastructure.Models.Clearance; +namespace SharpClaw.Contracts.Entities.Core.Clearance; /// /// A reusable set of permissions that can be attached to a role, context, diff --git a/SharpClaw.Contracts/Entities/Core/Clearance/RoleDB.cs b/SharpClaw.Contracts/Entities/Core/Clearance/RoleDB.cs index a1d3e6e3..9198d8ad 100644 --- a/SharpClaw.Contracts/Entities/Core/Clearance/RoleDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Clearance/RoleDB.cs @@ -1,7 +1,7 @@ using SharpClaw.Contracts.Entities; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Clearance; +namespace SharpClaw.Contracts.Entities.Core.Clearance; public class RoleDB : BaseEntity { diff --git a/SharpClaw.Contracts/Entities/Core/Context/ChannelContextDB.cs b/SharpClaw.Contracts/Entities/Core/Context/ChannelContextDB.cs index dd3b82a4..db20056f 100644 --- a/SharpClaw.Contracts/Entities/Core/Context/ChannelContextDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Context/ChannelContextDB.cs @@ -1,9 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Jobs; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts.Entities; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Context; +namespace SharpClaw.Contracts.Entities.Core.Context; /// /// Groups channels and tasks under a shared set of pre-authorised @@ -47,5 +46,4 @@ public class ChannelContextDB : BaseEntity public ICollection AllowedAgents { get; set; } = []; public ICollection Channels { get; set; } = []; - public ICollection Tasks { get; set; } = []; } diff --git a/SharpClaw.Contracts/Entities/Core/Context/ChannelDB.cs b/SharpClaw.Contracts/Entities/Core/Context/ChannelDB.cs index ff18f65f..9d755282 100644 --- a/SharpClaw.Contracts/Entities/Core/Context/ChannelDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Context/ChannelDB.cs @@ -1,10 +1,10 @@ -using SharpClaw.Application.Infrastructure.Models; -using SharpClaw.Application.Infrastructure.Models.Clearance; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts.Entities; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Context; +namespace SharpClaw.Contracts.Entities.Core.Context; /// /// A channel that optionally belongs to an agent context. Each diff --git a/SharpClaw.Contracts/Entities/Core/Context/ChatThreadDB.cs b/SharpClaw.Contracts/Entities/Core/Context/ChatThreadDB.cs index 01f1717f..7d93cf76 100644 --- a/SharpClaw.Contracts/Entities/Core/Context/ChatThreadDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Context/ChatThreadDB.cs @@ -1,7 +1,7 @@ -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Context; +namespace SharpClaw.Contracts.Entities.Core.Context; /// /// A conversation thread within a channel. Threads enable persistent diff --git a/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceEntryDB.cs b/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceEntryDB.cs index c71aa298..5b9df514 100644 --- a/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceEntryDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Context; +namespace SharpClaw.Contracts.Entities.Core.Context; /// /// A single keyed default-resource entry within a . diff --git a/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceSetDB.cs b/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceSetDB.cs index 3eea6aa1..ad8b16d8 100644 --- a/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceSetDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Context/DefaultResourceSetDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Context; +namespace SharpClaw.Contracts.Entities.Core.Context; /// /// Stores default resource IDs for per-resource action types, indexed by diff --git a/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobDB.cs b/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobDB.cs index 282f5a62..dc21bc39 100644 --- a/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobDB.cs @@ -1,9 +1,9 @@ -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; -namespace SharpClaw.Application.Infrastructure.Models.Jobs; +namespace SharpClaw.Contracts.Entities.Core.Jobs; /// /// Persisted record of an agent action job. Tracks the full lifecycle diff --git a/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobLogEntryDB.cs b/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobLogEntryDB.cs index 67cfd3f2..32bacef4 100644 --- a/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobLogEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Jobs/AgentJobLogEntryDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Jobs; +namespace SharpClaw.Contracts.Entities.Core.Jobs; /// /// A single timestamped log entry within an . diff --git a/SharpClaw.Contracts/Entities/Core/Messages/ChatMessageDB.cs b/SharpClaw.Contracts/Entities/Core/Messages/ChatMessageDB.cs index 57d6c4ec..8702f090 100644 --- a/SharpClaw.Contracts/Entities/Core/Messages/ChatMessageDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Messages/ChatMessageDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -namespace SharpClaw.Application.Infrastructure.Models.Messages; +namespace SharpClaw.Contracts.Entities.Core.Messages; public class ChatMessageDB : BaseEntity { diff --git a/SharpClaw.Contracts/Entities/Core/ModelDB.cs b/SharpClaw.Contracts/Entities/Core/ModelDB.cs index b602eab9..57150232 100644 --- a/SharpClaw.Contracts/Entities/Core/ModelDB.cs +++ b/SharpClaw.Contracts/Entities/Core/ModelDB.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations.Schema; using SharpClaw.Contracts.Entities; -namespace SharpClaw.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; public class ModelDB : BaseEntity { diff --git a/SharpClaw.Contracts/Entities/Core/ModuleConfigEntryDB.cs b/SharpClaw.Contracts/Entities/Core/ModuleConfigEntryDB.cs index 1375cf7d..bb683c98 100644 --- a/SharpClaw.Contracts/Entities/Core/ModuleConfigEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/ModuleConfigEntryDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; /// /// Persistent key-value configuration entry for a module. diff --git a/SharpClaw.Contracts/Entities/Core/ModuleStateDB.cs b/SharpClaw.Contracts/Entities/Core/ModuleStateDB.cs index 7d50fa48..38d28997 100644 --- a/SharpClaw.Contracts/Entities/Core/ModuleStateDB.cs +++ b/SharpClaw.Contracts/Entities/Core/ModuleStateDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; /// /// Tracks the enabled/disabled state of a bundled module. diff --git a/SharpClaw.Contracts/Entities/Core/ProviderDB.cs b/SharpClaw.Contracts/Entities/Core/ProviderDB.cs index 85d4844c..9e1d2fde 100644 --- a/SharpClaw.Contracts/Entities/Core/ProviderDB.cs +++ b/SharpClaw.Contracts/Entities/Core/ProviderDB.cs @@ -1,8 +1,7 @@ using SharpClaw.Contracts.Attributes; using SharpClaw.Contracts.Entities; -using SharpClaw.Contracts.Providers; -namespace SharpClaw.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; public class ProviderDB : BaseEntity { @@ -10,7 +9,7 @@ public class ProviderDB : BaseEntity public string ProviderKey { get; set; } = string.Empty; /// - /// Base URL for the provider API. Only used for providers. + /// Base URL for the provider API. Only used for custom providers. /// public string? ApiEndpoint { get; set; } diff --git a/SharpClaw.Contracts/Entities/Core/RefreshTokenDB.cs b/SharpClaw.Contracts/Entities/Core/RefreshTokenDB.cs index f3abc1f3..f81310ec 100644 --- a/SharpClaw.Contracts/Entities/Core/RefreshTokenDB.cs +++ b/SharpClaw.Contracts/Entities/Core/RefreshTokenDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; public class RefreshTokenDB : BaseEntity { diff --git a/SharpClaw.Contracts/Entities/Core/Tasks/TaskDefinitionDB.cs b/SharpClaw.Contracts/Entities/Core/Tasks/TaskDefinitionDB.cs index fea3469b..eafb4d8d 100644 --- a/SharpClaw.Contracts/Entities/Core/Tasks/TaskDefinitionDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Tasks/TaskDefinitionDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Tasks; +namespace SharpClaw.Contracts.Entities.Core.Tasks; /// /// Persisted task script definition. Stores the raw C# source together diff --git a/SharpClaw.Contracts/Entities/Core/Tasks/TaskExecutionLogDB.cs b/SharpClaw.Contracts/Entities/Core/Tasks/TaskExecutionLogDB.cs index 24b36ab5..d0606c23 100644 --- a/SharpClaw.Contracts/Entities/Core/Tasks/TaskExecutionLogDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Tasks/TaskExecutionLogDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Tasks; +namespace SharpClaw.Contracts.Entities.Core.Tasks; /// /// A single timestamped log entry within a . diff --git a/SharpClaw.Contracts/Entities/Core/Tasks/TaskInstanceDB.cs b/SharpClaw.Contracts/Entities/Core/Tasks/TaskInstanceDB.cs index ecf146b3..235f869a 100644 --- a/SharpClaw.Contracts/Entities/Core/Tasks/TaskInstanceDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Tasks/TaskInstanceDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; -namespace SharpClaw.Application.Infrastructure.Models.Tasks; +namespace SharpClaw.Contracts.Entities.Core.Tasks; /// /// A single execution of a . Tracks diff --git a/SharpClaw.Contracts/Entities/Core/Tasks/TaskOutputEntryDB.cs b/SharpClaw.Contracts/Entities/Core/Tasks/TaskOutputEntryDB.cs index efa9f893..4bb74494 100644 --- a/SharpClaw.Contracts/Entities/Core/Tasks/TaskOutputEntryDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Tasks/TaskOutputEntryDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Tasks; +namespace SharpClaw.Contracts.Entities.Core.Tasks; /// /// A single persisted output emitted by a task instance via Emit(). diff --git a/SharpClaw.Contracts/Entities/Core/Tasks/TaskTriggerBindingDB.cs b/SharpClaw.Contracts/Entities/Core/Tasks/TaskTriggerBindingDB.cs index 11bc7fc6..622ec7c4 100644 --- a/SharpClaw.Contracts/Entities/Core/Tasks/TaskTriggerBindingDB.cs +++ b/SharpClaw.Contracts/Entities/Core/Tasks/TaskTriggerBindingDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models.Tasks; +namespace SharpClaw.Contracts.Entities.Core.Tasks; /// /// Persisted binding for a self-registration trigger defined via a class-level diff --git a/SharpClaw.Contracts/Entities/Core/ToolAwarenessSetDB.cs b/SharpClaw.Contracts/Entities/Core/ToolAwarenessSetDB.cs index b866deb6..856e82c6 100644 --- a/SharpClaw.Contracts/Entities/Core/ToolAwarenessSetDB.cs +++ b/SharpClaw.Contracts/Entities/Core/ToolAwarenessSetDB.cs @@ -1,6 +1,6 @@ using SharpClaw.Contracts.Entities; -namespace SharpClaw.Application.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; /// /// A named, reusable set of booleans controlling which tool-call schemas diff --git a/SharpClaw.Contracts/Entities/Core/UserDB.cs b/SharpClaw.Contracts/Entities/Core/UserDB.cs index eb2965a8..a0364356 100644 --- a/SharpClaw.Contracts/Entities/Core/UserDB.cs +++ b/SharpClaw.Contracts/Entities/Core/UserDB.cs @@ -1,8 +1,8 @@ -using SharpClaw.Application.Infrastructure.Models.Clearance; +using SharpClaw.Contracts.Entities.Core.Clearance; using SharpClaw.Contracts.Attributes; using SharpClaw.Contracts.Entities; -namespace SharpClaw.Infrastructure.Models; +namespace SharpClaw.Contracts.Entities.Core; public class UserDB : BaseEntity { diff --git a/SharpClaw.Contracts/Enums/DatabaseAccessLevel.cs b/SharpClaw.Contracts/Enums/DatabaseAccessLevel.cs deleted file mode 100644 index 5cec177f..00000000 --- a/SharpClaw.Contracts/Enums/DatabaseAccessLevel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SharpClaw.Contracts.Enums; - -public enum DatabaseAccessLevel -{ - ReadOnly = 0, - FullAccess = 1 -} diff --git a/SharpClaw.Contracts/Models/WellKnownCapabilityKeys.cs b/SharpClaw.Contracts/Models/WellKnownCapabilityKeys.cs index f56b4cc2..802ee60a 100644 --- a/SharpClaw.Contracts/Models/WellKnownCapabilityKeys.cs +++ b/SharpClaw.Contracts/Models/WellKnownCapabilityKeys.cs @@ -1,24 +1,34 @@ namespace SharpClaw.Contracts.Models; /// -/// Well-known capability tag keys used to describe model capabilities. -/// These replace the former ModelCapability flags enum and are -/// stored as comma-separated entries in ModelDB.CapabilityTagsRaw. +/// Conventional capability tag keys for model capabilities, stored as +/// comma-separated entries in ModelDB.CapabilityTagsRaw. +/// +/// Capability tags are free-form strings — modules and providers may +/// introduce additional keys without changes to this file. The values +/// listed here are the well-known names used by core and shared provider +/// code so spelling stays consistent across the solution. Only +/// is observed by core directly (it gates chat-service +/// model assignment); the others are conventions consumed by provider +/// modules and capability-aware UI. /// public static class WellKnownCapabilityKeys { - /// The model supports chat completions. + /// + /// The model supports chat completions. Observed by core to decide + /// whether a model is eligible for the chat service. + /// public const string Chat = "chat"; - /// The model supports vision (image) inputs. + /// The model supports vision (image) inputs. Convention used by provider modules. public const string Vision = "vision"; - /// The model produces embeddings. + /// The model produces embeddings. Convention used by provider modules. public const string Embedding = "embedding"; - /// The model produces speech audio (text-to-speech). + /// The model produces speech audio (text-to-speech). Convention used by provider modules. public const string Tts = "tts"; - /// The model generates images. + /// The model generates images. Convention used by provider modules. public const string ImageGeneration = "image-generation"; } diff --git a/SharpClaw.Contracts/Modules/IContextDataReader.cs b/SharpClaw.Contracts/Modules/IContextDataReader.cs deleted file mode 100644 index 8f854838..00000000 --- a/SharpClaw.Contracts/Modules/IContextDataReader.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace SharpClaw.Contracts.Modules; - -/// -/// Thread info for cross-thread history access. -/// -public sealed record ThreadSummary( - Guid ThreadId, - string ThreadName, - Guid ChannelId, - string ChannelTitle); - -/// -/// A single chat message for history reads. -/// -public sealed record ChatMessageSummary( - string Role, - string Content, - string Sender, - DateTimeOffset Timestamp); - -/// -/// Host-side read service for the ContextTools inline tools. -/// Provides access to chat threads, messages, and cross-thread history -/// without exposing core Infrastructure entities to modules. -/// -public interface IContextDataReader -{ - /// Returns true if the thread exists and belongs to the given channel. - Task ThreadExistsAsync(Guid threadId, Guid channelId, CancellationToken ct = default); - - /// Returns messages in the thread, oldest first, limited to . - Task> GetThreadMessagesAsync( - Guid threadId, int maxMessages, CancellationToken ct = default); - - /// - /// Returns threads accessible to the agent from channels other than - /// , respecting cross-thread history - /// permissions and channel opt-in flags. - /// - Task> GetAccessibleThreadsAsync( - Guid agentId, Guid currentChannelId, CancellationToken ct = default); -} diff --git a/SharpClaw.Contracts/Modules/ISharpClawEventSinkRegistry.cs b/SharpClaw.Contracts/Modules/ISharpClawEventSinkRegistry.cs new file mode 100644 index 00000000..82dbaf56 --- /dev/null +++ b/SharpClaw.Contracts/Modules/ISharpClawEventSinkRegistry.cs @@ -0,0 +1,17 @@ +namespace SharpClaw.Contracts.Modules; + +/// +/// Host-side registry of instances. +/// Modules that contribute a sink whose lifetime tracks an external state +/// (for example a trigger source that only wants events while bindings are +/// loaded) call when their effective subscription +/// changes so the dispatcher rebuilds its sink list on the next dispatch. +/// +public interface ISharpClawEventSinkRegistry +{ + /// + /// Drop any cached sink list so the next dispatch re-resolves the + /// current set of registered sinks from DI. + /// + void InvalidateCache(); +} diff --git a/SharpClaw.Contracts/Modules/ModuleEntityModelBuilder.cs b/SharpClaw.Contracts/Modules/ModuleEntityModelBuilder.cs deleted file mode 100644 index e69de29b..00000000 diff --git a/SharpClaw.Contracts/Modules/ThreadSummary.cs b/SharpClaw.Contracts/Modules/ThreadSummary.cs new file mode 100644 index 00000000..465b0751 --- /dev/null +++ b/SharpClaw.Contracts/Modules/ThreadSummary.cs @@ -0,0 +1,13 @@ +namespace SharpClaw.Contracts.Modules; + +/// +/// Thread info for cross-thread history access. Returned by +/// +/// so core chat-pipeline call sites never need to know which module +/// owns the underlying cross-thread visibility policy. +/// +public sealed record ThreadSummary( + Guid ThreadId, + string ThreadName, + Guid ChannelId, + string ChannelTitle); diff --git a/SharpClaw.Contracts/Permissions/IGlobalFlagEvaluator.cs b/SharpClaw.Contracts/Permissions/IGlobalFlagEvaluator.cs new file mode 100644 index 00000000..4d111ad4 --- /dev/null +++ b/SharpClaw.Contracts/Permissions/IGlobalFlagEvaluator.cs @@ -0,0 +1,26 @@ +namespace SharpClaw.Contracts.Permissions; + +/// +/// Narrow, decision-shaped contract that lets modules evaluate +/// global-flag permissions against the host without taking a direct +/// dependency on core services such as AgentActionService. +/// +/// Each module owns the constant string for its own flags (see the +/// per-module *PermissionKeys classes) and asks this evaluator +/// for a single Approved/Denied verdict. The evaluator hides the +/// underlying clearance pipeline; modules never see +/// ClearanceVerdict, caller layers, or +/// AgentActionService internals. +/// +/// +public interface IGlobalFlagEvaluator +{ + /// + /// Returns when the agent has the named + /// global flag granted at a clearance level that resolves to + /// Approved for the current caller layer; otherwise + /// . + /// + Task IsApprovedAsync( + string flagKey, Guid agentId, CancellationToken ct = default); +} diff --git a/SharpClaw.Contracts/Persistence/ISharpClawDataContext.cs b/SharpClaw.Contracts/Persistence/ISharpClawDataContext.cs index e69de29b..087cf919 100644 --- a/SharpClaw.Contracts/Persistence/ISharpClawDataContext.cs +++ b/SharpClaw.Contracts/Persistence/ISharpClawDataContext.cs @@ -0,0 +1,31 @@ +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Access; +using SharpClaw.Contracts.Entities.Core.Clearance; +using SharpClaw.Contracts.Entities.Core.Context; +using SharpClaw.Contracts.Entities.Core.Messages; + +namespace SharpClaw.Contracts.Persistence; + +/// +/// Read-only data-access contract that exposes the core entity surface +/// to modules. Modules write their own LINQ queries against the real +/// entity types through this contract; mutations remain the +/// responsibility of host-side adapters. +/// +/// +/// The accessors return rather than +/// DbSet<T> so module code cannot mutate core state +/// through this surface. The contract grows only when a module +/// presents a real read need. +/// +public interface ISharpClawDataContext +{ + IQueryable Agents { get; } + IQueryable Channels { get; } + IQueryable AgentContexts { get; } + IQueryable ChatThreads { get; } + IQueryable ChatMessages { get; } + IQueryable PermissionSets { get; } + IQueryable GlobalFlags { get; } + IQueryable Roles { get; } +} diff --git a/SharpClaw.Contracts/Services/IScheduledJobService.cs b/SharpClaw.Contracts/Services/IScheduledJobService.cs new file mode 100644 index 00000000..cfe4d70f --- /dev/null +++ b/SharpClaw.Contracts/Services/IScheduledJobService.cs @@ -0,0 +1,43 @@ +using SharpClaw.Contracts.DTOs.Tasks; + +namespace SharpClaw.Contracts.Services; + +/// +/// Host-provided contract for scheduled-job CRUD, lifecycle, and cron +/// preview operations. Exposed through SharpClaw.Contracts so that +/// modules can map the /scheduled-jobs REST surface without taking +/// a project reference on SharpClaw.Application.Core. +/// +public interface IScheduledJobService +{ + /// Validate cron fields and persist a new scheduled job. + Task CreateAsync( + CreateScheduledJobRequest request, CancellationToken ct = default); + + /// Returns a single scheduled job by id, or null. + Task GetByIdAsync(Guid id, CancellationToken ct = default); + + /// List all scheduled jobs ordered by next run time. + Task> ListAsync(CancellationToken ct = default); + + /// Apply a partial update to a scheduled job. + Task UpdateAsync( + Guid id, UpdateScheduledJobRequest request, CancellationToken ct = default); + + /// Delete a scheduled job. Returns false if it does not exist. + Task DeleteAsync(Guid id, CancellationToken ct = default); + + /// Pause a pending scheduled job. + Task PauseAsync(Guid id, CancellationToken ct = default); + + /// Resume a paused scheduled job, recomputing NextRunAt if needed. + Task ResumeAsync(Guid id, CancellationToken ct = default); + + /// Compute upcoming occurrences for a stored job's cron expression. + Task PreviewJobAsync( + Guid id, int count = 10, CancellationToken ct = default); + + /// Validate and evaluate a cron expression statelessly. + CronPreviewResponse PreviewExpression( + string expression, string? timezone = null, int count = 10); +} diff --git a/SharpClaw.Contracts/SharpClaw.Contracts.csproj b/SharpClaw.Contracts/SharpClaw.Contracts.csproj index 36847937..e6c48ebe 100644 --- a/SharpClaw.Contracts/SharpClaw.Contracts.csproj +++ b/SharpClaw.Contracts/SharpClaw.Contracts.csproj @@ -32,7 +32,6 @@ - diff --git a/SharpClaw.Contracts/Tasks/IHostAgentBridge.cs b/SharpClaw.Contracts/Tasks/IHostAgentBridge.cs new file mode 100644 index 00000000..3ea6cbca --- /dev/null +++ b/SharpClaw.Contracts/Tasks/IHostAgentBridge.cs @@ -0,0 +1,89 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Generic host-side bridge for agent-related operations: chat, structured +/// response parsing, lookups, and provisioning of agents/threads/roles/channels. +/// Any module (or other host consumer) that needs to drive these flows resolves +/// this bridge from instead of +/// taking a direct dependency on SharpClawDbContext, ChatService, +/// RoleService, etc. +/// +/// The implementation lives in SharpClaw.Application.Core and is +/// registered in DI by the host. Multiple modules may share this bridge — +/// the instanceId / taskName parameters are advisory metadata +/// for logging and chat context, not module-specific state. +/// +public interface IHostAgentBridge +{ + Task ChatAsync( + Guid instanceId, + string taskName, + string message, + Guid? agentId, + CancellationToken ct); + + Task ChatStreamAsync( + Guid instanceId, + string taskName, + string message, + Guid? agentId, + CancellationToken ct); + + Task ChatToThreadAsync( + Guid instanceId, + string taskName, + Guid threadId, + string message, + Guid? agentId, + CancellationToken ct); + + string ParseStructuredResponse( + Guid instanceId, + string text, + string? typeName); + + Task FindModelAsync(string search, CancellationToken ct); + Task FindProviderAsync(string search, CancellationToken ct); + Task FindAgentAsync(string search, CancellationToken ct); + Task FindRoleAsync(string search, CancellationToken ct); + Task FindChannelAsync(string search, CancellationToken ct); + + Task CreateAgentAsync( + Guid instanceId, + string name, + Guid modelId, + string? systemPrompt, + string? customId, + CancellationToken ct); + + Task CreateThreadAsync( + Guid instanceId, + Guid? channelId, + string? threadName, + CancellationToken ct); + + Task CreateRoleAsync(string roleName, CancellationToken ct); + + Task SetRolePermissionsAsync( + Guid roleId, + string requestJson, + CancellationToken ct); + + Task AssignRoleAsync( + Guid agentId, + Guid roleId, + CancellationToken ct); + + Task CreateChannelAsync( + Guid instanceId, + string title, + Guid agentId, + string? customId, + CancellationToken ct); + + Task AddAllowedAgentAsync( + Guid instanceId, + Guid agentId, + Guid? channelId, + CancellationToken ct); +} diff --git a/SharpClaw.Contracts/Tasks/IHostQueueMetrics.cs b/SharpClaw.Contracts/Tasks/IHostQueueMetrics.cs new file mode 100644 index 00000000..ecf37460 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/IHostQueueMetrics.cs @@ -0,0 +1,20 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Host-provided probes for built-in queue and scheduler counts (pending agent +/// jobs, pending task instances, due-but-not-yet-triggered scheduled jobs). +/// Exposed as a contract so any consumer — metric providers, health checks, +/// diagnostic endpoints, dashboards — can read these numbers without taking +/// a direct dependency on the host's persistence layer. +/// +public interface IHostQueueMetrics +{ + /// Number of agent jobs currently in the Queued state. + Task GetPendingJobCountAsync(CancellationToken ct); + + /// Number of task instances currently in the Queued state. + Task GetPendingTaskCountAsync(CancellationToken ct); + + /// Number of scheduled jobs past their NextRunAt time that have not been triggered. + Task GetSchedulerPendingJobCountAsync(CancellationToken ct); +} diff --git a/SharpClaw.Contracts/Tasks/IShortcutLauncherService.cs b/SharpClaw.Contracts/Tasks/IShortcutLauncherService.cs deleted file mode 100644 index 147b617b..00000000 --- a/SharpClaw.Contracts/Tasks/IShortcutLauncherService.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Abstracts OS shortcut creation and removal for OsShortcut triggers. -/// -public interface IShortcutLauncherService -{ - /// - /// Writes the OS shortcut (.lnk / .desktop) for the given trigger definition. - /// - Task WriteShortcutAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default); - - /// - /// Rewrites the shortcut file for an existing task (label, icon, category). - /// - Task RefreshShortcutsAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default); - - /// - /// Deletes all shortcut files and the stub launcher for . - /// - Task RemoveShortcutsAsync(string customId, CancellationToken ct = default); -} diff --git a/SharpClaw.Contracts/Tasks/ITaskEventHandler.cs b/SharpClaw.Contracts/Tasks/ITaskEventHandler.cs index 8edd551c..ab0f9ea7 100644 --- a/SharpClaw.Contracts/Tasks/ITaskEventHandler.cs +++ b/SharpClaw.Contracts/Tasks/ITaskEventHandler.cs @@ -7,12 +7,9 @@ namespace SharpClaw.Contracts.Tasks; /// public interface ITaskEventHandler { - TaskTriggerKind TriggerKind { get; } - /// - /// Module-owned trigger key, set when is - /// . Used by module event - /// loops to match handlers without referencing host enum values. + /// Module-owned trigger key. Used by module event loops to match + /// handlers without referencing host-side discriminators. /// string? ModuleTriggerKey { get; } diff --git a/SharpClaw.Contracts/Tasks/ITaskInstanceLauncher.cs b/SharpClaw.Contracts/Tasks/ITaskInstanceLauncher.cs new file mode 100644 index 00000000..6d1ae9cc --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskInstanceLauncher.cs @@ -0,0 +1,20 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Host-provided entry point for starting task instances. +/// The scheduler in the AgentOrchestration module calls this to begin +/// execution of a task definition; the host is the only owner of +/// task definition validation, instance creation, and orchestration. +/// +public interface ITaskInstanceLauncher +{ + /// + /// Create a queued instance of the supplied task definition and start + /// the orchestrator. Returns the new instance id. + /// + Task LaunchAsync( + Guid taskDefinitionId, + IReadOnlyDictionary? parameterValues, + Guid? callerAgentId, + CancellationToken ct); +} diff --git a/SharpClaw.Contracts/Tasks/ITaskMetricProvider.cs b/SharpClaw.Contracts/Tasks/ITaskMetricProvider.cs index ce62f9e8..add06dff 100644 --- a/SharpClaw.Contracts/Tasks/ITaskMetricProvider.cs +++ b/SharpClaw.Contracts/Tasks/ITaskMetricProvider.cs @@ -1,12 +1,19 @@ namespace SharpClaw.Contracts.Tasks; /// -/// Provides a named metric value that can be polled by a metric trigger source -/// for threshold comparisons. +/// Provides a named, pollable numeric metric value. Hosts and modules can +/// register implementations to expose runtime measurements (queue depths, +/// throughput, latency, custom counters, etc.) to any consumer that needs to +/// observe or compare them — for example trigger sources that fire on +/// threshold crossings, dashboards, or diagnostic endpoints. /// public interface ITaskMetricProvider { - /// Unique name used in [OnMetricThreshold("MetricName", …)]. + /// + /// Stable, unique identifier for this metric. Convention is a short, + /// developer-friendly dotted or kebab-case name (e.g. queue.pending-jobs, + /// http.request-latency-ms) so it reads well in scripts, logs, and UI. + /// string MetricName { get; } /// Human-readable description of the metric. diff --git a/SharpClaw.Contracts/Tasks/ITaskParserModuleExtension.cs b/SharpClaw.Contracts/Tasks/ITaskParserModuleExtension.cs index a35012ab..705cab46 100644 --- a/SharpClaw.Contracts/Tasks/ITaskParserModuleExtension.cs +++ b/SharpClaw.Contracts/Tasks/ITaskParserModuleExtension.cs @@ -18,8 +18,7 @@ public interface ITaskParserModuleExtension /// /// Maps event-handler method names (as they appear in task scripts) to a /// module-owned trigger key and the owning module ID. - /// The parser records on the - /// step and stores the key in TaskStepDefinition.ModuleTriggerKey. + /// The parser stores the key in TaskStepDefinition.ModuleTriggerKey. /// IReadOnlyDictionary EventTriggerMappings { get; } @@ -28,5 +27,36 @@ public interface ITaskParserModuleExtension /// should be captured as Expression on the parsed step. /// IReadOnlySet SingleArgExpressionMethods { get; } + + /// + /// Optional contribution of the wire-format step-key strings the parser + /// stamps on statement-shaped constructs that have no method-name + /// binding (declarations, assignments, control flow, return, delay, + /// evaluate, log, parse-response). + /// + /// Exactly one registered module must provide a non-null value. + /// Core owns no statement step-key constants; the parser depends + /// entirely on this contribution. + /// + /// + TaskParserPrimitives? Primitives => null; + + /// + /// Maps trigger-attribute names (short form, e.g. "Schedule") to + /// module-owned handlers that emit a + /// for each matching attribute occurrence. The parser also accepts the + /// "…Attribute" long form for the same handler. A registered + /// handler returning from + /// declines the + /// attribute and the parser falls back to its built-in switch. + /// + /// Phase 1 of the trigger-attribute module migration. No core + /// attribute is moved out yet; the legacy switch in + /// TaskScriptParser remains the source of truth for any + /// attribute name that no module claims. + /// + /// + IReadOnlyDictionary TriggerAttributeHandlers + => new Dictionary(StringComparer.Ordinal); } diff --git a/SharpClaw.Contracts/Tasks/ITaskStepDescriptorProvider.cs b/SharpClaw.Contracts/Tasks/ITaskStepDescriptorProvider.cs new file mode 100644 index 00000000..bd0e6145 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskStepDescriptorProvider.cs @@ -0,0 +1,19 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Implemented by modules that contribute task step descriptors. The host +/// collects all implementations +/// at startup and registers their descriptors with the task step registry +/// before any task script is parsed. +/// +public interface ITaskStepDescriptorProvider +{ + /// The module ID contributing these descriptors. + string ModuleId { get; } + + /// + /// All descriptors contributed by this module. Each descriptor's + /// must equal . + /// + IReadOnlyList Descriptors { get; } +} diff --git a/SharpClaw.Contracts/Tasks/ITaskStepExecutionContext.cs b/SharpClaw.Contracts/Tasks/ITaskStepExecutionContext.cs index b87e9273..0ddb6e5a 100644 --- a/SharpClaw.Contracts/Tasks/ITaskStepExecutionContext.cs +++ b/SharpClaw.Contracts/Tasks/ITaskStepExecutionContext.cs @@ -17,6 +17,15 @@ public interface ITaskStepExecutionContext /// Active cancellation token for the task instance. CancellationToken CancellationToken { get; } + /// + /// Scoped service provider for the running task instance. Modules may + /// resolve services (e.g. SharpClawDbContext, chat/agent-job + /// services) from this provider when executing steps. The scope is + /// owned by the orchestrator and is valid for the duration of step + /// execution. + /// + IServiceProvider Services { get; } + /// /// Task-script variables. Modules may read and write entries to propagate /// results (e.g. a transcription job ID stored via ResultVariable). @@ -36,4 +45,51 @@ public interface ITaskStepExecutionContext /// Append a log entry to this task instance's log. /// Task AppendLogAsync(string message); + + /// + /// Push an output payload to the task instance's output stream + /// (persisted as a TaskOutputEntry and surfaced to streaming + /// listeners). Used by modules implementing the core.emit + /// step or any equivalent module-owned output operation. + /// + Task WriteOutputAsync(string? outputJson); + + /// + /// Update the channel currently associated with this running task + /// instance. Used by module executors implementing + /// core.create_channel when the task was started in + /// context-mode (no initial channel) — subsequent chat/thread steps + /// then resolve to the newly-created channel. + /// + void SetChannelId(Guid channelId); + + /// + /// Recursively execute a nested step list (loop body, then/else branch, + /// event-handler body). Returns if + /// any nested step requested an early return so the caller can unwind. + /// + Task ExecuteStepsAsync( + IReadOnlyList steps, + CancellationToken cancellationToken); + + /// + /// Evaluate a boolean expression against the current variable scope. + /// Used by control-flow executors (conditional, while-loop). + /// + bool EvaluateCondition(string? expression); + + /// + /// Register an event handler for a module-owned trigger key. Used by + /// the scripting event_handler step; module event loops then + /// enumerate to fire matching handlers. + /// + void RegisterEventHandler( + string moduleTriggerKey, + string? parameterName, + IReadOnlyList body); + + /// + /// Block until the task instance has been resumed (no-op when not paused). + /// + Task WaitIfPausedAsync(); } diff --git a/SharpClaw.Contracts/Tasks/ITaskStepInvocation.cs b/SharpClaw.Contracts/Tasks/ITaskStepInvocation.cs new file mode 100644 index 00000000..e1439290 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskStepInvocation.cs @@ -0,0 +1,48 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Read-only projection of a single task step as seen by module-side +/// invocation executors. Unlike the resolved-argument execution path +/// used by , this surface preserves +/// the raw step shape so control-flow primitives (conditionals, +/// loops, event handlers, declare/assign, evaluate, return) can drive +/// their own nested execution. +/// +public interface ITaskStepInvocation +{ + /// Stable string key identifying this step's operation. + string StepKey { get; } + + /// Variable name for declare/assign and foreach-loop steps. + string? VariableName { get; } + + /// Type name for declare-variable and parse-response steps. + string? TypeName { get; } + + /// Variable that stores the result of this step, if any. + string? ResultVariable { get; } + + /// + /// The raw, unresolved expression text from the source script. Modules + /// that semantically store expressions verbatim (e.g. + /// declare_variable, assign, evaluate) read this + /// directly; modules that consume runtime values resolve it via + /// . + /// + string? RawExpression { get; } + + /// Raw, unresolved positional arguments. + IReadOnlyList? Arguments { get; } + + /// Module-owned trigger key for event-handler steps. + string? ModuleTriggerKey { get; } + + /// Lambda parameter name for event-handler callbacks. + string? HandlerParameter { get; } + + /// Nested body steps (then-branch, loop body, handler body). + IReadOnlyList? Body { get; } + + /// Nested else-body steps (conditional else-branch). + IReadOnlyList? ElseBody { get; } +} diff --git a/SharpClaw.Contracts/Tasks/ITaskStepInvocationExecutor.cs b/SharpClaw.Contracts/Tasks/ITaskStepInvocationExecutor.cs new file mode 100644 index 00000000..d28ea7ea --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskStepInvocationExecutor.cs @@ -0,0 +1,20 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Optional companion interface to +/// for executors that need raw step access (nested bodies, unresolved +/// expressions, event-handler registration). When the orchestrator is +/// dispatching a step whose StepKey is claimed by an executor +/// implementing this interface, it bypasses the resolved-argument path +/// and calls directly. +/// +public interface ITaskStepInvocationExecutor : ITaskStepExecutorExtension +{ + /// + /// Execute a step with full access to its raw shape. Return + /// to unwind to the task entry point. + /// + Task ExecuteInvocationAsync( + ITaskStepInvocation step, + ITaskStepExecutionContext context); +} diff --git a/SharpClaw.Contracts/Tasks/ITaskToolCatalog.cs b/SharpClaw.Contracts/Tasks/ITaskToolCatalog.cs new file mode 100644 index 00000000..a50be97e --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskToolCatalog.cs @@ -0,0 +1,25 @@ +using SharpClaw.Contracts.Providers; + +namespace SharpClaw.Contracts.Tasks; + +/// +/// Read-only catalog of active task definitions exposed as agent tool +/// schemas. Lets modules surface task tools through their own chat +/// contributors without taking a dependency on TaskToolProvider, +/// SharpClawDbContext, or any infrastructure type. +/// +/// The catalog is policy-free: it always returns every active task +/// definition. The decision of whether to surface those schemas in a +/// given chat turn lives in the calling contributor (typically +/// AgentOrchestrationChatContributor), which evaluates its own +/// module-owned permission flag before invoking the catalog. +/// +/// +public interface ITaskToolCatalog +{ + /// + /// Build tool definitions for all active task definitions. + /// + Task> GetToolDefinitionsAsync( + CancellationToken ct = default); +} diff --git a/SharpClaw.Contracts/Tasks/ITaskTriggerAttributeHandler.cs b/SharpClaw.Contracts/Tasks/ITaskTriggerAttributeHandler.cs new file mode 100644 index 00000000..d547909c --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskTriggerAttributeHandler.cs @@ -0,0 +1,26 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Module-owned recogniser for a single trigger attribute name (the short +/// form, e.g. "Schedule"; the parser also accepts the +/// "ScheduleAttribute" long form via the registration map). +/// +/// Attribute classes are not real .NET types in task scripts — scripts are +/// parsed but not compiled — so attribute ownership cannot be expressed as +/// type containment. Instead, a module registers a handler under each +/// attribute name it claims; the parser routes matching attribute +/// occurrences to the handler and uses the returned +/// directly. +/// +/// +public interface ITaskTriggerAttributeHandler +{ + /// + /// Inspect a single attribute occurrence and return its parsed + /// , or to + /// decline (in which case the parser falls back to its built-in switch). + /// Handlers may emit diagnostics via + /// . + /// + TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context); +} diff --git a/SharpClaw.Contracts/Tasks/ITaskTriggerBindingSideEffect.cs b/SharpClaw.Contracts/Tasks/ITaskTriggerBindingSideEffect.cs new file mode 100644 index 00000000..b287ea75 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskTriggerBindingSideEffect.cs @@ -0,0 +1,50 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Optional companion contract to for +/// trigger kinds that need a side effect attached to each persisted +/// binding row but do not own binding persistence themselves +/// (i.e. the source still wants the registrar's default +/// TaskTriggerBindingDB upsert). +/// +/// The canonical example is the OS-shortcut trigger: the binding row is +/// indistinguishable from any other trigger binding, but creating one +/// must also write a .lnk / .desktop file, and removing +/// one must delete it again. +/// +/// +/// A trigger source must not implement both ITaskTriggerSource with +/// set to +/// and this side-effect contract: when the source +/// owns persistence, the registrar does not produce the binding rows that +/// these hooks would otherwise observe. +/// +public interface ITaskTriggerBindingSideEffect +{ + /// + /// Trigger key this side effect attaches to. Compared against + /// TaskTriggerBindingDB.Kind on each registrar pass. + /// + string TriggerKey { get; } + + /// + /// Fires after the registrar persists a binding row for a trigger + /// matching . Implementations should be + /// idempotent — the registrar may re-fire this hook on retries. + /// + Task OnBindingCreatedAsync( + TaskDefinitionDescriptor definition, + TaskTriggerDefinition trigger, + TaskTriggerBindingDescriptor binding, + CancellationToken ct); + + /// + /// Fires before the registrar removes a binding row for a trigger + /// matching . Implementations should be + /// idempotent — the side effect must remain a no-op if the underlying + /// state was already cleared. + /// + Task OnBindingRemovedAsync( + TaskTriggerBindingDescriptor binding, + CancellationToken ct); +} diff --git a/SharpClaw.Contracts/Tasks/ITaskTriggerSource.cs b/SharpClaw.Contracts/Tasks/ITaskTriggerSource.cs index 03a42f11..f62fc4d1 100644 --- a/SharpClaw.Contracts/Tasks/ITaskTriggerSource.cs +++ b/SharpClaw.Contracts/Tasks/ITaskTriggerSource.cs @@ -36,4 +36,69 @@ public interface ITaskTriggerSource /// Stop watching and release all resources held by this source. /// Task StopAsync(); + + /// + /// Value persisted into TaskTriggerBindingDB.TriggerValue. Used as the + /// primary lookup key for binding rows. Should be the most specific + /// identifier the source dispatches on (route path, host name, metric + /// name, etc.). + /// + /// Default implementation returns ; while the + /// trigger-extraction migration is in progress the legacy switch in + /// TaskTriggerRegistrar remains the source of truth for sources + /// that have not yet overridden this method. + /// + string? GetBindingValue(TaskTriggerDefinition def) => null; + + /// + /// Value persisted into TaskTriggerBindingDB.Filter. Used as a + /// secondary discriminator (for example, an event-subtype filter on top + /// of a primary event-type binding value). + /// + string? GetBindingFilter(TaskTriggerDefinition def) => null; + + /// + /// Indicates this source manages its own persistence for incoming + /// trigger definitions. When , the registrar + /// skips its default TaskTriggerBindingDB upsert for triggers + /// whose TriggerKey is owned by this source and instead calls + /// . Sources that opt in must + /// idempotently persist whatever state they need (e.g. + /// ScheduledJobDB rows, on-disk shortcut files) and remove + /// stale state on each call. + /// + /// + /// A source must not implement both OwnsBindingPersistence == true + /// and : when the source owns + /// persistence, the default binding rows are not written, so per-row + /// side-effect hooks have nothing to attach to. + /// + bool OwnsBindingPersistence => false; + + /// + /// Called by TaskTriggerRegistrar when a task definition is + /// created or updated, but only for sources where + /// is . + /// Receives only the triggers whose TriggerKey this source + /// claims (via ). Implementations must be + /// idempotent: stale state for the same definition must be removed + /// in the same call. + /// + /// + /// if any tracked state changed, so the + /// registrar can propagate the change flag to its caller. + /// + Task SyncBindingsAsync( + TaskDefinitionDescriptor definition, + IReadOnlyList ownedTriggers, + CancellationToken ct) => Task.FromResult(false); + + /// + /// Called by TaskTriggerRegistrar when a task definition is + /// deleted, but only for sources where + /// is . Implementations must remove all state + /// previously persisted for . + /// + Task RemoveBindingsAsync(Guid definitionId, CancellationToken ct) => + Task.CompletedTask; } diff --git a/SharpClaw.Contracts/Tasks/ITaskTriggerSourceRegistry.cs b/SharpClaw.Contracts/Tasks/ITaskTriggerSourceRegistry.cs new file mode 100644 index 00000000..b71c37bc --- /dev/null +++ b/SharpClaw.Contracts/Tasks/ITaskTriggerSourceRegistry.cs @@ -0,0 +1,33 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Read-only facade over the set of +/// implementations registered with the host. Introduced by the trigger +/// extraction plan so that TaskTriggerRegistrar can resolve a source +/// by its trigger key and delegate binding-value derivation to it instead +/// of switching on well-known core key strings. +/// +public interface ITaskTriggerSourceRegistry +{ + /// + /// Resolve the trigger source that handles the given trigger key, or + /// if no registered source claims the key + /// (for example because the owning module is currently disabled). + /// + ITaskTriggerSource? ResolveByKey(string? triggerKey); + + /// All trigger sources currently registered with the host. + IReadOnlyList Sources { get; } + + /// + /// Resolve the binding side-effect handler that attaches to the given + /// trigger key, or if no registered side effect + /// claims the key. Distinct from + /// because side effects are an opt-in companion to a trigger source — + /// most sources don't need one. + /// + ITaskTriggerBindingSideEffect? ResolveSideEffect(string? triggerKey); + + /// All binding side-effect handlers currently registered. + IReadOnlyList SideEffects { get; } +} diff --git a/SharpClaw.Contracts/Tasks/IWebhookRouteRegistrar.cs b/SharpClaw.Contracts/Tasks/IWebhookRouteRegistrar.cs new file mode 100644 index 00000000..d67d2f17 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/IWebhookRouteRegistrar.cs @@ -0,0 +1,22 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Abstracts the minimal-API route registration surface so that a webhook +/// trigger source implementation can be tested without a real +/// WebApplication and so that module-owned trigger sources do not +/// need a compile-time dependency on ASP.NET Core hosting types. +/// +/// The host (API project) provides the concrete implementation; module +/// trigger sources consume this interface via dependency injection and +/// call for each active binding. +/// +/// +public interface IWebhookRouteRegistrar +{ + /// + /// Register a POST route at the given path if it has not already been + /// registered during this application lifetime. + /// + /// Absolute path, e.g. /webhooks/tasks/my-hook. + void EnsureRegistered(string routePath); +} diff --git a/SharpClaw.Contracts/Tasks/IWebhookTriggerHost.cs b/SharpClaw.Contracts/Tasks/IWebhookTriggerHost.cs new file mode 100644 index 00000000..4d756ae5 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/IWebhookTriggerHost.cs @@ -0,0 +1,28 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Host-facing abstraction for the webhook trigger source implementation +/// that lives in a module. Lets the API project bridge incoming HTTP +/// requests to the module-owned source without taking a compile-time +/// dependency on the module assembly. +/// +public interface IWebhookTriggerHost +{ + /// + /// Supplies the host-provided minimal-API route registrar. Called once + /// after the WebApplication is built; subsequent + /// StartAsync calls in the trigger source forward each active + /// binding's route through the registrar. + /// + void SetRouteRegistrar(IWebhookRouteRegistrar registrar); + + /// + /// Returns the HTTP status code (202 = fire, 401 = bad signature, 404 = not found) + /// for an incoming webhook request. + /// + Task HandleRequestAsync( + string routePath, + string body, + string headersJson, + CancellationToken ct = default); +} diff --git a/SharpClaw.Contracts/Tasks/TaskDefinitionDescriptor.cs b/SharpClaw.Contracts/Tasks/TaskDefinitionDescriptor.cs new file mode 100644 index 00000000..ffb926db --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskDefinitionDescriptor.cs @@ -0,0 +1,15 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Lightweight, contract-layer view of a persisted task definition exposed +/// to implementations that opt into owning +/// their own binding persistence. The host populates this descriptor from +/// its EF entity (TaskDefinitionDB) before invoking the source so +/// modules never need a project reference back to the host infrastructure +/// project. +/// +/// Stable database identity of the task definition. +/// Value from the [Task("name")] attribute. Used +/// by some sources (e.g. OS shortcut, cron) as a stable customId for +/// per-definition side effects. +public sealed record TaskDefinitionDescriptor(Guid Id, string Name); diff --git a/SharpClaw.Contracts/Tasks/TaskParserPrimitives.cs b/SharpClaw.Contracts/Tasks/TaskParserPrimitives.cs new file mode 100644 index 00000000..828707b7 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskParserPrimitives.cs @@ -0,0 +1,55 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Wire-format step-key strings for the statement-shaped constructs the +/// task-script parser emits directly (declarations, assignments, control +/// flow, return, delay, evaluate, log, parse-response). +/// +/// These keys have no method-name binding and therefore cannot flow through +/// . A module that +/// owns the scripting-language primitives supplies an instance of this +/// record via ; the +/// parser stores it and stamps these values on +/// TaskStepDefinition.StepKey as it produces statement nodes. +/// +/// +/// Core defines no step-name constants of its own; every value comes from +/// the registering module. +/// +/// +public sealed record TaskParserPrimitives +{ + /// Step key for variable declarations. + public required string DeclareVariable { get; init; } + + /// Step key for variable / property assignments. + public required string Assign { get; init; } + + /// Step key for event-handler registrations. + public required string EventHandler { get; init; } + + /// Step key for if/else conditional branches. + public required string Conditional { get; init; } + + /// Step key for while/foreach loops. + public required string Loop { get; init; } + + /// Step key for return statements. + public required string Return { get; init; } + + /// Step key for the parser-recognized Task.Delay form. + public required string Delay { get; init; } + + /// Step key for evaluated expressions. + public required string Evaluate { get; init; } + + /// Step key for log statements. + public required string Log { get; init; } + + /// + /// Step key for parse-response steps. Validator-recognized so that + /// TypeName on parse-response steps is checked against the + /// task's known type set. + /// + public required string ParseResponse { get; init; } +} diff --git a/SharpClaw.Contracts/Tasks/TaskPermissionKeys.cs b/SharpClaw.Contracts/Tasks/TaskPermissionKeys.cs deleted file mode 100644 index 772328ee..00000000 --- a/SharpClaw.Contracts/Tasks/TaskPermissionKeys.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Well-known values -/// for the built-in task subsystem. -/// -public static class TaskPermissionKeys -{ - /// - /// Grants permission to create, update, and delete task definitions. - /// An agent without this flag may still execute and view tasks. - /// - public const string CanManageTasks = "CanManageTasks"; - - /// - /// Grants permission to start, stop, pause, and resume task instances. - /// - public const string CanExecuteTasks = "CanExecuteTasks"; - - /// - /// Grants permission to invoke task definitions as agent tools. - /// Agents with this flag see active task definitions in their tool list. - /// - public const string CanInvokeTasksAsTool = "CanInvokeTasksAsTool"; -} diff --git a/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepDescriptor.cs b/SharpClaw.Contracts/Tasks/TaskStepDescriptor.cs similarity index 64% rename from SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepDescriptor.cs rename to SharpClaw.Contracts/Tasks/TaskStepDescriptor.cs index 918214a8..dd6876d7 100644 --- a/SharpClaw.Application.Infrastructure.Tasks/Registry/TaskStepDescriptor.cs +++ b/SharpClaw.Contracts/Tasks/TaskStepDescriptor.cs @@ -1,11 +1,10 @@ -using SharpClaw.Contracts.Tasks; - -namespace SharpClaw.Application.Infrastructure.Tasks.Registry; +namespace SharpClaw.Contracts.Tasks; /// /// Describes a single task step operation that can be registered in -/// . Core step descriptors are built-in; -/// module descriptors are added during module startup. +/// the task step registry. All descriptors are owned by a module +/// () and contributed to the host via +/// . /// public sealed record TaskStepDescriptor { @@ -19,27 +18,28 @@ public sealed record TaskStepDescriptor /// /// Stable wire-style step key (e.g. core.chat). - /// Use for core operations. /// public required string StepKey { get; init; } /// - /// The module ID that owns this step, or - /// for built-in core steps. + /// The module ID that owns this step. There are no longer any core-owned + /// descriptors; every descriptor must declare a module owner. /// public required string OwnerId { get; init; } /// /// When , the first method argument is captured - /// as Expression on the parsed . + /// as Expression on the parsed step. /// public bool FirstArgIsExpression { get; init; } /// - /// HTTP verb for HTTP-request descriptors ("GET", "POST", "PUT", "DELETE"). - /// for non-HTTP steps. + /// Optional constant value to prepend to the parsed step's + /// Arguments list. Lets a module encode descriptor-specific + /// data (e.g. an HTTP verb) without leaking it into the + /// host-side step contract. /// - public string? HttpMethod { get; init; } + public string? PrefixArgument { get; init; } /// /// when the method uses a generic type argument @@ -52,7 +52,4 @@ public sealed record TaskStepDescriptor /// (overrides when non-zero). /// public int ExpressionArgIndex { get; init; } - - /// Owner marker for all built-in core steps. - public const string CoreOwner = "core"; } diff --git a/SharpClaw.Contracts/Tasks/TaskStepResult.cs b/SharpClaw.Contracts/Tasks/TaskStepResult.cs new file mode 100644 index 00000000..d4874863 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskStepResult.cs @@ -0,0 +1,19 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Outcome of executing a single task step. Returned by +/// implementations to signal +/// whether the orchestrator should continue with the next sibling step +/// or unwind to the task entry point. +/// +public enum TaskStepResult +{ + /// Continue with the next sibling step. + Continue, + + /// + /// Unwind out of the current step list (and any nesting) up to the + /// task entry point. Used by the scripting return primitive. + /// + Return, +} diff --git a/SharpClaw.Contracts/Tasks/TaskTriggerAttributeContext.cs b/SharpClaw.Contracts/Tasks/TaskTriggerAttributeContext.cs new file mode 100644 index 00000000..f752a5c8 --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskTriggerAttributeContext.cs @@ -0,0 +1,86 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Read-only view of a single trigger attribute occurrence on a task class, +/// passed to . Exposes only +/// the values a handler needs to emit a +/// (positional and named argument access plus a diagnostic sink), so +/// modules implementing handlers do not depend on Roslyn or on the parser's +/// internal representation. +/// +public abstract class TaskTriggerAttributeContext +{ + /// + /// The attribute identifier as it appears in source, without the + /// surrounding brackets and without the trailing Attribute suffix + /// (e.g. "Schedule" for both [Schedule(...)] and + /// [ScheduleAttribute(...)]). + /// + public abstract string AttributeName { get; } + + /// 1-based source line of the attribute occurrence. + public abstract int Line { get; } + + /// Number of arguments supplied to the attribute. + public abstract int ArgumentCount { get; } + + /// + /// Returns the positional argument at as a string + /// literal value, or if the argument is missing or + /// is not a string literal. + /// + public abstract string? GetStringArg(int index); + + /// + /// Returns the positional argument at as an int + /// literal value, or if the argument is missing or + /// is not an int literal. + /// + public abstract int? GetIntArg(int index); + + /// + /// Returns the named argument as a string literal + /// value, or if absent or not a string literal. + /// + public abstract string? GetNamedStringArg(string name); + + /// + /// Returns the named argument as an int literal + /// value, or if absent or not an int literal. + /// + public abstract int? GetNamedIntArg(string name); + + /// + /// Returns the named argument as a double literal + /// value (also accepts float and int literals), or + /// if absent or not a numeric literal. + /// + public abstract double? GetNamedDoubleArg(string name); + + /// + /// Returns the named argument parsed as the + /// enum (case-insensitive), supporting both + /// member access (FlagsEnum.A) and bitwise-or composition + /// (FlagsEnum.A | FlagsEnum.B) for [Flags] enums. Returns + /// if the argument is absent or unparseable. + /// + public abstract T? GetNamedEnumArg(string name) where T : struct, Enum; + + /// + /// Returns the raw source text of the positional argument at + /// , exactly as written in the script (useful for + /// attributes whose argument may be a member access or composite + /// expression, e.g. [RequiresPlatform(TaskPlatform.Windows)]). + /// Returns if the argument is missing. + /// + public abstract string? GetRawArgText(int index); + + /// + /// Reports a diagnostic against this attribute occurrence. The parser + /// stamps the attribute's source line on the diagnostic. + /// + public abstract void Report( + TaskTriggerAttributeDiagnosticSeverity severity, + string code, + string message); +} diff --git a/SharpClaw.Contracts/Tasks/TaskTriggerAttributeDiagnosticSeverity.cs b/SharpClaw.Contracts/Tasks/TaskTriggerAttributeDiagnosticSeverity.cs new file mode 100644 index 00000000..a879880d --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskTriggerAttributeDiagnosticSeverity.cs @@ -0,0 +1,20 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Severity level for diagnostics emitted by an +/// while parsing a single +/// trigger attribute. +/// +/// +/// Defined here in SharpClaw.Contracts so module-owned attribute +/// handlers can emit diagnostics without taking a dependency on +/// SharpClaw.Application.Infrastructure.Tasks. The parser maps +/// these values onto its internal TaskDiagnosticSeverity when +/// recording handler-emitted diagnostics. +/// +public enum TaskTriggerAttributeDiagnosticSeverity +{ + Info, + Warning, + Error, +} diff --git a/SharpClaw.Contracts/Tasks/TaskTriggerBindingDescriptor.cs b/SharpClaw.Contracts/Tasks/TaskTriggerBindingDescriptor.cs new file mode 100644 index 00000000..ab6f707d --- /dev/null +++ b/SharpClaw.Contracts/Tasks/TaskTriggerBindingDescriptor.cs @@ -0,0 +1,18 @@ +namespace SharpClaw.Contracts.Tasks; + +/// +/// Lightweight, contract-layer view of a persisted trigger-binding row +/// passed to implementations. +/// Mirrors the host's TaskTriggerBindingDB shape but lives in the +/// contracts assembly so modules can react to binding lifecycle events +/// without referencing the host infrastructure project. +/// +/// Owning task definition identity. +/// Persisted trigger kind (e.g. "OsShortcut"). +/// Primary discriminator value; may be . +/// Secondary discriminator value; may be . +public sealed record TaskTriggerBindingDescriptor( + Guid TaskDefinitionId, + string Kind, + string? TriggerValue, + string? Filter); diff --git a/SharpClaw.Contracts/Tasks/TaskTriggerDefinition.cs b/SharpClaw.Contracts/Tasks/TaskTriggerDefinition.cs index b04cbd91..43fad90c 100644 --- a/SharpClaw.Contracts/Tasks/TaskTriggerDefinition.cs +++ b/SharpClaw.Contracts/Tasks/TaskTriggerDefinition.cs @@ -14,74 +14,16 @@ public sealed record TaskTriggerDefinition /// public string? TriggerKey { get; init; } - // ── Cron ────────────────────────────────────────────────────── - public string? CronExpression { get; init; } - public string? CronTimezone { get; init; } - - // ── Event ───────────────────────────────────────────────────── - /// SharpClawEventType flag name(s) as a comma-separated string. - public string? EventType { get; init; } - public string? EventFilter { get; init; } - - // ── FileChanged ─────────────────────────────────────────────── - public string? WatchPath { get; init; } - public string? FilePattern { get; init; } - public FileWatchEvent FileEvents { get; init; } - - // ── Process ─────────────────────────────────────────────────── - public string? ProcessName { get; init; } - - // ── Webhook ─────────────────────────────────────────────────── - public string? WebhookRoute { get; init; } - public string? WebhookSecretEnvVar { get; init; } - public string? WebhookSignatureHeader { get; init; } - - // ── Host probe ──────────────────────────────────────────────── - public string? HostName { get; init; } - public int? HostPort { get; init; } - - // ── Task chaining ───────────────────────────────────────────── - public string? SourceTaskName { get; init; } - - // ── Hotkey ──────────────────────────────────────────────────── - public string? HotkeyCombo { get; init; } - - // ── Idle ───────────────────────────────────────────────────── - public int? IdleMinutes { get; init; } - - // ── Network ─────────────────────────────────────────────────── - public string? NetworkSsid { get; init; } - public NetworkState NetworkState { get; init; } - - // ── Device ─────────────────────────────────────────────────── - public string? DeviceClass { get; init; } - public string? DeviceNamePattern { get; init; } - - // ── Query rows ─────────────────────────────────────────────── - public string? SqlQuery { get; init; } - public int? QueryPollIntervalSecs { get; init; } - - // ── Metric threshold ───────────────────────────────────────── - public string? MetricSource { get; init; } - public double? MetricThreshold { get; init; } - public ThresholdDirection MetricDirection { get; init; } - public int? MetricPollIntervalSecs { get; init; } - - // ── OS shortcut ─────────────────────────────────────────────── - public string? ShortcutLabel { get; init; } - public string? ShortcutIcon { get; init; } - public string? ShortcutCategory { get; init; } + /// Source line number in the task script for diagnostic purposes. + public int Line { get; init; } - // ── Custom source ───────────────────────────────────────────── /// - /// Kept for serialisation compatibility. Superseded by . + /// Opaque parameter map populated by the parser. Each owning + /// reads its inputs from this dictionary + /// using parameter-key constants owned by the module that owns the trigger. + /// Keys are case-sensitive; values must remain wire-compatible across + /// releases so persisted task scripts continue to round-trip. /// - public string? CustomSourceName { get; init; } - public string? CustomSourceFilter { get; init; } - - // ── Cross-cutting ───────────────────────────────────────────── - public TriggerConcurrency Concurrency { get; init; } = TriggerConcurrency.SkipIfRunning; - - /// Source line number in the task script for diagnostic purposes. - public int Line { get; init; } + public IReadOnlyDictionary Parameters { get; init; } = + new Dictionary(StringComparer.Ordinal); } diff --git a/SharpClaw.Contracts/Tasks/TaskTriggerKind.cs b/SharpClaw.Contracts/Tasks/TaskTriggerKind.cs deleted file mode 100644 index b25c5a0a..00000000 --- a/SharpClaw.Contracts/Tasks/TaskTriggerKind.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Events that can trigger a task event-handler callback. -/// -public enum TaskTriggerKind -{ - /// - /// A trigger owned by a module. The module trigger key is stored - /// alongside this discriminator in TaskStepDefinition.ModuleTriggerKey - /// and in ITaskEventHandler.ModuleTriggerKey. - /// - ModuleEvent, - - /// Fired on a periodic timer interval. - Timer, - - /// Fired once when the task starts executing. - TaskStarted, - - /// Fired when the task is being stopped / cancelled. - TaskStopping, -} diff --git a/SharpClaw.Contracts/Tasks/TriggerConcurrency.cs b/SharpClaw.Contracts/Tasks/TriggerConcurrency.cs deleted file mode 100644 index de578796..00000000 --- a/SharpClaw.Contracts/Tasks/TriggerConcurrency.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Controls how concurrent firings of the same trigger are handled. -/// -public enum TriggerConcurrency -{ - /// Skip this firing if the task is already running. Default. - SkipIfRunning, - /// Queue the firing; run after the current execution completes. - Queue, - /// Allow multiple simultaneous executions of the same task. - Parallel, - /// Cancel the running execution and start a new one immediately. - CancelAndReplace, -} diff --git a/SharpClaw.Contracts/Tasks/WellKnownTaskStepKeys.cs b/SharpClaw.Contracts/Tasks/WellKnownTaskStepKeys.cs deleted file mode 100644 index 2bdc7061..00000000 --- a/SharpClaw.Contracts/Tasks/WellKnownTaskStepKeys.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Stable well-known string keys for all core task step operations. -/// These constants are the authoritative discriminators in -/// for steps owned by the core. -/// Module authors should use namespaced keys of the form -/// moduleid.step_name (e.g. sharpclaw.transcription.start_transcription). -/// -public static class WellKnownTaskStepKeys -{ - // ── Agent interaction ────────────────────────────────────────────────────── - - /// Send a message to an agent and await the full response. - public const string Chat = "core.chat"; - - /// Send a message to an agent and stream the response. - public const string ChatStream = "core.chat_stream"; - - // ── Output ──────────────────────────────────────────────────────────────── - - /// Push a result object to SSE / WebSocket listeners. - public const string Emit = "core.emit"; - - /// Parse an agent text response into a typed data object. - public const string ParseResponse = "core.parse_response"; - - // ── State ───────────────────────────────────────────────────────────────── - - /// Declare a local variable, optionally with an initializer. - public const string DeclareVariable = "core.declare_variable"; - - /// Assign a value to an existing variable or property. - public const string Assign = "core.assign"; - - // ── Control flow ────────────────────────────────────────────────────────── - - /// Register a callback for an event trigger. - public const string EventHandler = "core.event_handler"; - - /// Conditional if/else branch. - public const string Conditional = "core.conditional"; - - /// Loop (while or foreach). - public const string Loop = "core.loop"; - - /// Await a fixed delay. - public const string Delay = "core.delay"; - - /// Block until the task is cancelled externally. - public const string WaitUntilStopped = "core.wait_until_stopped"; - - /// Return / exit from the task entry point. - public const string Return = "core.return"; - - // ── HTTP ────────────────────────────────────────────────────────────────── - - /// Make an HTTP request. - public const string HttpRequest = "core.http_request"; - - // ── Evaluation ──────────────────────────────────────────────────────────── - - /// Evaluate a restricted C# expression. - public const string Evaluate = "core.evaluate"; - - // ── Logging ─────────────────────────────────────────────────────────────── - - /// Write a log message. - public const string Log = "core.log"; - - // ── Entity lookup / creation ────────────────────────────────────────────── - - /// Find a model by name or custom ID. - public const string FindModel = "core.find_model"; - - /// Find a provider by name or custom ID. - public const string FindProvider = "core.find_provider"; - - /// Find an agent by name or custom ID. - public const string FindAgent = "core.find_agent"; - - /// Create a new agent. - public const string CreateAgent = "core.create_agent"; - - /// Create a new thread in a channel. - public const string CreateThread = "core.create_thread"; - - /// Send a chat message into a specific thread. - public const string ChatToThread = "core.chat_to_thread"; - - // ── Role / permission / channel provisioning ────────────────────────────── - - /// Create a new role (upsert by name). - public const string CreateRole = "core.create_role"; - - /// Find a role by name or custom ID. - public const string FindRole = "core.find_role"; - - /// Set the permission flags on an existing role. - public const string SetRolePermissions = "core.set_role_permissions"; - - /// Assign a role to an agent. - public const string AssignRole = "core.assign_role"; - - /// Create a new channel (upsert by custom ID). - public const string CreateChannel = "core.create_channel"; - - /// Find a channel by title or custom ID. - public const string FindChannel = "core.find_channel"; - - /// Add an agent to a channel's allowed agents list (idempotent). - public const string AddAllowedAgent = "core.add_allowed_agent"; -} diff --git a/SharpClaw.Contracts/Tasks/WellKnownTriggerKeys.cs b/SharpClaw.Contracts/Tasks/WellKnownTriggerKeys.cs deleted file mode 100644 index 988ba414..00000000 --- a/SharpClaw.Contracts/Tasks/WellKnownTriggerKeys.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace SharpClaw.Contracts.Tasks; - -/// -/// Well-known string keys for the trigger sources built into the core. -/// Stored as-is in TaskTriggerBindingDB.Kind and in -/// . -/// -public static class WellKnownTriggerKeys -{ - public const string Cron = "Cron"; - public const string Event = "Event"; - public const string FileChanged = "FileChanged"; - public const string Webhook = "Webhook"; - public const string HostReachable = "HostReachable"; - public const string HostUnreachable = "HostUnreachable"; - public const string TaskCompleted = "TaskCompleted"; - public const string TaskFailed = "TaskFailed"; - public const string NetworkChanged = "NetworkChanged"; - public const string MetricThreshold = "MetricThreshold"; - public const string Startup = "Startup"; - public const string Shutdown = "Shutdown"; -} diff --git a/SharpClaw.Contracts/Providers/WellKnownProviderKeys.cs b/SharpClaw.Providers.Common/WellKnownProviderKeys.cs similarity index 97% rename from SharpClaw.Contracts/Providers/WellKnownProviderKeys.cs rename to SharpClaw.Providers.Common/WellKnownProviderKeys.cs index a721e630..f16a4505 100644 --- a/SharpClaw.Contracts/Providers/WellKnownProviderKeys.cs +++ b/SharpClaw.Providers.Common/WellKnownProviderKeys.cs @@ -1,4 +1,4 @@ -namespace SharpClaw.Contracts.Providers; +namespace SharpClaw.Providers.Common; /// /// Well-known string keys that identify built-in provider types. diff --git a/SharpClaw.Tests/Cli/ChannelCliCommandTests.cs b/SharpClaw.Tests/Cli/ChannelCliCommandTests.cs index c9284ede..9c923d92 100644 --- a/SharpClaw.Tests/Cli/ChannelCliCommandTests.cs +++ b/SharpClaw.Tests/Cli/ChannelCliCommandTests.cs @@ -5,12 +5,13 @@ using System.Text.Json; using SharpClaw.Application.API.Cli; using SharpClaw.Application.Core.Modules; -using SharpClaw.Application.Infrastructure.Models; -using SharpClaw.Application.Infrastructure.Models.Context; +using SharpClaw.Contracts.Entities.Core; +using SharpClaw.Contracts.Entities.Core.Context; using SharpClaw.Application.Services; using SharpClaw.Contracts.Modules; using SharpClaw.Contracts.Providers; -using SharpClaw.Infrastructure.Models; +using SharpClaw.Providers.Common; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Infrastructure.Persistence; namespace SharpClaw.Tests.Cli; diff --git a/SharpClaw.Tests/Persistence/ColdEntityIndexTests.cs b/SharpClaw.Tests/Persistence/ColdEntityIndexTests.cs index b6ca650a..1fdafe45 100644 --- a/SharpClaw.Tests/Persistence/ColdEntityIndexTests.cs +++ b/SharpClaw.Tests/Persistence/ColdEntityIndexTests.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Infrastructure.Persistence.JSON; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Tests/Persistence/ColdEntityStoreTests.cs b/SharpClaw.Tests/Persistence/ColdEntityStoreTests.cs index 1d6ee082..44d573c9 100644 --- a/SharpClaw.Tests/Persistence/ColdEntityStoreTests.cs +++ b/SharpClaw.Tests/Persistence/ColdEntityStoreTests.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts.Persistence; using SharpClaw.Infrastructure.Persistence.JSON; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Tests/Persistence/ColdIndexMaintenanceServiceTests.cs b/SharpClaw.Tests/Persistence/ColdIndexMaintenanceServiceTests.cs index 2431d72d..8f7992c1 100644 --- a/SharpClaw.Tests/Persistence/ColdIndexMaintenanceServiceTests.cs +++ b/SharpClaw.Tests/Persistence/ColdIndexMaintenanceServiceTests.cs @@ -47,7 +47,7 @@ public async Task RebuildAsync_RebuildsIndexForColdEntityDir() var entityDir = _fs.CombinePath(_options.DataDirectory, "ChatMessageDB"); _fs.CreateDirectory(entityDir); - var msg = new SharpClaw.Application.Infrastructure.Models.Messages.ChatMessageDB + var msg = new SharpClaw.Contracts.Entities.Core.Messages.ChatMessageDB { Id = Guid.NewGuid(), ChannelId = Guid.NewGuid(), @@ -111,7 +111,7 @@ public async Task RebuildAsync_LogsWarning_WhenSlow() // Create many entity files to make rebuild take time for (var i = 0; i < 5; i++) { - var msg = new SharpClaw.Application.Infrastructure.Models.Messages.ChatMessageDB + var msg = new SharpClaw.Contracts.Entities.Core.Messages.ChatMessageDB { Id = Guid.NewGuid(), ChannelId = Guid.NewGuid(), diff --git a/SharpClaw.Tests/Persistence/JsonFilePersistenceServiceTests.cs b/SharpClaw.Tests/Persistence/JsonFilePersistenceServiceTests.cs index c282d93a..1c0a5c37 100644 --- a/SharpClaw.Tests/Persistence/JsonFilePersistenceServiceTests.cs +++ b/SharpClaw.Tests/Persistence/JsonFilePersistenceServiceTests.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; -using SharpClaw.Application.Infrastructure.Models; +using SharpClaw.Contracts.Entities.Core; using SharpClaw.Contracts.Persistence; using SharpClaw.Infrastructure.Persistence; using SharpClaw.Infrastructure.Persistence.JSON; diff --git a/SharpClaw.Tests/Persistence/PersistenceEntityResolverTests.cs b/SharpClaw.Tests/Persistence/PersistenceEntityResolverTests.cs index a7f53feb..94e35a43 100644 --- a/SharpClaw.Tests/Persistence/PersistenceEntityResolverTests.cs +++ b/SharpClaw.Tests/Persistence/PersistenceEntityResolverTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Messages; -using SharpClaw.Application.Infrastructure.Models.Tasks; +using SharpClaw.Contracts.Entities.Core.Jobs; +using SharpClaw.Contracts.Entities.Core.Messages; +using SharpClaw.Contracts.Entities.Core.Tasks; using SharpClaw.Contracts.Entities; using SharpClaw.Contracts.Enums; using SharpClaw.Contracts.Persistence; diff --git a/SharpClaw.Tests/Persistence/QuarantineServiceTests.cs b/SharpClaw.Tests/Persistence/QuarantineServiceTests.cs index 017d6d50..3c39d96f 100644 --- a/SharpClaw.Tests/Persistence/QuarantineServiceTests.cs +++ b/SharpClaw.Tests/Persistence/QuarantineServiceTests.cs @@ -2,7 +2,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using SharpClaw.Application.Infrastructure.Models.Messages; +using SharpClaw.Contracts.Entities.Core.Messages; using SharpClaw.Contracts.Persistence; using SharpClaw.Infrastructure.Persistence.JSON; using SharpClaw.Utils.Security; diff --git a/SharpClaw.Tests/Providers/LlamaSharp/LlamaSharpParameterParityTests.cs b/SharpClaw.Tests/Providers/LlamaSharp/LlamaSharpParameterParityTests.cs index 2065f1d0..b34e7306 100644 --- a/SharpClaw.Tests/Providers/LlamaSharp/LlamaSharpParameterParityTests.cs +++ b/SharpClaw.Tests/Providers/LlamaSharp/LlamaSharpParameterParityTests.cs @@ -4,6 +4,7 @@ using SharpClaw.Modules.Providers.LlamaSharp.LocalInference; using SharpClaw.Application.Services; using SharpClaw.Contracts.Providers; +using SharpClaw.Providers.Common; namespace SharpClaw.Tests.Providers.LlamaSharp; diff --git a/SharpClaw.Tests/SharpClaw.Tests.csproj b/SharpClaw.Tests/SharpClaw.Tests.csproj index 8cb561e4..3ab107c6 100644 --- a/SharpClaw.Tests/SharpClaw.Tests.csproj +++ b/SharpClaw.Tests/SharpClaw.Tests.csproj @@ -27,7 +27,15 @@ + + + + + + + + diff --git a/SharpClaw.Tests/TaskStepRegistrySetup.cs b/SharpClaw.Tests/TaskStepRegistrySetup.cs new file mode 100644 index 00000000..b0826db0 --- /dev/null +++ b/SharpClaw.Tests/TaskStepRegistrySetup.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; + +using SharpClaw.Application.Infrastructure.Tasks.Parsing; +using SharpClaw.Application.Infrastructure.Tasks.Registry; +using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; +using SharpClaw.Modules.ComputerUse; +using SharpClaw.Modules.DatabaseAccess; +using SharpClaw.Modules.FilesystemTriggers; +using SharpClaw.Modules.Http; +using SharpClaw.Modules.Metrics; +using SharpClaw.Modules.NetworkTriggers; + +namespace SharpClaw.Tests; + +/// +/// Global one-time test setup that seeds +/// with the descriptor providers contributed by the default modules and +/// registers the default-module parser extensions with +/// . The production host performs both during +/// startup; tests bypass the host entirely, so we replicate that here. +/// +[SetUpFixture] +public sealed class TaskStepRegistrySetup +{ + [OneTimeSetUp] + public void SeedRegistry() + { + ITaskStepDescriptorProvider[] providers = + [ + new TaskScriptingStepDescriptorProvider(), + new AgentOrchestrationStepDescriptorProvider(), + new HttpStepDescriptorProvider(), + ]; + + foreach (var provider in providers) + { + foreach (var descriptor in provider.Descriptors) + TaskStepRegistry.Default.Register(descriptor); + } + + TaskScriptParser.RegisterModule(TaskScriptingParserExtension.Instance); + TaskScriptParser.RegisterModule(ComputerUseParserExtension.Instance); + TaskScriptParser.RegisterModule(FilesystemTriggersParserExtension.Instance); + TaskScriptParser.RegisterModule(HttpParserExtension.Instance); + TaskScriptParser.RegisterModule(MetricsParserExtension.Instance); + TaskScriptParser.RegisterModule(NetworkTriggersParserExtension.Instance); + TaskScriptParser.RegisterModule(DatabaseAccessParserExtension.Instance); + } +} diff --git a/SharpClaw.Tests/Tasks/CronSchedulerTests.cs b/SharpClaw.Tests/Tasks/CronSchedulerTests.cs deleted file mode 100644 index 08dc8011..00000000 --- a/SharpClaw.Tests/Tasks/CronSchedulerTests.cs +++ /dev/null @@ -1,441 +0,0 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Infrastructure.Models.Tasks; -using SharpClaw.Application.Services; -using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Persistence; - -namespace SharpClaw.Tests.Tasks; - -/// -/// Tests for Phase 5: and the cron-aware scheduling -/// logic inside . -/// -[TestFixture] -public class CronSchedulerTests -{ - // ───────────────────────────────────────────────────────────── - // CronEvaluator — parsing - // ───────────────────────────────────────────────────────────── - - [Test] - public void TryParse_ValidFiveFieldExpression_ReturnsTrue() - { - var ok = CronEvaluator.TryParse("0 * * * *", out var error); - - ok.Should().BeTrue(); - error.Should().BeNull(); - } - - [Test] - public void TryParse_ValidSixFieldExpression_ReturnsTrue() - { - var ok = CronEvaluator.TryParse("0 0 * * * *", out var error); - - ok.Should().BeTrue(); - error.Should().BeNull(); - } - - [Test] - public void TryParse_InvalidExpression_ReturnsFalseWithErrorMessage() - { - var ok = CronEvaluator.TryParse("not a cron", out var error); - - ok.Should().BeFalse(); - error.Should().NotBeNullOrWhiteSpace(); - } - - [Test] - public void TryParse_EmptyExpression_ReturnsFalse() - { - var ok = CronEvaluator.TryParse("", out var error); - - ok.Should().BeFalse(); - error.Should().NotBeNullOrWhiteSpace(); - } - - // ───────────────────────────────────────────────────────────── - // CronEvaluator — next occurrence - // ───────────────────────────────────────────────────────────── - - [Test] - public void GetNextOccurrence_EveryMinute_ReturnsNextWholeMinute() - { - // "* * * * *" fires at the top of every minute. - var after = new DateTimeOffset(2025, 1, 1, 12, 0, 30, TimeSpan.Zero); - - var next = CronEvaluator.GetNextOccurrence("* * * * *", after); - - next.Should().NotBeNull(); - next!.Value.Should().Be(new DateTimeOffset(2025, 1, 1, 12, 1, 0, TimeSpan.Zero)); - } - - [Test] - public void GetNextOccurrence_ExpressionThatNeverFiresAgain_ReturnsNull() - { - // Fire only in January 2020 — already past. - var after = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - // "0 0 1 1 *" = midnight on 1 Jan; occurs every year, so use a year-2020-only trick via - // a past-only range — instead just verify a concrete never-again scenario isn't possible - // with standard cron. Use a far-future cutoff approach: fire once per year in Jan, - // and verify next occurrence IS returned (not null) so we also test the positive case. - var next = CronEvaluator.GetNextOccurrence("0 0 1 1 *", after); - - next.Should().NotBeNull("the expression fires every year in January"); - next!.Value.Month.Should().Be(1); - next.Value.Day.Should().Be(1); - } - - [Test] - public void GetNextOccurrences_ReturnsRequestedCount() - { - var after = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - - var occurrences = CronEvaluator.GetNextOccurrences("0 * * * *", after, count: 5).ToList(); - - occurrences.Should().HaveCount(5); - // Occurrences should be strictly monotonically increasing. - for (var i = 1; i < occurrences.Count; i++) - occurrences[i].Should().BeAfter(occurrences[i - 1]); - } - - [Test] - public void GetNextOccurrences_DefaultCount_Returns10() - { - var after = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - - var occurrences = CronEvaluator.GetNextOccurrences("0 * * * *", after).ToList(); - - occurrences.Should().HaveCount(10); - } - - [Test] - public void GetNextOccurrence_WithTimezone_AdjustsForOffset() - { - // "0 9 * * *" = 09:00 in the given zone. - // UTC+2: 09:00 local = 07:00 UTC. - var after = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero); // UTC midnight - - var next = CronEvaluator.GetNextOccurrence("0 9 * * *", after, "Europe/Paris"); - - next.Should().NotBeNull(); - // In CEST (UTC+2) on 2025-06-01, 09:00 local = 07:00 UTC. - next!.Value.ToUniversalTime().Hour.Should().Be(7); - } - - // ───────────────────────────────────────────────────────────── - // ScheduledTaskService — ProcessDueTasksAsync integration - // ───────────────────────────────────────────────────────────── - - private SharpClawDbContext CreateDb() => - new(new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options); - - private static IConfiguration EmptyConfig() => - new ConfigurationBuilder().Build(); - - private static IConfiguration ConfigWith(string key, string value) => - new ConfigurationBuilder() - .AddInMemoryCollection([new(key, value)]) - .Build(); - - private static ScheduledTaskService BuildService( - SharpClawDbContext db, IConfiguration? config = null) - { - var services = new ServiceCollection(); - services.AddSingleton(db); - services.AddScoped(sp => - { - var coldStore = sp.GetRequiredService(); - // TaskService isn't exercised in these tests; just satisfy DI. - return null!; - }); - // Use a minimal scope factory backed by the raw db. - var scopeFactory = new DirectScopeFactory(db); - return new ScheduledTaskService( - scopeFactory, - config ?? EmptyConfig(), - NullLogger.Instance); - } - - [Test] - public async Task ProcessDueTasks_CronJob_AdvancesNextRunAtViaCronEvaluator() - { - using var db = CreateDb(); - - // "* * * * *" fires every minute — next occurrence is always in the future. - var now = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero); - var job = new ScheduledJobDB - { - Name = "cron-job", - NextRunAt = now.AddMinutes(-1), // due - CronExpression = "* * * * *", - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var scopeFactory = new DirectScopeFactory(db); - var svc = new ScheduledTaskService( - scopeFactory, EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - updated!.Status.Should().Be(ScheduledTaskStatus.Pending, - "a cron job with future occurrences should remain Pending"); - updated.NextRunAt.Should().BeAfter(now, - "NextRunAt must be advanced to the next cron occurrence"); - } - - [Test] - public async Task ProcessDueTasks_RepeatIntervalJob_AdvancesNextRunAt() - { - using var db = CreateDb(); - - var now = DateTimeOffset.UtcNow; - var interval = TimeSpan.FromHours(1); - var job = new ScheduledJobDB - { - Name = "repeat-job", - NextRunAt = now.AddMinutes(-1), - RepeatInterval = interval, - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - updated!.Status.Should().Be(ScheduledTaskStatus.Pending); - updated.NextRunAt.Should().BeCloseTo(now.Add(interval), TimeSpan.FromSeconds(5)); - } - - [Test] - public async Task ProcessDueTasks_OneShotJob_IsCompletedAndNotRescheduled() - { - using var db = CreateDb(); - - var now = DateTimeOffset.UtcNow; - var originalNextRun = now.AddMinutes(-1); - var job = new ScheduledJobDB - { - Name = "one-shot", - NextRunAt = originalNextRun, - // No RepeatInterval, no CronExpression → one-shot - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - updated!.Status.Should().Be(ScheduledTaskStatus.Completed); - updated.NextRunAt.Should().Be(originalNextRun, - "a one-shot job's NextRunAt should not be moved"); - } - - [Test] - public async Task ProcessDueTasks_PausedJob_IsNotProcessed() - { - using var db = CreateDb(); - - var now = DateTimeOffset.UtcNow; - var job = new ScheduledJobDB - { - Name = "paused-job", - NextRunAt = now.AddMinutes(-5), // overdue but paused - RepeatInterval = TimeSpan.FromMinutes(10), - Status = ScheduledTaskStatus.Paused - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - updated!.Status.Should().Be(ScheduledTaskStatus.Paused, - "a paused job must never be picked up by the scheduler"); - } - - [Test] - public async Task ProcessDueTasks_MissedFireSkip_AdvancesWithoutFiring() - { - using var db = CreateDb(); - - // Job was due 2 hours ago — well beyond the 60-minute threshold. - var now = DateTimeOffset.UtcNow; - var job = new ScheduledJobDB - { - Name = "missed-skip-job", - NextRunAt = now.AddHours(-2), - RepeatInterval = TimeSpan.FromHours(3), - MissedFirePolicy = MissedFirePolicy.Skip, - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var originalNextRunAt = job.NextRunAt; - - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - // The job should have been skipped: advanced but with Completed status - // because there is no second RepeatInterval advancement after a Skip. - // What we care about is that it was NOT dispatched (no TaskInstance created) - // and NextRunAt was advanced. - updated!.NextRunAt.Should().BeAfter(originalNextRunAt, - "NextRunAt must be advanced past the missed window"); - } - - [Test] - public async Task ProcessDueTasks_MissedFireFireOnce_ExecutesNormally() - { - using var db = CreateDb(); - - // Job was due 2 hours ago but policy is FireOnceAndRecompute (default). - var now = DateTimeOffset.UtcNow; - var job = new ScheduledJobDB - { - Name = "missed-fire-once", - NextRunAt = now.AddHours(-2), - RepeatInterval = TimeSpan.FromHours(3), - MissedFirePolicy = MissedFirePolicy.FireOnceAndRecompute, - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), EmptyConfig(), - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - // FireOnceAndRecompute: job should be rescheduled (Pending) with advanced NextRunAt. - updated!.Status.Should().Be(ScheduledTaskStatus.Pending); - updated.NextRunAt.Should().BeAfter(now); - } - - [Test] - public async Task ProcessDueTasks_CustomMissedFireThreshold_IsRespected() - { - using var db = CreateDb(); - - // Threshold is 5 minutes. Job is 10 minutes late → should be skipped. - var now = DateTimeOffset.UtcNow; - var job = new ScheduledJobDB - { - Name = "threshold-job", - NextRunAt = now.AddMinutes(-10), - RepeatInterval = TimeSpan.FromHours(1), - MissedFirePolicy = MissedFirePolicy.Skip, - Status = ScheduledTaskStatus.Pending - }; - db.ScheduledTasks.Add(job); - await db.SaveChangesAsync(); - - var originalNextRunAt = job.NextRunAt; - - var config = ConfigWith("Scheduler:MissedFireThresholdMinutes", "5"); - var svc = new ScheduledTaskService( - new DirectScopeFactory(db), config, - NullLogger.Instance); - - await InvokeProcessDueTasks(svc, CancellationToken.None); - - var updated = await db.ScheduledTasks.FindAsync(job.Id); - updated!.NextRunAt.Should().BeAfter(originalNextRunAt); - } - - // ───────────────────────────────────────────────────────────── - // MissedFirePolicy and ScheduledTaskStatus enum smoke-tests - // ───────────────────────────────────────────────────────────── - - [Test] - public void MissedFirePolicy_HasExpectedVariants() - { - Enum.GetNames().Should().Contain([ - nameof(MissedFirePolicy.FireOnceAndRecompute), - nameof(MissedFirePolicy.Skip) - ]); - } - - [Test] - public void ScheduledTaskStatus_HasPausedVariant() - { - Enum.GetNames().Should().Contain(nameof(ScheduledTaskStatus.Paused)); - } - - // ───────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────── - - /// - /// Invoke the private ProcessDueTasksAsync via reflection so tests - /// can drive the scheduling logic without starting the full background loop. - /// - private static async Task InvokeProcessDueTasks( - ScheduledTaskService svc, CancellationToken ct) - { - var method = typeof(ScheduledTaskService) - .GetMethod("ProcessDueTasksAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?? throw new InvalidOperationException("ProcessDueTasksAsync not found via reflection."); - - await (Task)method.Invoke(svc, [ct])!; - } -} - -/// -/// Minimal that resolves a single -/// instance for use in unit tests. -/// -file sealed class DirectScopeFactory(SharpClawDbContext db) : IServiceScopeFactory -{ - public IServiceScope CreateScope() => new DirectScope(db); - - private sealed class DirectScope(SharpClawDbContext db) : IServiceScope - { - public IServiceProvider ServiceProvider { get; } = new DirectServiceProvider(db); - public void Dispose() { } - } - - private sealed class DirectServiceProvider(SharpClawDbContext db) : IServiceProvider - { - public object? GetService(Type serviceType) - { - if (serviceType == typeof(SharpClawDbContext)) return db; - if (serviceType == typeof(TaskService)) return null; // not needed for cron-only tests - if (serviceType == typeof(TaskOrchestrator)) return null; - return null; - } - } -} diff --git a/SharpClaw.Tests/Tasks/ScheduledJobCrudTests.cs b/SharpClaw.Tests/Tasks/ScheduledJobCrudTests.cs deleted file mode 100644 index eb540372..00000000 --- a/SharpClaw.Tests/Tasks/ScheduledJobCrudTests.cs +++ /dev/null @@ -1,369 +0,0 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using NUnit.Framework; -using SharpClaw.Application.Infrastructure.Models.Jobs; -using SharpClaw.Application.Services; -using SharpClaw.Contracts.DTOs.Tasks; -using SharpClaw.Contracts.Enums; -using SharpClaw.Infrastructure.Persistence; - -namespace SharpClaw.Tests.Tasks; - -/// -/// Tests for Phase 6: CRUD, validation, -/// pause/resume, and cron preview. -/// -[TestFixture] -public class ScheduledJobCrudTests -{ - private SharpClawDbContext _db = null!; - private ScheduledJobService _svc = null!; - - [SetUp] - public void SetUp() - { - var opts = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - _db = new SharpClawDbContext(opts); - _svc = new ScheduledJobService(_db, NullLogger.Instance); - } - - [TearDown] - public void TearDown() => _db.Dispose(); - - // ───────────────────────────────────────────────────────────── - // Validation helper (static) — SCHED001-004 - // ───────────────────────────────────────────────────────────── - - [Test] - public void ValidateCronFields_BothCronAndInterval_ThrowsSCHED001() - { - var act = () => ScheduledJobService.ValidateCronFields( - "0 * * * *", null, TimeSpan.FromHours(1), null); - - act.Should().Throw() - .WithMessage($"*{ScheduledJobService.ErrBothSchedules}*"); - } - - [Test] - public void ValidateCronFields_InvalidCronExpression_ThrowsSCHED002() - { - var act = () => ScheduledJobService.ValidateCronFields( - "not-a-cron", null, null, null); - - act.Should().Throw() - .WithMessage($"*{ScheduledJobService.ErrInvalidCron}*"); - } - - [Test] - public void ValidateCronFields_UnknownTimezone_ThrowsSCHED003() - { - var act = () => ScheduledJobService.ValidateCronFields( - "0 * * * *", "Invalid/Timezone", null, null); - - act.Should().Throw() - .WithMessage($"*{ScheduledJobService.ErrInvalidTz}*"); - } - - [Test] - public void ValidateCronFields_NeverFiringCron_ReturnsWarningAndAllowsCreation() - { - // Feb 30 never exists — expression will have no future occurrences - // Use a past-only expression: "0 0 30 2 *" (Feb 30 - never fires) - var (nextRunAt, warnings) = ScheduledJobService.ValidateCronFields( - "0 0 30 2 *", null, null, null); - - warnings.Should().ContainSingle(w => w.Contains(ScheduledJobService.WarnNeverFires)); - nextRunAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); - } - - [Test] - public void ValidateCronFields_ValidCron_AutoDerivesNextRunAt() - { - var (nextRunAt, warnings) = ScheduledJobService.ValidateCronFields( - "0 * * * *", null, null, null); - - warnings.Should().BeEmpty(); - nextRunAt.Should().BeAfter(DateTimeOffset.UtcNow); - } - - [Test] - public void ValidateCronFields_SuppliedNextRunAtIsPreserved_WhenCronValid() - { - var supplied = DateTimeOffset.UtcNow.AddDays(7); - - var (nextRunAt, _) = ScheduledJobService.ValidateCronFields( - "0 * * * *", null, null, supplied); - - nextRunAt.Should().Be(supplied); - } - - // ───────────────────────────────────────────────────────────── - // CRUD - // ───────────────────────────────────────────────────────────── - - [Test] - public async Task CreateAsync_WithCronExpression_PersistsAndAutoDerivesNextRunAt() - { - var req = new CreateScheduledJobRequest( - Name: "hourly", - CronExpression: "0 * * * *"); - - var result = await _svc.CreateAsync(req); - - result.Id.Should().NotBeEmpty(); - result.CronExpression.Should().Be("0 * * * *"); - result.NextRunAt.Should().BeAfter(DateTimeOffset.UtcNow); - result.Status.Should().Be(ScheduledTaskStatus.Pending); - } - - [Test] - public async Task CreateAsync_WithRepeatInterval_PersistsCorrectly() - { - var next = DateTimeOffset.UtcNow.AddHours(1); - var req = new CreateScheduledJobRequest( - Name: "interval-job", - NextRunAt: next, - RepeatInterval: TimeSpan.FromHours(2)); - - var result = await _svc.CreateAsync(req); - - result.RepeatInterval.Should().Be(TimeSpan.FromHours(2)); - result.NextRunAt.Should().BeCloseTo(next, TimeSpan.FromSeconds(1)); - } - - [Test] - public async Task CreateAsync_BothCronAndInterval_Throws() - { - var req = new CreateScheduledJobRequest( - Name: "bad", - CronExpression: "0 * * * *", - RepeatInterval: TimeSpan.FromHours(1)); - - var act = async () => await _svc.CreateAsync(req); - - await act.Should().ThrowAsync() - .WithMessage($"*{ScheduledJobService.ErrBothSchedules}*"); - } - - [Test] - public async Task GetByIdAsync_ExistingJob_ReturnsResponse() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("job1", CronExpression: "0 * * * *")); - - var fetched = await _svc.GetByIdAsync(created.Id); - - fetched.Should().NotBeNull(); - fetched!.Name.Should().Be("job1"); - } - - [Test] - public async Task GetByIdAsync_MissingId_ReturnsNull() - { - var result = await _svc.GetByIdAsync(Guid.NewGuid()); - result.Should().BeNull(); - } - - [Test] - public async Task ListAsync_ReturnsAllJobs_OrderedByNextRunAt() - { - var later = DateTimeOffset.UtcNow.AddHours(2); - var sooner = DateTimeOffset.UtcNow.AddHours(1); - - await _svc.CreateAsync(new CreateScheduledJobRequest("b", NextRunAt: later, - RepeatInterval: TimeSpan.FromHours(1))); - await _svc.CreateAsync(new CreateScheduledJobRequest("a", NextRunAt: sooner, - RepeatInterval: TimeSpan.FromHours(1))); - - var list = await _svc.ListAsync(); - - list.Should().HaveCount(2); - list[0].NextRunAt.Should().BeBefore(list[1].NextRunAt); - } - - [Test] - public async Task UpdateAsync_ChangesName_Persists() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("original", CronExpression: "0 * * * *")); - - var updated = await _svc.UpdateAsync(created.Id, - new UpdateScheduledJobRequest(Name: "renamed")); - - updated!.Name.Should().Be("renamed"); - } - - [Test] - public async Task UpdateAsync_MissingId_ReturnsNull() - { - var result = await _svc.UpdateAsync(Guid.NewGuid(), - new UpdateScheduledJobRequest(Name: "x")); - - result.Should().BeNull(); - } - - [Test] - public async Task UpdateAsync_BothCronAndInterval_Throws() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("job", CronExpression: "0 * * * *")); - - var act = async () => await _svc.UpdateAsync(created.Id, - new UpdateScheduledJobRequest( - CronExpression: "0 0 * * *", - RepeatInterval: TimeSpan.FromHours(1))); - - await act.Should().ThrowAsync() - .WithMessage($"*{ScheduledJobService.ErrBothSchedules}*"); - } - - [Test] - public async Task DeleteAsync_ExistingJob_RemovesFromDb() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("del-me", CronExpression: "0 * * * *")); - - var deleted = await _svc.DeleteAsync(created.Id); - var fetched = await _svc.GetByIdAsync(created.Id); - - deleted.Should().BeTrue(); - fetched.Should().BeNull(); - } - - [Test] - public async Task DeleteAsync_MissingId_ReturnsFalse() - { - var result = await _svc.DeleteAsync(Guid.NewGuid()); - result.Should().BeFalse(); - } - - // ───────────────────────────────────────────────────────────── - // Pause / Resume - // ───────────────────────────────────────────────────────────── - - [Test] - public async Task PauseAsync_PendingJob_SetsStatusToPaused() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("pauseable", CronExpression: "0 * * * *")); - - var paused = await _svc.PauseAsync(created.Id); - - paused!.Status.Should().Be(ScheduledTaskStatus.Paused); - } - - [Test] - public async Task ResumeAsync_PausedJob_SetsStatusToPending() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("resumeable", CronExpression: "0 * * * *")); - await _svc.PauseAsync(created.Id); - - var resumed = await _svc.ResumeAsync(created.Id); - - resumed!.Status.Should().Be(ScheduledTaskStatus.Pending); - } - - [Test] - public async Task ResumeAsync_PausedCronJob_RecomputesNextRunAt() - { - // Create with a NextRunAt far in the past to simulate it expiring while paused - var pastNext = DateTimeOffset.UtcNow.AddHours(-1); - var entity = new ScheduledJobDB - { - Name = "stale-cron", - CronExpression = "0 * * * *", - NextRunAt = pastNext, - Status = ScheduledTaskStatus.Paused, - }; - _db.ScheduledTasks.Add(entity); - await _db.SaveChangesAsync(); - - var resumed = await _svc.ResumeAsync(entity.Id); - - resumed!.Status.Should().Be(ScheduledTaskStatus.Pending); - resumed.NextRunAt.Should().BeAfter(DateTimeOffset.UtcNow); - } - - [Test] - public async Task PauseAsync_MissingId_ReturnsNull() - { - var result = await _svc.PauseAsync(Guid.NewGuid()); - result.Should().BeNull(); - } - - [Test] - public async Task ResumeAsync_MissingId_ReturnsNull() - { - var result = await _svc.ResumeAsync(Guid.NewGuid()); - result.Should().BeNull(); - } - - // ───────────────────────────────────────────────────────────── - // Preview — stateless - // ───────────────────────────────────────────────────────────── - - [Test] - public void PreviewExpression_ValidCron_ReturnsOccurrences() - { - var result = ScheduledJobService.PreviewExpression("0 * * * *", count: 5); - - result.Expression.Should().Be("0 * * * *"); - result.NextOccurrences.Should().HaveCount(5); - result.NextOccurrences.Should().BeInAscendingOrder(); - } - - [Test] - public void PreviewExpression_InvalidCron_Throws() - { - var act = () => ScheduledJobService.PreviewExpression("not-valid"); - - act.Should().Throw() - .WithMessage($"*{ScheduledJobService.ErrInvalidCron}*"); - } - - [Test] - public void PreviewExpression_InvalidTimezone_Throws() - { - var act = () => ScheduledJobService.PreviewExpression( - "0 * * * *", "Bad/Zone"); - - act.Should().Throw() - .WithMessage($"*{ScheduledJobService.ErrInvalidTz}*"); - } - - [Test] - public async Task PreviewJobAsync_CronJob_ReturnsOccurrences() - { - var created = await _svc.CreateAsync( - new CreateScheduledJobRequest("preview-job", CronExpression: "0 * * * *")); - - var preview = await _svc.PreviewJobAsync(created.Id, count: 3); - - preview.Should().NotBeNull(); - preview!.Expression.Should().Be("0 * * * *"); - preview.NextOccurrences.Should().HaveCount(3); - } - - [Test] - public async Task PreviewJobAsync_IntervalJobWithNoCron_ReturnsNull() - { - var created = await _svc.CreateAsync(new CreateScheduledJobRequest( - "interval", NextRunAt: DateTimeOffset.UtcNow.AddHours(1), - RepeatInterval: TimeSpan.FromHours(1))); - - var preview = await _svc.PreviewJobAsync(created.Id); - - preview.Should().BeNull(); - } - - [Test] - public async Task PreviewJobAsync_MissingId_ReturnsNull() - { - var result = await _svc.PreviewJobAsync(Guid.NewGuid()); - result.Should().BeNull(); - } -} diff --git a/SharpClaw.Tests/Tasks/TaskScriptCompilerTests.cs b/SharpClaw.Tests/Tasks/TaskScriptCompilerTests.cs index 8ccf137f..12954c99 100644 --- a/SharpClaw.Tests/Tasks/TaskScriptCompilerTests.cs +++ b/SharpClaw.Tests/Tasks/TaskScriptCompilerTests.cs @@ -4,6 +4,8 @@ using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.Http; +using SharpClaw.Modules.AgentOrchestration; namespace SharpClaw.Tests.Tasks; @@ -184,7 +186,7 @@ public void Compile_ReturnStepTerminatesStepList() var result = TaskScriptEngine.ProcessScript(SimpleSource); result.Success.Should().BeTrue(); - result.Plan!.ExecutionSteps.Last().StepKey.Should().Be(WellKnownTaskStepKeys.Return); + result.Plan!.ExecutionSteps.Last().StepKey.Should().Be(TaskScriptingStepKeys.Return); } [Test] @@ -211,9 +213,9 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.ProcessScript(source); result.Success.Should().BeTrue(); - var conditional = result.Plan!.ExecutionSteps.Single(s => s.StepKey == WellKnownTaskStepKeys.Conditional); - conditional.Body.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Log); - conditional.ElseBody.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Log); + var conditional = result.Plan!.ExecutionSteps.Single(s => s.StepKey == TaskScriptingStepKeys.Conditional); + conditional.Body.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Log); + conditional.ElseBody.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Log); } [Test] @@ -241,8 +243,8 @@ public async Task RunAsync(CancellationToken ct) }); result.Success.Should().BeTrue(); - var loop = result.Plan!.ExecutionSteps.Single(s => s.StepKey == WellKnownTaskStepKeys.Loop); - loop.LoopKind.Should().Be(TaskLoopKind.ForEach); + var loop = result.Plan!.ExecutionSteps.Single(s => s.StepKey == TaskScriptingStepKeys.Loop); + loop.VariableName.Should().NotBeNull(); } [Test] @@ -262,7 +264,9 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.ProcessScript(source); result.Success.Should().BeTrue(); - var httpStep = result.Plan!.ExecutionSteps.Single(s => s.StepKey == WellKnownTaskStepKeys.HttpRequest); - httpStep.HttpMethod.Should().Be("GET"); + var httpStep = result.Plan!.ExecutionSteps.Single(s => s.StepKey == HttpStepKeys.HttpRequest); + httpStep.Arguments.Should().NotBeNull(); + httpStep.Arguments![0].Should().Be("GET"); } } + diff --git a/SharpClaw.Tests/Tasks/TaskScriptParserTests.cs b/SharpClaw.Tests/Tasks/TaskScriptParserTests.cs index d621158b..9c8daa27 100644 --- a/SharpClaw.Tests/Tasks/TaskScriptParserTests.cs +++ b/SharpClaw.Tests/Tasks/TaskScriptParserTests.cs @@ -3,6 +3,8 @@ using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; +using SharpClaw.Modules.Http; namespace SharpClaw.Tests.Tasks; @@ -145,7 +147,7 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.Parse(source); result.Success.Should().BeTrue(); - result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Log); + result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Log); } [Test] @@ -167,7 +169,7 @@ public async Task RunAsync(CancellationToken ct) result.Success.Should().BeTrue(); result.Definition!.Steps.Should().HaveCount(2); - result.Definition.Steps.Last().StepKey.Should().Be(WellKnownTaskStepKeys.Return); + result.Definition.Steps.Last().StepKey.Should().Be(TaskScriptingStepKeys.Return); } [Test] @@ -190,7 +192,7 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.Parse(source); result.Success.Should().BeTrue(); - result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Conditional); + result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Conditional); } [Test] @@ -217,9 +219,9 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.Parse(source); result.Success.Should().BeTrue(); - var conditional = result.Definition!.Steps.Single(s => s.StepKey == WellKnownTaskStepKeys.Conditional); - conditional.Body.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Log); - conditional.ElseBody.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Log); + var conditional = result.Definition!.Steps.Single(s => s.StepKey == TaskScriptingStepKeys.Conditional); + conditional.Body.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Log); + conditional.ElseBody.Should().ContainSingle(s => s.StepKey == TaskScriptingStepKeys.Log); } [Test] @@ -239,7 +241,7 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.Parse(source); result.Success.Should().BeTrue(); - result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == WellKnownTaskStepKeys.Chat); + result.Definition!.Steps.Should().ContainSingle(s => s.StepKey == AgentOrchestrationStepKeys.Chat); } [Test] @@ -259,8 +261,9 @@ public async Task RunAsync(CancellationToken ct) var result = TaskScriptEngine.Parse(source); result.Success.Should().BeTrue(); - var step = result.Definition!.Steps.Single(s => s.StepKey == WellKnownTaskStepKeys.HttpRequest); - step.HttpMethod.Should().Be("GET"); + var step = result.Definition!.Steps.Single(s => s.StepKey == HttpStepKeys.HttpRequest); + step.Arguments.Should().NotBeNull(); + step.Arguments![0].Should().Be("GET"); step.Expression.Should().Contain("example.com"); } @@ -373,3 +376,4 @@ public async Task RunAsync(CancellationToken ct) { } result.Diagnostics.Should().Contain(d => d.Code == "TASK002"); } } + diff --git a/SharpClaw.Tests/Tasks/TaskScriptSemanticsTests.cs b/SharpClaw.Tests/Tasks/TaskScriptSemanticsTests.cs index 278b0695..83f25afc 100644 --- a/SharpClaw.Tests/Tasks/TaskScriptSemanticsTests.cs +++ b/SharpClaw.Tests/Tasks/TaskScriptSemanticsTests.cs @@ -2,6 +2,7 @@ using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; namespace SharpClaw.Tests.Tasks; @@ -35,8 +36,8 @@ public async Task RunAsync(CancellationToken ct) result.Success.Should().BeTrue(); result.Plan.Should().NotBeNull(); result.Plan!.ExecutionSteps.Should().ContainSingle(); - result.Plan.ExecutionSteps[0].StepKey.Should().Be(WellKnownTaskStepKeys.Loop); - result.Plan.ExecutionSteps[0].LoopKind.Should().Be(TaskLoopKind.ForEach); + result.Plan.ExecutionSteps[0].StepKey.Should().Be(TaskScriptingStepKeys.Loop); + result.Plan.ExecutionSteps[0].VariableName.Should().Be("item"); result.Plan.ParameterValues["Items"].Should().BeAssignableTo>(); } @@ -62,7 +63,7 @@ public async Task RunAsync(CancellationToken ct) result.Success.Should().BeTrue(); result.Plan.Should().NotBeNull(); result.Plan!.ExecutionSteps.Should().ContainSingle(); - result.Plan.ExecutionSteps[0].LoopKind.Should().Be(TaskLoopKind.While); + result.Plan.ExecutionSteps[0].VariableName.Should().BeNull(); } [Test] @@ -119,3 +120,4 @@ public async Task RunAsync(CancellationToken ct) result.Plan.ParameterValues["Enabled"].Should().Be(true); } } + diff --git a/SharpClaw.Tests/Tasks/TaskScriptValidatorTests.cs b/SharpClaw.Tests/Tasks/TaskScriptValidatorTests.cs index 938d92a7..892d6ff5 100644 --- a/SharpClaw.Tests/Tasks/TaskScriptValidatorTests.cs +++ b/SharpClaw.Tests/Tasks/TaskScriptValidatorTests.cs @@ -3,6 +3,7 @@ using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; namespace SharpClaw.Tests.Tasks; @@ -145,8 +146,8 @@ public void Validate_DuplicateVariableDeclaration_ProducesTASK104() ToolCallHooks = [], Steps = [ - new TaskStepDefinition { StepKey = WellKnownTaskStepKeys.DeclareVariable, Line = 1, Column = 0, VariableName = "x", TypeName = "string" }, - new TaskStepDefinition { StepKey = WellKnownTaskStepKeys.DeclareVariable, Line = 2, Column = 0, VariableName = "x", TypeName = "string" }, + new TaskStepDefinition { StepKey = TaskScriptingStepKeys.DeclareVariable, Line = 1, Column = 0, VariableName = "x", TypeName = "string" }, + new TaskStepDefinition { StepKey = TaskScriptingStepKeys.DeclareVariable, Line = 2, Column = 0, VariableName = "x", TypeName = "string" }, ] }; @@ -174,7 +175,7 @@ public void Validate_VariableWithUnknownType_ProducesTASK105() ToolCallHooks = [], Steps = [ - new TaskStepDefinition { StepKey = WellKnownTaskStepKeys.DeclareVariable, Line = 1, Column = 0, VariableName = "obj", TypeName = "WeirdType" }, + new TaskStepDefinition { StepKey = TaskScriptingStepKeys.DeclareVariable, Line = 1, Column = 0, VariableName = "obj", TypeName = "WeirdType" }, ] }; @@ -184,42 +185,6 @@ public void Validate_VariableWithUnknownType_ProducesTASK105() result.Diagnostics.Should().Contain(d => d.Code == "TASK105"); } - // ───────────────────────────────────────────────────────────── - // TASK106 — foreach loop missing iteration variable - // ───────────────────────────────────────────────────────────── - - [Test] - public void Validate_ForEachLoopWithNoIterationVariable_ProducesTASK106() - { - var definition = new TaskScriptDefinition - { - Name = "no-iter-var", - SourceText = "", - ClassName = "NoIterVarTask", - EntryPointMethod = "RunAsync", - Parameters = [], - DataTypes = [], - ToolCallHooks = [], - Steps = - [ - new TaskStepDefinition - { - StepKey = WellKnownTaskStepKeys.Loop, - Line = 1, Column = 0, - LoopKind = TaskLoopKind.ForEach, - VariableName = null, // missing - Expression = "items", - Body = [] - } - ] - }; - - var result = TaskScriptEngine.Validate(definition); - - result.IsValid.Should().BeFalse(); - result.Diagnostics.Should().Contain(d => d.Code == "TASK106"); - } - // ───────────────────────────────────────────────────────────── // TASK107 — foreach loop missing source expression // ───────────────────────────────────────────────────────────── @@ -240,9 +205,8 @@ public void Validate_ForEachLoopWithNoSourceExpression_ProducesTASK107() [ new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Loop, + StepKey = TaskScriptingStepKeys.Loop, Line = 1, Column = 0, - LoopKind = TaskLoopKind.ForEach, VariableName = "item", Expression = null, // missing Body = [] @@ -302,15 +266,14 @@ public void Validate_InvalidTypeInsideLoopBody_IsReported() [ new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.Loop, + StepKey = TaskScriptingStepKeys.Loop, Line = 1, Column = 0, - LoopKind = TaskLoopKind.While, Expression = "true", Body = [ new TaskStepDefinition { - StepKey = WellKnownTaskStepKeys.DeclareVariable, + StepKey = TaskScriptingStepKeys.DeclareVariable, Line = 2, Column = 4, VariableName = "v", TypeName = "UnknownNestedType" @@ -326,3 +289,4 @@ public void Validate_InvalidTypeInsideLoopBody_IsReported() result.Diagnostics.Should().Contain(d => d.Code == "TASK105"); } } + diff --git a/SharpClaw.Tests/Tasks/TaskStepKeyAssignmentTests.cs b/SharpClaw.Tests/Tasks/TaskStepKeyAssignmentTests.cs index ecfcb9ce..c2bf088f 100644 --- a/SharpClaw.Tests/Tasks/TaskStepKeyAssignmentTests.cs +++ b/SharpClaw.Tests/Tasks/TaskStepKeyAssignmentTests.cs @@ -2,13 +2,17 @@ using NUnit.Framework; using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; +using SharpClaw.Modules.Http; namespace SharpClaw.Tests.Tasks; /// -/// Verifies that the parser sets the correct -/// string key on every parsed for -/// representative core step kinds. +/// Verifies that the parser sets the correct module-owned step key on every +/// parsed for representative step +/// kinds. Step keys are owned by the TaskScripting, AgentOrchestration, and +/// HTTP modules; the literal string values intentionally match the legacy +/// core.* wire format for backward compatibility. /// [TestFixture] public class TaskStepKeyAssignmentTests @@ -34,7 +38,7 @@ public void Parse_Declaration_HasDeclareVariableKey() var result = TaskScriptEngine.Parse(Wrap("var x = 1;")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.DeclareVariable); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.DeclareVariable); } [Test] @@ -43,7 +47,7 @@ public void Parse_Assignment_HasAssignKey() var result = TaskScriptEngine.Parse(Wrap("x = 1;")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Assign); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Assign); } [Test] @@ -52,7 +56,7 @@ public void Parse_Conditional_HasConditionalKey() var result = TaskScriptEngine.Parse(Wrap("if (true) { Log(\"y\"); }")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Conditional); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Conditional); } [Test] @@ -61,7 +65,7 @@ public void Parse_WhileLoop_HasLoopKey() var result = TaskScriptEngine.Parse(Wrap("while (false) { }")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Loop); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Loop); } [Test] @@ -70,7 +74,7 @@ public void Parse_ForEachLoop_HasLoopKey() var result = TaskScriptEngine.Parse(Wrap("foreach (var item in items) { Log(item); }")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Loop); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Loop); } [Test] @@ -79,7 +83,7 @@ public void Parse_ReturnStatement_HasReturnKey() var result = TaskScriptEngine.Parse(Wrap("return;")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Return); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Return); } // ── Context-API method calls ────────────────────────────────────────────── @@ -90,7 +94,7 @@ public void Parse_LogCall_HasLogKey() var result = TaskScriptEngine.Parse(Wrap("Log(\"hello\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Log); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Log); } [Test] @@ -99,7 +103,7 @@ public void Parse_ChatCall_HasChatKey() var result = TaskScriptEngine.Parse(Wrap("var r = await Chat(agentId, \"hello\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Chat); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.Chat); } [Test] @@ -108,7 +112,7 @@ public void Parse_ChatStreamCall_HasChatStreamKey() var result = TaskScriptEngine.Parse(Wrap("await ChatStream(agentId, \"msg\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.ChatStream); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.ChatStream); } [Test] @@ -117,7 +121,7 @@ public void Parse_EmitCall_HasEmitKey() var result = TaskScriptEngine.Parse(Wrap("await Emit(result);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Emit); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.Emit); } [Test] @@ -126,7 +130,7 @@ public void Parse_ParseResponseCall_HasParseResponseKey() var result = TaskScriptEngine.Parse(Wrap("var r = await ParseResponse(reply);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.ParseResponse); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.ParseResponse); } [Test] @@ -135,7 +139,7 @@ public void Parse_HttpGetCall_HasHttpRequestKey() var result = TaskScriptEngine.Parse(Wrap("var r = await HttpGet(\"https://example.com\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.HttpRequest); + result.Definition!.Steps.Single().StepKey.Should().Be(HttpStepKeys.HttpRequest); } [Test] @@ -144,7 +148,7 @@ public void Parse_HttpPostCall_HasHttpRequestKey() var result = TaskScriptEngine.Parse(Wrap("var r = await HttpPost(\"https://example.com\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.HttpRequest); + result.Definition!.Steps.Single().StepKey.Should().Be(HttpStepKeys.HttpRequest); } [Test] @@ -153,7 +157,7 @@ public void Parse_TaskDelayCall_HasDelayKey() var result = TaskScriptEngine.Parse(Wrap("await Task.Delay(1000);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Delay); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.Delay); } [Test] @@ -162,7 +166,7 @@ public void Parse_WaitUntilStoppedCall_HasWaitUntilStoppedKey() var result = TaskScriptEngine.Parse(Wrap("await WaitUntilStopped();")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.WaitUntilStopped); + result.Definition!.Steps.Single().StepKey.Should().Be(TaskScriptingStepKeys.WaitUntilStopped); } [Test] @@ -171,7 +175,7 @@ public void Parse_FindModelCall_HasFindModelKey() var result = TaskScriptEngine.Parse(Wrap("var m = await FindModel(modelId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.FindModel); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.FindModel); } [Test] @@ -180,7 +184,7 @@ public void Parse_FindAgentCall_HasFindAgentKey() var result = TaskScriptEngine.Parse(Wrap("var a = await FindAgent(agentId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.FindAgent); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.FindAgent); } [Test] @@ -189,7 +193,7 @@ public void Parse_CreateAgentCall_HasCreateAgentKey() var result = TaskScriptEngine.Parse(Wrap("var a = await CreateAgent(\"name\", modelId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.CreateAgent); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.CreateAgent); } [Test] @@ -198,7 +202,7 @@ public void Parse_CreateChannelCall_HasCreateChannelKey() var result = TaskScriptEngine.Parse(Wrap("var c = await CreateChannel(\"title\", agentId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.CreateChannel); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.CreateChannel); } [Test] @@ -207,7 +211,7 @@ public void Parse_FindChannelCall_HasFindChannelKey() var result = TaskScriptEngine.Parse(Wrap("var c = await FindChannel(channelId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.FindChannel); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.FindChannel); } [Test] @@ -216,7 +220,7 @@ public void Parse_CreateRoleCall_HasCreateRoleKey() var result = TaskScriptEngine.Parse(Wrap("var r = await CreateRole(\"admin\");")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.CreateRole); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.CreateRole); } [Test] @@ -225,7 +229,7 @@ public void Parse_AssignRoleCall_HasAssignRoleKey() var result = TaskScriptEngine.Parse(Wrap("await AssignRole(agentId, roleId);")); result.Success.Should().BeTrue(); - result.Definition!.Steps.Single().StepKey.Should().Be(WellKnownTaskStepKeys.AssignRole); + result.Definition!.Steps.Single().StepKey.Should().Be(AgentOrchestrationStepKeys.AssignRole); } [Test] @@ -246,9 +250,9 @@ public void Parse_ConditionalNestedSteps_HaveCorrectKeys() result.Success.Should().BeTrue(); var cond = result.Definition!.Steps.Single(); - cond.StepKey.Should().Be(WellKnownTaskStepKeys.Conditional); - cond.Body!.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Log); - cond.ElseBody!.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Log); + cond.StepKey.Should().Be(TaskScriptingStepKeys.Conditional); + cond.Body!.Single().StepKey.Should().Be(TaskScriptingStepKeys.Log); + cond.ElseBody!.Single().StepKey.Should().Be(TaskScriptingStepKeys.Log); } [Test] @@ -265,7 +269,8 @@ public void Parse_LoopNestedSteps_HaveCorrectKeys() result.Success.Should().BeTrue(); var loop = result.Definition!.Steps.Single(); - loop.StepKey.Should().Be(WellKnownTaskStepKeys.Loop); - loop.Body!.Single().StepKey.Should().Be(WellKnownTaskStepKeys.Log); + loop.StepKey.Should().Be(TaskScriptingStepKeys.Loop); + loop.Body!.Single().StepKey.Should().Be(TaskScriptingStepKeys.Log); } } + diff --git a/SharpClaw.Tests/Tasks/TaskTriggerAttributeParserTests.cs b/SharpClaw.Tests/Tasks/TaskTriggerAttributeParserTests.cs index 679f2a9c..8fa23b38 100644 --- a/SharpClaw.Tests/Tasks/TaskTriggerAttributeParserTests.cs +++ b/SharpClaw.Tests/Tasks/TaskTriggerAttributeParserTests.cs @@ -3,20 +3,27 @@ using SharpClaw.Application.Infrastructure.Tasks; using SharpClaw.Application.Infrastructure.Tasks.Models; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.AgentOrchestration; +using SharpClaw.Modules.ComputerUse.Triggers; +using SharpClaw.Modules.DatabaseAccess.Triggers; +using SharpClaw.Modules.FilesystemTriggers; +using SharpClaw.Modules.Http; +using SharpClaw.Modules.Metrics; +using SharpClaw.Modules.NetworkTriggers; namespace SharpClaw.Tests.Tasks; /// -/// Tests for trigger-definition attribute extraction by . -/// Covers all self-registration trigger attributes, concurrency policy, and diagnostic codes. +/// Parser tests for trigger-attribute extraction. After the +/// trigger-extraction cleanup the parser no longer populates +/// typed properties on ; module +/// handlers write directly into the opaque +/// map. These tests +/// assert that surface. /// [TestFixture] public class TaskTriggerAttributeParserTests { - // ───────────────────────────────────────────────────────────── - // Helpers - // ───────────────────────────────────────────────────────────── - private static TaskScriptDefinition ParseOk(string source) { var result = TaskScriptEngine.Parse(source); @@ -38,250 +45,170 @@ public async Task RunAsync(CancellationToken ct) } """; - // ───────────────────────────────────────────────────────────── - // No triggers - // ───────────────────────────────────────────────────────────── + private static TaskTriggerDefinition Single(string source) => + ParseOk(source).TriggerDefinitions.Should().ContainSingle().Subject; [Test] public void Parse_NoTriggerAttributes_ReturnsEmptyTriggerDefinitions() { - var def = ParseOk(Wrap("")); - def.TriggerDefinitions.Should().BeEmpty(); + ParseOk(Wrap("")).TriggerDefinitions.Should().BeEmpty(); } - // ───────────────────────────────────────────────────────────── - // [Schedule] → Cron - // ───────────────────────────────────────────────────────────── + // ── [Schedule] ──────────────────────────────────────────────── [Test] public void Parse_Schedule_PopulatesCronTriggerKeyAndExpression() { - var def = ParseOk(Wrap("", """[Schedule("0 9 * * MON-FRI")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.Cron, - CronExpression = "0 9 * * MON-FRI", - CronTimezone = (string?)null, - }); + var t = Single(Wrap("", """[Schedule("0 9 * * MON-FRI")]""")); + t.TriggerKey.Should().Be(TaskScriptingTriggerKeys.Cron); + t.Parameters[TaskScriptingTriggerKeys.CronExpression].Should().Be("0 9 * * MON-FRI"); + t.Parameters.ContainsKey(TaskScriptingTriggerKeys.CronTimezone).Should().BeFalse(); } [Test] public void Parse_ScheduleWithTimezone_ExtractsCronTimezone() { - var def = ParseOk(Wrap("", """[Schedule("0 9 * * *", Timezone = "America/New_York")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.Cron, - CronExpression = "0 9 * * *", - CronTimezone = "America/New_York", - }); + var t = Single(Wrap("", """[Schedule("0 9 * * *", Timezone = "America/New_York")]""")); + t.TriggerKey.Should().Be(TaskScriptingTriggerKeys.Cron); + t.Parameters[TaskScriptingTriggerKeys.CronExpression].Should().Be("0 9 * * *"); + t.Parameters[TaskScriptingTriggerKeys.CronTimezone].Should().Be("America/New_York"); } - // ───────────────────────────────────────────────────────────── - // [OnEvent] → Event - // ───────────────────────────────────────────────────────────── + // ── [OnEvent] ───────────────────────────────────────────────── [Test] public void Parse_OnEvent_PopulatesEventTriggerKeyAndType() { - var def = ParseOk(Wrap("", """[OnEvent("ModelAdded")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.Event, - EventType = "ModelAdded", - }); + var t = Single(Wrap("", """[OnEvent("ModelAdded")]""")); + t.TriggerKey.Should().Be(AgentOrchestrationTriggerKeys.Event); + t.Parameters[AgentOrchestrationTriggerKeys.EventType].Should().Be("ModelAdded"); } [Test] public void Parse_OnEventWithFilter_ExtractsFilter() { - var def = ParseOk(Wrap("", """[OnEvent("ModelAdded", Filter = "provider=openai")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.Event, - EventType = "ModelAdded", - EventFilter = "provider=openai", - }); + var t = Single(Wrap("", """[OnEvent("ModelAdded", Filter = "provider=openai")]""")); + t.Parameters[AgentOrchestrationTriggerKeys.EventType].Should().Be("ModelAdded"); + t.Parameters[AgentOrchestrationTriggerKeys.EventFilter].Should().Be("provider=openai"); } - // ───────────────────────────────────────────────────────────── - // [OnFileChanged] → FileChanged - // ───────────────────────────────────────────────────────────── + // ── [OnFileChanged] ─────────────────────────────────────────── [Test] public void Parse_OnFileChanged_PopulatesFileChangedTriggerKeyAndPath() { - var def = ParseOk(Wrap("", """[OnFileChanged("/tmp/data")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.FileChanged, - WatchPath = "/tmp/data", - FileEvents = FileWatchEvent.Any, - }); + var t = Single(Wrap("", """[OnFileChanged("/tmp/data")]""")); + t.TriggerKey.Should().Be(FilesystemTriggerKeys.FileChanged); + t.Parameters[FilesystemTriggerKeys.WatchPath].Should().Be("/tmp/data"); + t.Parameters[FilesystemTriggerKeys.FileEvents].Should().Be(FileWatchEvent.Any.ToString()); } [Test] public void Parse_OnFileChangedWithPatternAndEvents_ExtractsNamedArgs() { - var def = ParseOk(Wrap("", """[OnFileChanged("/tmp/data", Pattern = "*.json", Events = FileWatchEvent.Created | FileWatchEvent.Deleted)]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.FilePattern.Should().Be("*.json"); - t.FileEvents.Should().Be(FileWatchEvent.Created | FileWatchEvent.Deleted); + var t = Single(Wrap("", """[OnFileChanged("/tmp/data", Pattern = "*.json", Events = FileWatchEvent.Created | FileWatchEvent.Deleted)]""")); + t.Parameters[FilesystemTriggerKeys.FilePattern].Should().Be("*.json"); + t.Parameters[FilesystemTriggerKeys.FileEvents].Should().Be((FileWatchEvent.Created | FileWatchEvent.Deleted).ToString()); } - // ───────────────────────────────────────────────────────────── - // [OnProcessStarted] / [OnProcessStopped] → Process* - // ───────────────────────────────────────────────────────────── + // ── [OnProcessStarted] / [OnProcessStopped] ─────────────────── [Test] public void Parse_OnProcessStarted_PopulatesProcessStartedTriggerKey() { - var def = ParseOk(Wrap("", """[OnProcessStarted("chrome")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "ProcessStarted", - ProcessName = "chrome", - }); + var t = Single(Wrap("", """[OnProcessStarted("chrome")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.ProcessStarted); + t.Parameters[ComputerUseTriggerKeys.ProcessName].Should().Be("chrome"); } [Test] public void Parse_OnProcessStopped_PopulatesProcessStoppedTriggerKey() { - var def = ParseOk(Wrap("", """[OnProcessStopped("chrome")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "ProcessStopped", - ProcessName = "chrome", - }); + var t = Single(Wrap("", """[OnProcessStopped("chrome")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.ProcessStopped); + t.Parameters[ComputerUseTriggerKeys.ProcessName].Should().Be("chrome"); } - // ───────────────────────────────────────────────────────────── - // [OnWebhook] → Webhook - // ───────────────────────────────────────────────────────────── + // ── [OnWebhook] ─────────────────────────────────────────────── [Test] public void Parse_OnWebhook_PopulatesWebhookTriggerKeyAndRoute() { - var def = ParseOk(Wrap("", """[OnWebhook("/hook/deploy")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.Webhook, - WebhookRoute = "/hook/deploy", - }); + var t = Single(Wrap("", """[OnWebhook("/hook/deploy")]""")); + t.TriggerKey.Should().Be(HttpTriggerKeys.Webhook); + t.Parameters[HttpTriggerKeys.WebhookRoute].Should().Be("/hook/deploy"); } [Test] public void Parse_OnWebhookWithSecret_ExtractsSecretAndSignatureHeader() { - var def = ParseOk(Wrap("", """[OnWebhook("/hook/deploy", Secret = "GH_SECRET", SignatureHeader = "X-Hub-Signature-256")]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.WebhookSecretEnvVar.Should().Be("GH_SECRET"); - t.WebhookSignatureHeader.Should().Be("X-Hub-Signature-256"); + var t = Single(Wrap("", """[OnWebhook("/hook/deploy", Secret = "GH_SECRET", SignatureHeader = "X-Hub-Signature-256")]""")); + t.Parameters[HttpTriggerKeys.WebhookSecretEnvVar].Should().Be("GH_SECRET"); + t.Parameters[HttpTriggerKeys.WebhookSignatureHeader].Should().Be("X-Hub-Signature-256"); } - // ───────────────────────────────────────────────────────────── - // [OnHostReachable] / [OnHostUnreachable] → Host* - // ───────────────────────────────────────────────────────────── + // ── [OnHostReachable] / [OnHostUnreachable] ─────────────────── [Test] public void Parse_OnHostReachable_PopulatesHostReachableTriggerKeyAndName() { - var def = ParseOk(Wrap("", """[OnHostReachable("db.internal")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.HostReachable, - HostName = "db.internal", - }); + var t = Single(Wrap("", """[OnHostReachable("db.internal")]""")); + t.TriggerKey.Should().Be(NetworkTriggerKeys.HostReachable); + t.Parameters[NetworkTriggerKeys.HostName].Should().Be("db.internal"); } [Test] public void Parse_OnHostUnreachableWithPort_ExtractsPort() { - var def = ParseOk(Wrap("", """[OnHostUnreachable("db.internal", Port = 5432)]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.TriggerKey.Should().Be(WellKnownTriggerKeys.HostUnreachable); - t.HostPort.Should().Be(5432); + var t = Single(Wrap("", """[OnHostUnreachable("db.internal", Port = 5432)]""")); + t.TriggerKey.Should().Be(NetworkTriggerKeys.HostUnreachable); + t.Parameters[NetworkTriggerKeys.HostPort].Should().Be("5432"); } - // ───────────────────────────────────────────────────────────── - // [OnTaskCompleted] / [OnTaskFailed] → Task* - // ───────────────────────────────────────────────────────────── + // ── [OnTaskCompleted] / [OnTaskFailed] ──────────────────────── [Test] public void Parse_OnTaskCompleted_PopulatesSourceTaskName() { - var def = ParseOk(Wrap("", """[OnTaskCompleted("IngestData")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.TaskCompleted, - SourceTaskName = "IngestData", - }); + var t = Single(Wrap("", """[OnTaskCompleted("IngestData")]""")); + t.TriggerKey.Should().Be(TaskScriptingTriggerKeys.TaskCompleted); + t.Parameters[TaskScriptingTriggerKeys.SourceTaskName].Should().Be("IngestData"); } [Test] public void Parse_OnTaskFailed_PopulatesSourceTaskName() { - var def = ParseOk(Wrap("", """[OnTaskFailed("IngestData")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = WellKnownTriggerKeys.TaskFailed, - SourceTaskName = "IngestData", - }); + var t = Single(Wrap("", """[OnTaskFailed("IngestData")]""")); + t.TriggerKey.Should().Be(TaskScriptingTriggerKeys.TaskFailed); + t.Parameters[TaskScriptingTriggerKeys.SourceTaskName].Should().Be("IngestData"); } - // ───────────────────────────────────────────────────────────── - // [OnWindowFocused] / [OnWindowBlurred] → Window* - // ───────────────────────────────────────────────────────────── + // ── [OnWindowFocused] / [OnWindowBlurred] ───────────────────── [Test] public void Parse_OnWindowFocused_PopulatesWindowFocusedTriggerKey() { - var def = ParseOk(Wrap("", """[OnWindowFocused("notepad")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "WindowFocused", - ProcessName = "notepad", - }); + var t = Single(Wrap("", """[OnWindowFocused("notepad")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.WindowFocused); + t.Parameters[ComputerUseTriggerKeys.ProcessName].Should().Be("notepad"); } [Test] public void Parse_OnWindowBlurred_PopulatesWindowBlurredTriggerKey() { - var def = ParseOk(Wrap("", """[OnWindowBlurred("notepad")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "WindowBlurred", - ProcessName = "notepad", - }); + var t = Single(Wrap("", """[OnWindowBlurred("notepad")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.WindowBlurred); + t.Parameters[ComputerUseTriggerKeys.ProcessName].Should().Be("notepad"); } - // ───────────────────────────────────────────────────────────── - // [OnHotkey] → Hotkey + TASK429 - // ───────────────────────────────────────────────────────────── + // ── [OnHotkey] ──────────────────────────────────────────────── [Test] public void Parse_OnHotkeyValidCombo_PopulatesHotkeyTriggerKey() { - var def = ParseOk(Wrap("", """[OnHotkey("Ctrl+Shift+F10")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "Hotkey", - HotkeyCombo = "Ctrl+Shift+F10", - }); + var t = Single(Wrap("", """[OnHotkey("Ctrl+Shift+F10")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.Hotkey); + t.Parameters[ComputerUseTriggerKeys.HotkeyCombo].Should().Be("Ctrl+Shift+F10"); } [Test] @@ -298,103 +225,81 @@ public void Parse_OnHotkeyEmptyString_EmitsTask429() result.Diagnostics.Should().Contain(d => d.Code == "TASK429"); } - // ───────────────────────────────────────────────────────────── - // [OnSystemIdle] / [OnSystemActive] → Idle/Active - // ───────────────────────────────────────────────────────────── + // ── [OnSystemIdle] / [OnSystemActive] ───────────────────────── [Test] public void Parse_OnSystemIdle_PopulatesIdleMinutesWithSystemIdleTriggerKey() { - var def = ParseOk(Wrap("", """[OnSystemIdle(Minutes = 15)]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "SystemIdle", - IdleMinutes = 15, - }); + var t = Single(Wrap("", """[OnSystemIdle(Minutes = 15)]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.SystemIdle); + t.Parameters[ComputerUseTriggerKeys.IdleMinutes].Should().Be("15"); } [Test] public void Parse_OnSystemActive_PopulatesSystemActiveTriggerKey() { - var def = ParseOk(Wrap("", """[OnSystemActive]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be("SystemActive"); + Single(Wrap("", """[OnSystemActive]""")).TriggerKey + .Should().Be(ComputerUseTriggerKeys.SystemActive); } - // ───────────────────────────────────────────────────────────── - // [OnScreenLocked] / [OnScreenUnlocked] → Screen* - // ───────────────────────────────────────────────────────────── + // ── [OnScreenLocked] / [OnScreenUnlocked] ───────────────────── [Test] public void Parse_OnScreenLocked_PopulatesScreenLockedTriggerKey() { - var def = ParseOk(Wrap("", """[OnScreenLocked]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be("ScreenLocked"); + Single(Wrap("", """[OnScreenLocked]""")).TriggerKey + .Should().Be(ComputerUseTriggerKeys.ScreenLocked); } [Test] public void Parse_OnScreenUnlocked_PopulatesScreenUnlockedTriggerKey() { - var def = ParseOk(Wrap("", """[OnScreenUnlocked]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be("ScreenUnlocked"); + Single(Wrap("", """[OnScreenUnlocked]""")).TriggerKey + .Should().Be(ComputerUseTriggerKeys.ScreenUnlocked); } - // ───────────────────────────────────────────────────────────── - // [OnNetworkChanged] → NetworkChanged - // ───────────────────────────────────────────────────────────── + // ── [OnNetworkChanged] ──────────────────────────────────────── [Test] public void Parse_OnNetworkChanged_PopulatesNetworkChangedTriggerKey() { - var def = ParseOk(Wrap("", """[OnNetworkChanged]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be(WellKnownTriggerKeys.NetworkChanged); + Single(Wrap("", """[OnNetworkChanged]""")).TriggerKey + .Should().Be(NetworkTriggerKeys.NetworkChanged); } [Test] public void Parse_OnNetworkChangedWithSsidAndState_ExtractsNamedArgs() { - var def = ParseOk(Wrap("", """[OnNetworkChanged(Ssid = "HomeNet", State = NetworkState.Connected)]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.NetworkSsid.Should().Be("HomeNet"); - t.NetworkState.Should().Be(NetworkState.Connected); + var t = Single(Wrap("", """[OnNetworkChanged(Ssid = "HomeNet", State = NetworkState.Connected)]""")); + t.Parameters[NetworkTriggerKeys.NetworkSsid].Should().Be("HomeNet"); + t.Parameters[NetworkTriggerKeys.NetworkState].Should().Be(NetworkState.Connected.ToString()); } - // ───────────────────────────────────────────────────────────── - // [OnDeviceConnected] / [OnDeviceDisconnected] → Device* - // ───────────────────────────────────────────────────────────── + // ── [OnDeviceConnected] / [OnDeviceDisconnected] ────────────── [Test] public void Parse_OnDeviceConnected_PopulatesDeviceConnectedTriggerKey() { - var def = ParseOk(Wrap("", """[OnDeviceConnected(Class = "USB", Pattern = "YubiKey*")]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.TriggerKey.Should().Be("DeviceConnected"); - t.DeviceClass.Should().Be("USB"); - t.DeviceNamePattern.Should().Be("YubiKey*"); + var t = Single(Wrap("", """[OnDeviceConnected(Class = "USB", Pattern = "YubiKey*")]""")); + t.TriggerKey.Should().Be(ComputerUseTriggerKeys.DeviceConnected); + t.Parameters[ComputerUseTriggerKeys.DeviceClass].Should().Be("USB"); + t.Parameters[ComputerUseTriggerKeys.DeviceNamePattern].Should().Be("YubiKey*"); } [Test] public void Parse_OnDeviceDisconnected_PopulatesDeviceDisconnectedTriggerKey() { - var def = ParseOk(Wrap("", """[OnDeviceDisconnected(Class = "USB")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be("DeviceDisconnected"); + Single(Wrap("", """[OnDeviceDisconnected(Class = "USB")]""")).TriggerKey + .Should().Be(ComputerUseTriggerKeys.DeviceDisconnected); } - // ───────────────────────────────────────────────────────────── - // [OnQueryReturnsRows] → QueryReturnsRows + TASK431 - // ───────────────────────────────────────────────────────────── + // ── [OnQueryReturnsRows] ────────────────────────────────────── [Test] public void Parse_OnQueryReturnsRowsWithSelectCount_PopulatesQueryReturnsRowsTriggerKey() { - var def = ParseOk(Wrap("", """[OnQueryReturnsRows("SELECT COUNT(*) FROM PendingItems WHERE Done = 0")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be("QueryReturnsRows"); + Single(Wrap("", """[OnQueryReturnsRows("SELECT COUNT(*) FROM PendingItems WHERE Done = 0")]""")).TriggerKey + .Should().Be(DatabaseAccessTriggerKeys.QueryReturnsRows); } [Test] @@ -407,128 +312,67 @@ public void Parse_OnQueryReturnsRowsWithNonCountQuery_EmitsTask431() [Test] public void Parse_OnQueryReturnsRowsWithPollInterval_ExtractsPollInterval() { - var def = ParseOk(Wrap("", """[OnQueryReturnsRows("SELECT COUNT(*) FROM Q", PollInterval = 30)]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.QueryPollIntervalSecs.Should().Be(30); + var t = Single(Wrap("", """[OnQueryReturnsRows("SELECT COUNT(*) FROM Q", PollInterval = 30)]""")); + t.Parameters[DatabaseAccessTriggerKeys.QueryPollIntervalSecs].Should().Be("30"); } - // ───────────────────────────────────────────────────────────── - // [OnMetricThreshold] → MetricThreshold - // ───────────────────────────────────────────────────────────── + // ── [OnMetricThreshold] ─────────────────────────────────────── [Test] public void Parse_OnMetricThreshold_PopulatesMetricFields() { - var def = ParseOk(Wrap("", """[OnMetricThreshold("System.CpuPercent", Threshold = 90.0, Direction = ThresholdDirection.Above)]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.TriggerKey.Should().Be(WellKnownTriggerKeys.MetricThreshold); - t.MetricSource.Should().Be("System.CpuPercent"); - t.MetricThreshold.Should().Be(90.0); - t.MetricDirection.Should().Be(ThresholdDirection.Above); + var t = Single(Wrap("", """[OnMetricThreshold("System.CpuPercent", Threshold = 90.0, Direction = ThresholdDirection.Above)]""")); + t.TriggerKey.Should().Be(MetricTriggerKeys.MetricThreshold); + t.Parameters[MetricTriggerKeys.Source].Should().Be("System.CpuPercent"); + t.Parameters[MetricTriggerKeys.Threshold].Should().Be("90"); + t.Parameters[MetricTriggerKeys.Direction].Should().Be(ThresholdDirection.Above.ToString()); } - // ───────────────────────────────────────────────────────────── - // [OnStartup] / [OnShutdown] → Startup/Shutdown - // ───────────────────────────────────────────────────────────── + // ── [OnStartup] / [OnShutdown] ──────────────────────────────── [Test] public void Parse_OnStartup_PopulatesStartupTriggerKey() { - var def = ParseOk(Wrap("", """[OnStartup]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be(WellKnownTriggerKeys.Startup); + Single(Wrap("", """[OnStartup]""")).TriggerKey + .Should().Be(TaskScriptingTriggerKeys.Startup); } [Test] public void Parse_OnShutdown_PopulatesShutdownTriggerKey() { - var def = ParseOk(Wrap("", """[OnShutdown]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.TriggerKey.Should().Be(WellKnownTriggerKeys.Shutdown); + Single(Wrap("", """[OnShutdown]""")).TriggerKey + .Should().Be(TaskScriptingTriggerKeys.Shutdown); } - // ───────────────────────────────────────────────────────────── - // [OsShortcut] → OsShortcut - // ───────────────────────────────────────────────────────────── + // ── [OsShortcut] ────────────────────────────────────────────── [Test] public void Parse_OsShortcut_PopulatesShortcutFieldsWithOsShortcutTriggerKey() { - var def = ParseOk(Wrap("", """[OsShortcut("Run Ingest", Icon = "ingest.ico", Category = "Data")]""")); - var t = def.TriggerDefinitions.Should().ContainSingle().Subject; - t.TriggerKey.Should().Be("OsShortcut"); - t.ShortcutLabel.Should().Be("Run Ingest"); - t.ShortcutIcon.Should().Be("ingest.ico"); - t.ShortcutCategory.Should().Be("Data"); + var t = Single(Wrap("", """[OsShortcut("Run Ingest", Icon = "ingest.ico", Category = "Data")]""")); + t.TriggerKey.Should().Be(OsShortcutTriggerKeys.OsShortcut); + t.Parameters[OsShortcutTriggerKeys.ShortcutLabel].Should().Be("Run Ingest"); + t.Parameters[OsShortcutTriggerKeys.ShortcutIcon].Should().Be("ingest.ico"); + t.Parameters[OsShortcutTriggerKeys.ShortcutCategory].Should().Be("Data"); } - // ───────────────────────────────────────────────────────────── - // [OnTrigger] → Custom - // ───────────────────────────────────────────────────────────── + // ── [OnTrigger] ─────────────────────────────────────────────── [Test] public void Parse_OnTrigger_PopulatesTriggerKey() { - var def = ParseOk(Wrap("", """[OnTrigger("MyCustomSource")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Should().BeEquivalentTo(new - { - TriggerKey = "MyCustomSource", - }); + Single(Wrap("", """[OnTrigger("MyCustomSource")]""")).TriggerKey + .Should().Be("MyCustomSource"); } [Test] public void Parse_OnTriggerWithFilter_ExtractsFilter() { - var def = ParseOk(Wrap("", """[OnTrigger("MyCustomSource", Filter = "type=foo")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.CustomSourceFilter.Should().Be("type=foo"); - } - - // ───────────────────────────────────────────────────────────── - // [ConcurrencyPolicy] propagation - // ───────────────────────────────────────────────────────────── - - [Test] - public void Parse_ConcurrencyPolicy_PropagatedToAllTriggers() - { - var source = Wrap("", """ -[Schedule("0 9 * * *")] -[OnEvent("ModelAdded")] -[ConcurrencyPolicy(Policy = TriggerConcurrency.Queue)] -"""); - var def = ParseOk(source); - def.TriggerDefinitions.Should().HaveCount(2); - def.TriggerDefinitions.Should().AllSatisfy(t => t.Concurrency.Should().Be(TriggerConcurrency.Queue)); - } - - [Test] - public void Parse_NoConcurrencyPolicy_DefaultsToSkipIfRunning() - { - var def = ParseOk(Wrap("", """[Schedule("0 9 * * *")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Concurrency.Should().Be(TriggerConcurrency.SkipIfRunning); - } - - // ───────────────────────────────────────────────────────────── - // TASK421 — multiple [ConcurrencyPolicy] - // ───────────────────────────────────────────────────────────── - - [Test] - public void Parse_MultipleConcurrencyPolicyAttributes_EmitsTask421() - { - var source = Wrap("", """ -[Schedule("0 9 * * *")] -[ConcurrencyPolicy(Policy = TriggerConcurrency.Queue)] -[ConcurrencyPolicy(Policy = TriggerConcurrency.Parallel)] -"""); - var result = TaskScriptEngine.Parse(source); - result.Diagnostics.Should().Contain(d => d.Code == "TASK421"); + var t = Single(Wrap("", """[OnTrigger("MyCustomSource", Filter = "type=foo")]""")); + t.Parameters[TaskScriptingTriggerKeys.CustomSourceFilter].Should().Be("type=foo"); } - // ───────────────────────────────────────────────────────────── - // TASK428 — [WebhookSecret] without [OnWebhook] - // ───────────────────────────────────────────────────────────── + // ── TASK428 — [WebhookSecret] without [OnWebhook] ───────────── [Test] public void Parse_WebhookSecretWithoutOnWebhook_EmitsTask428() @@ -547,9 +391,7 @@ public void Parse_WebhookSecretWithOnWebhook_NoTask428() result.Diagnostics.Should().NotContain(d => d.Code == "TASK428"); } - // ───────────────────────────────────────────────────────────── - // Multiple triggers on same class - // ───────────────────────────────────────────────────────────── + // ── Multiple triggers on same class ────────────────────────── [Test] public void Parse_MultipleTriggerAttributes_AllExtracted() @@ -562,18 +404,18 @@ public void Parse_MultipleTriggerAttributes_AllExtracted() var def = ParseOk(source); def.TriggerDefinitions.Should().HaveCount(3); def.TriggerDefinitions.Select(t => t.TriggerKey) - .Should().BeEquivalentTo([WellKnownTriggerKeys.Cron, WellKnownTriggerKeys.Event, WellKnownTriggerKeys.Startup]); + .Should().BeEquivalentTo([ + TaskScriptingTriggerKeys.Cron, + AgentOrchestrationTriggerKeys.Event, + TaskScriptingTriggerKeys.Startup, + ]); } - // ───────────────────────────────────────────────────────────── - // Line numbers are captured - // ───────────────────────────────────────────────────────────── + // ── Line numbers are captured ───────────────────────────────── [Test] public void Parse_TriggerDefinition_RecordsNonZeroLineNumber() { - var def = ParseOk(Wrap("", """[Schedule("0 9 * * *")]""")); - def.TriggerDefinitions.Should().ContainSingle() - .Which.Line.Should().BeGreaterThan(0); + Single(Wrap("", """[Schedule("0 9 * * *")]""")).Line.Should().BeGreaterThan(0); } } diff --git a/SharpClaw.Tests/Tasks/WebhookTriggerSourceTests.cs b/SharpClaw.Tests/Tasks/WebhookTriggerSourceTests.cs index 5ecca31c..d75d3ed8 100644 --- a/SharpClaw.Tests/Tasks/WebhookTriggerSourceTests.cs +++ b/SharpClaw.Tests/Tasks/WebhookTriggerSourceTests.cs @@ -5,8 +5,8 @@ using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using SharpClaw.Application.Core.Services.Triggers; -using SharpClaw.Application.Core.Services.Triggers.Sources; using SharpClaw.Contracts.Tasks; +using SharpClaw.Modules.Http; namespace SharpClaw.Tests.Tasks; @@ -33,7 +33,7 @@ public void SetUp() [Test] public void TriggerKey_IsWebhook() { - _source.TriggerKey.Should().Be(WellKnownTriggerKeys.Webhook); + _source.TriggerKey.Should().Be(HttpTriggerKeys.Webhook); } // ── StartAsync ──────────────────────────────────────────────── @@ -293,12 +293,19 @@ private static StubTriggerSourceContext MakeContext( string? secretEnvVar = null, string? signatureHeader = null) { + var parameters = new Dictionary(StringComparer.Ordinal) + { + [HttpTriggerKeys.WebhookRoute] = route, + }; + if (secretEnvVar is not null) + parameters[HttpTriggerKeys.WebhookSecretEnvVar] = secretEnvVar; + if (signatureHeader is not null) + parameters[HttpTriggerKeys.WebhookSignatureHeader] = signatureHeader; + var def = new TaskTriggerDefinition { - TriggerKey = WellKnownTriggerKeys.Webhook, - WebhookRoute = route, - WebhookSecretEnvVar = secretEnvVar, - WebhookSignatureHeader = signatureHeader, + TriggerKey = HttpTriggerKeys.Webhook, + Parameters = parameters, }; return new StubTriggerSourceContext(def); } @@ -347,8 +354,11 @@ private sealed class SpyTriggerSourceContext(string route) : ITaskTriggerSourceC { public TaskTriggerDefinition Definition { get; } = new TaskTriggerDefinition { - TriggerKey = WellKnownTriggerKeys.Webhook, - WebhookRoute = route, + TriggerKey = HttpTriggerKeys.Webhook, + Parameters = new Dictionary(StringComparer.Ordinal) + { + [HttpTriggerKeys.WebhookRoute] = route, + }, }; public Guid TaskDefinitionId { get; } = Guid.NewGuid(); public int FireCount { get; private set; } diff --git a/SharpClaw.slnx b/SharpClaw.slnx index 861e3ff7..1cc81be2 100644 --- a/SharpClaw.slnx +++ b/SharpClaw.slnx @@ -37,8 +37,12 @@ + + + + @@ -147,3 +151,4 @@ + diff --git a/all-refs.txt b/all-refs.txt new file mode 100644 index 00000000..6be1a621 --- /dev/null +++ b/all-refs.txt @@ -0,0 +1,487 @@ +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:258: CronExpression: cronExpr, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:259: CronTimezone: tz), ct); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:279: CronExpression: cronExpr, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:280: CronTimezone: tz), ct); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:647: task.CronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:648: task.CronTimezone, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:12: /// Generic host-bus event with a SharpClawEventType filter. +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:18: /// Comma-separated list of SharpClawEventType flag names. +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:19: public const string EventType = "EventType"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:22: public const string EventFilter = "EventFilter"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:51: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:58: ? def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:63: public SharpClawEventType SubscribedEvents => +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:64: SharpClawEventType.All; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:92: var eventType = +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:93: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:94: if (string.IsNullOrWhiteSpace(eventType)) return false; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:95: if (!MatchesEventTypeFilter(eventType, evt.Type)) return false; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:97: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:99: !MatchesEventFilter(filter, evt)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:105: private static bool MatchesEventTypeFilter(string eventTypeFilter, SharpClawEventType actual) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:107: foreach (var part in eventTypeFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:109: if (Enum.TryParse(part, ignoreCase: true, out var parsed) && +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:117: private static bool MatchesEventFilter(string filter, SharpClawEvent evt) => +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Models\ScheduledJobDB.cs:35: public string? CronExpression { get; set; } +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Models\ScheduledJobDB.cs:36: public string? CronTimezone { get; set; } +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\CronEvaluator.cs:75: private static CronExpression Parse(string expression) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\CronEvaluator.cs:81: return CronExpression.Parse(expression, format); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:43: request.CronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:44: request.CronTimezone, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:61: CronExpression = request.CronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:62: CronTimezone = request.CronTimezone, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:96: var effectiveCron = request.CronExpression ?? entity.CronExpression; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:97: var effectiveTz = request.CronTimezone ?? entity.CronTimezone; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:101: if (request.CronExpression is not null || request.CronTimezone is not null || +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:115: if (request.CronExpression is not null) entity.CronExpression = request.CronExpression; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:116: if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:168: if (!string.IsNullOrEmpty(entity.CronExpression)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:171: entity.CronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:173: entity.CronTimezone); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:200: if (entity is null || string.IsNullOrEmpty(entity.CronExpression)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:204: .GetNextOccurrences(entity.CronExpression, DateTimeOffset.UtcNow, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:205: entity.CronTimezone, count) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:208: return new CronPreviewResponse(entity.CronExpression, entity.CronTimezone, occurrences); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:242: string? cronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:243: string? cronTimezone, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:249: if (!string.IsNullOrEmpty(cronExpression) && repeatInterval.HasValue) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:251: $"{ErrBothSchedules}: CronExpression and RepeatInterval are mutually exclusive."); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:253: if (!string.IsNullOrEmpty(cronExpression)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:255: if (!CronEvaluator.TryParse(cronExpression, out var cronErr)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:259: if (!string.IsNullOrWhiteSpace(cronTimezone)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:261: try { TimeZoneInfo.FindSystemTimeZoneById(cronTimezone); } +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:265: $"{ErrInvalidTz}: Unknown timezone '{cronTimezone}'."); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:270: cronExpression, DateTimeOffset.UtcNow, cronTimezone); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:275: $"{WarnNeverFires}: Expression '{cronExpression}' has no future " + +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:290: e.RepeatInterval, e.CronExpression, e.CronTimezone, e.MissedFirePolicy, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobWorker.cs:160: if (!string.IsNullOrEmpty(job.CronExpression)) +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobWorker.cs:163: job.CronExpression, now, job.CronTimezone); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:38: EventType = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:39: EventFilter = context.GetNamedStringArg("Filter"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:49: CronExpression = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:50: CronTimezone = context.GetNamedStringArg("Timezone"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:72: SourceTaskName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:82: SourceTaskName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:92: CustomSourceFilter = context.GetNamedStringArg("Filter"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:46: def.Parameters.GetValueOrDefault(TaskScriptingTriggerKeys.SourceTaskName); +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:50: public SharpClawEventType SubscribedEvents => +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:51: SharpClawEventType.JobCompleted | SharpClawEventType.JobFailed; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:75: TaskScriptingTriggerKeys.TaskCompleted => evt.Type.HasFlag(SharpClawEventType.JobCompleted), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:76: TaskScriptingTriggerKeys.TaskFailed => evt.Type.HasFlag(SharpClawEventType.JobFailed), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:34: public const string SourceTaskName = "SourceTaskName"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:37: public const string CronExpression = "CronExpression"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:40: public const string CronTimezone = "CronTimezone"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:43: public const string CustomSourceFilter = "CustomSourceFilter"; +E:\source\SharpClaw\DefaultModules\ComputerUse\ComputerUseModule.cs:443: "List visible desktop windows across all displays. Returns JSON array with title, processName, processId, executablePath. Windows only.", +E:\source\SharpClaw\DefaultModules\ComputerUse\ComputerUseModule.cs:495: Int(parameters, "processId"), Str(parameters, "processName"), Str(parameters, "titleContains")), +E:\source\SharpClaw\DefaultModules\ComputerUse\ComputerUseModule.cs:497: Int(parameters, "processId"), Str(parameters, "processName"), Str(parameters, "titleContains")), +E:\source\SharpClaw\DefaultModules\ComputerUse\ComputerUseModule.cs:508: Int(parameters, "processId"), Str(parameters, "processName"), Str(parameters, "titleContains")), +E:\source\SharpClaw\DefaultModules\ComputerUse\ComputerUseModule.cs:843: "processName": { +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:97: processName = process.ProcessName, +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:131: string? processName = null; +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:136: processName = process.ProcessName; +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:154: processName, +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:189: private static nint FindWindow(int? processId, string? processName, string? titleContains) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:192: if (string.IsNullOrWhiteSpace(processName)) processName = null; +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:195: if (processId is null && processName is null && titleContains is null) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:196: throw new ArgumentException("At least one of processId, processName, or titleContains is required."); +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:221: if (processName is not null) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:226: if (!proc.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:245: $"No window found matching processId={processId}, processName={processName}, titleContains={titleContains}."); +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:274: public string FocusWindow(int? processId, string? processName, string? titleContains) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:279: var hWnd = FindWindow(processId, processName, titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:292: public string CloseWindow(int? processId, string? processName, string? titleContains) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:297: var hWnd = FindWindow(processId, processName, titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:469: public string CaptureWindow(int? processId, string? processName, string? titleContains) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:474: return CaptureWindowWindows(processId, processName, titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:478: private string CaptureWindowWindows(int? processId, string? processName, string? titleContains) +E:\source\SharpClaw\DefaultModules\ComputerUse\DesktopAwarenessService.cs:480: var hWnd = FindWindow(processId, processName, titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\Contracts\IShortcutLauncher.cs:8: /// (ShortcutLabel, ShortcutIcon, ShortcutCategory) +E:\source\SharpClaw\DefaultModules\ComputerUse\Contracts\IWindowManager.cs:7: string FocusWindow(int? processId, string? processName, string? titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\Contracts\IWindowManager.cs:9: string CaptureWindow(int? processId, string? processName, string? titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\Contracts\IWindowManager.cs:11: string CloseWindow(int? processId, string? processName, string? titleContains); +E:\source\SharpClaw\DefaultModules\ComputerUse\Services\ShortcutLauncherService.cs:218: var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId; +E:\source\SharpClaw\DefaultModules\ComputerUse\Services\ShortcutLauncherService.cs:223: var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon); +E:\source\SharpClaw\DefaultModules\ComputerUse\Services\ShortcutLauncherService.cs:266: var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId; +E:\source\SharpClaw\DefaultModules\ComputerUse\Services\ShortcutLauncherService.cs:267: var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon) ?? "utilities-terminal"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Services\ShortcutLauncherService.cs:268: var rawCategory = ReadParam(definition, OsShortcutTriggerKeys.ShortcutCategory); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:55: ProcessName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:68: ProcessName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:88: if (!IsHotkeyComboValid(combo)) +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:99: HotkeyCombo = combo, +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:112: IdleMinutes = context.GetNamedIntArg("Minutes") ?? context.GetIntArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:123: DeviceClass = context.GetNamedStringArg("Class"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:124: DeviceNamePattern = context.GetNamedStringArg("Pattern"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:134: ShortcutLabel = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:135: ShortcutIcon = context.GetNamedStringArg("Icon"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:136: ShortcutCategory = context.GetNamedStringArg("Category"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:159: private static bool IsHotkeyComboValid(string? combo) +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:28: public const string ProcessName = "ProcessName"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:29: public const string HotkeyCombo = "HotkeyCombo"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:30: public const string IdleMinutes = "IdleMinutes"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:31: public const string DeviceClass = "DeviceClass"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:32: public const string DeviceNamePattern = "DeviceNamePattern"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\DeviceTriggerSource.cs:158: var pattern = ctx.Definition.DeviceNamePattern; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\HotkeyTriggerSource.cs:66: var combo = ctx.Definition.HotkeyCombo; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:69: var threshold = (ctx.Definition.IdleMinutes ?? 5) * 60; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:79: wasIdle = _contexts.Any(c => GetIdleSeconds() >= (c.Definition.IdleMinutes ?? 5) * 60); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:20: public const string ShortcutLabel = "ShortcutLabel"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:26: public const string ShortcutIcon = "ShortcutIcon"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:32: public const string ShortcutCategory = "ShortcutCategory"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerSource.cs:41: if (def.Parameters.TryGetValue(OsShortcutTriggerKeys.ShortcutLabel, out var label) && +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:66: var runningSet = GetRunningProcessNames().ToHashSet(StringComparer.OrdinalIgnoreCase); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:70: var name = ctx.Definition.ProcessName; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:93: private static IEnumerable GetRunningProcessNames() +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:96: return GetLinuxProcessNames(); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:99: .Select(p => p.ProcessName) +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:104: private static IEnumerable GetLinuxProcessNames() +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:70: var processName = ctx.Definition.ProcessName; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:71: if (string.IsNullOrWhiteSpace(processName)) continue; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:74: activeTitle.Contains(processName, StringComparison.OrdinalIgnoreCase); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:76: var wasFocused = lastFocused.GetValueOrDefault(processName, false); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:77: lastFocused[processName] = isFocused; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerAttributeHandlers.cs:37: SqlQuery = query, +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerAttributeHandlers.cs:38: QueryPollIntervalSecs = interval, +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerKeys.cs:15: public const string SqlQuery = "SqlQuery"; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerKeys.cs:16: public const string QueryPollIntervalSecs = "QueryPollIntervalSecs"; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:56: .Select(c => c.Definition.QueryPollIntervalSecs ?? 60) +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:64: var sql = ctx.Definition.SqlQuery; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:32: var path = ctx.Definition.WatchPath; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:36: "FileChangedTriggerSource: definition {Id} has no WatchPath; skipping.", +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:42: var pattern = ctx.Definition.FilePattern ?? "*"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:57: var events = ctx.Definition.FileEvents == 0 ? FileWatchEvent.Any : ctx.Definition.FileEvents; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:80: def.TriggerKey == FilesystemTriggerKeys.FileChanged ? def.WatchPath : null; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:24: WatchPath = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:25: FilePattern = context.GetNamedStringArg("Pattern"), +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:26: FileEvents = context.GetNamedEnumArg("Events") ?? FileWatchEvent.Any, +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:15: public const string WatchPath = "WatchPath"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:16: public const string FilePattern = "FilePattern"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:17: public const string FileEvents = "FileEvents"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:26: WebhookRoute = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:27: WebhookSecretEnvVar = context.GetNamedStringArg("Secret"), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:28: WebhookSignatureHeader = context.GetNamedStringArg("SignatureHeader"), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:15: public const string WebhookRoute = "WebhookRoute"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:16: public const string WebhookSecretEnvVar = "WebhookSecretEnvVar"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:17: public const string WebhookSignatureHeader = "WebhookSignatureHeader"; +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:15: /// , and maintains an in-memory +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:27: private IWebhookRouteRegistrar? _routeRegistrar; +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:46: public void SetRouteRegistrar(IWebhookRouteRegistrar registrar) +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:64: if (string.IsNullOrWhiteSpace(def.WebhookRoute)) +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:67: "Webhook binding for task {TaskId} has no WebhookRoute — skipping.", +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:72: var routePath = NormalizeRoute(def.WebhookRoute); +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:97: def.TriggerKey == HttpTriggerKeys.Webhook ? def.WebhookRoute : null; +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:117: if (!string.IsNullOrWhiteSpace(def.WebhookSecretEnvVar)) +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:119: var secret = Environment.GetEnvironmentVariable(def.WebhookSecretEnvVar); +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:124: routePath, def.WebhookSecretEnvVar); +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:128: var sigHeader = string.IsNullOrWhiteSpace(def.WebhookSignatureHeader) +E:\source\SharpClaw\DefaultModules\Http\WebhookTriggerSource.cs:130: : def.WebhookSignatureHeader; +E:\source\SharpClaw\DefaultModules\Metrics\MetricsModule.cs:11: /// Default module that owns the MetricThreshold task trigger and the +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:7: /// [OnMetricThreshold]. Behavior preserved verbatim from the legacy +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:15: ["OnMetricThreshold"] = new OnMetricThresholdHandler(), +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:18: private sealed class OnMetricThresholdHandler : ITaskTriggerAttributeHandler +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:23: TriggerKey = MetricTriggerKeys.MetricThreshold, +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:24: MetricSource = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:25: MetricThreshold = context.GetNamedDoubleArg("Threshold"), +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:26: MetricDirection = context.GetNamedEnumArg("Direction") ?? ThresholdDirection.Either, +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:27: MetricPollIntervalSecs = context.GetNamedIntArg("PollInterval"), +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:10: public const string MetricThreshold = "MetricThreshold"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:13: public const string Source = "MetricSource"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:14: public const string Threshold = "MetricThreshold"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:15: public const string Direction = "MetricDirection"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:16: public const string PollIntervalSecs = "MetricPollIntervalSecs"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:20: public string TriggerKey => MetricTriggerKeys.MetricThreshold; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:48: public string? GetBindingValue(TaskTriggerDefinition t) => t.MetricSource; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:66: if (string.IsNullOrWhiteSpace(def.MetricSource)) continue; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:69: string.Equals(p.MetricName, def.MetricSource, StringComparison.OrdinalIgnoreCase)); +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:81: var threshold = def.MetricThreshold ?? 0; +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerSource.cs:82: var crossed = def.MetricDirection switch +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:115: var filePattern = pattern +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:119: if (string.IsNullOrWhiteSpace(filePattern) || filePattern == "**") +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:120: filePattern = "*"; +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:122: return Directory.EnumerateFiles(moduleDir, filePattern, searchOption) +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ProcessInspectionService.cs:16: string ProcessName, +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ProcessInspectionService.cs:71: process.Id, process.ProcessName, modules, windowClasses, threads, null); +E:\source\SharpClaw\DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:57: NetworkTriggerKeys.HostReachable or NetworkTriggerKeys.HostUnreachable => def.HostName, +E:\source\SharpClaw\DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:76: var host = ctx.Definition.HostName; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:79: var port = ctx.Definition.HostPort; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:28: HostName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:29: HostPort = context.GetNamedIntArg("Port"), +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:39: NetworkSsid = context.GetNamedStringArg("Ssid"), +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:40: NetworkState = context.GetNamedEnumArg("State") ?? NetworkState.Any, +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:15: public const string HostName = "HostName"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:16: public const string HostPort = "HostPort"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:17: public const string NetworkSsid = "NetworkSsid"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:18: public const string NetworkState = "NetworkState"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:52: def.TriggerKey == NetworkTriggerKeys.NetworkChanged ? def.NetworkSsid : null; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:69: var ssid = ctx.Definition.NetworkSsid; +E:\source\SharpClaw\DefaultModules\Providers\LlamaSharp\LocalInference\LlamaSharpStringFormatGrammars.cs:45: "hostname" => Hostname, +E:\source\SharpClaw\DefaultModules\Providers\LlamaSharp\LocalInference\LlamaSharpStringFormatGrammars.cs:102: // RFC 1123 hostname: labels joined by '.', each alphanumeric/hyphen, +E:\source\SharpClaw\DefaultModules\Providers\LlamaSharp\LocalInference\LlamaSharpStringFormatGrammars.cs:104: private static readonly Fragment Hostname = new( +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:365: Mk8ShellVerb.SysHostname => InMemory(op.Verb, []), +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:941: // args[0] = hostname, args[1] = count (optional, default 1). +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:946: Mk8UrlSanitizer.ValidateHostname(op.Args[0]); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:961: Mk8UrlSanitizer.ValidateHostname(op.Args[0]); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:971: Mk8UrlSanitizer.ValidateHostname(op.Args[0]); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellCompiler.cs:1207: Mk8UrlSanitizer.ValidateHostname(op.Args[0]); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:436: Mk8ShellVerb.SysHostname => Environment.MachineName, +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:832: .OrderBy(p => p.ProcessName, StringComparer.OrdinalIgnoreCase) +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:836: try { return $"{p.Id}\t{p.ProcessName}"; } +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1044: var hostname = args[0]; +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1056: var reply = await pinger.SendPingAsync(hostname, 5000); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1078: private static async Task NetDnsAsync(string hostname, CancellationToken ct) +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1080: var addresses = await System.Net.Dns.GetHostAddressesAsync(hostname, ct); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1089: return $"DNS resolved {hostname} but all addresses are private/reserved (hidden)."; +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1091: return $"DNS: {hostname}\n" + string.Join(Environment.NewLine, results); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1096: var hostname = args[0]; +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1101: await client.ConnectAsync(hostname, port, ct); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1114: new SslClientAuthenticationOptions { TargetHost = hostname }, +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1118: return $"No certificate returned by {hostname}:{port}"; +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:1121: sb.AppendLine($"Host: {hostname}:{port}"); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:2096: .Where(p => p.ProcessName.Contains(name, StringComparison.OrdinalIgnoreCase)) +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:2097: .OrderBy(p => p.ProcessName)) +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:2102: sb.AppendLine($"{proc.Id}\t{proc.ProcessName}\t{proc.WorkingSet64 / 1024}KB"); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:2106: sb.AppendLine($"{proc.Id}\t{proc.ProcessName}\t(access denied)"); +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellExecutor.cs:2166: "LC_ALL", "TZ", "TERM", "PWD", "HOSTNAME", "SHELL", "EDITOR", +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellVerb.cs:272: SysHostname, +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellVerb.cs:377: /// Returns subject, issuer, expiry, SANs, thumbprint. Hostname SSRF-validated. +E:\source\SharpClaw\mk8.shell\Engine\Mk8ShellVerb.cs:392: /// hostname validation as . In-memory via +E:\source\SharpClaw\mk8.shell\Isolation\Mk8LinuxSandboxContainer.cs:852: /// --uts: UTS namespace (hostname isolation) +E:\source\SharpClaw\mk8.shell\Isolation\Mk8LinuxSandboxContainer.cs:865: "--uts", // UTS namespace (hostname isolation) +E:\source\SharpClaw\mk8.shell\Isolation\Mk8NetworkWhitelist.cs:119: /// Target hostname or IP. Supports wildcard prefix: *.example.com +E:\source\SharpClaw\mk8.shell\Safety\Mk8EnvAllowlist.cs:13: "PWD", "HOSTNAME", "SHELL", "EDITOR", +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:23: /// Blocked hostname patterns for DNS/ping operations. +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:32: /// Validates a hostname for use with NetPing/NetDns. Blocks: +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:35: public static void ValidateHostname(string hostname) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:37: ArgumentException.ThrowIfNullOrWhiteSpace(hostname); +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:39: if (hostname.Length > 253) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:40: throw new Mk8UrlViolationException(hostname, +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:41: "Hostname exceeds maximum length of 253 characters."); +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:44: if (System.Net.IPAddress.TryParse(hostname, out _)) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:45: throw new Mk8UrlViolationException(hostname, +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:46: "IP address literals are not allowed. Use a hostname."); +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:49: if (BlockedHosts.Contains(hostname)) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:50: throw new Mk8UrlViolationException(hostname, +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:51: $"Host '{hostname}' is blocked."); +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:56: if (hostname.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:57: throw new Mk8UrlViolationException(hostname, +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:58: $"Hostname with suffix '{suffix}' is blocked."); +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:62: foreach (var ch in hostname) +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:65: throw new Mk8UrlViolationException(hostname, +E:\source\SharpClaw\mk8.shell\Safety\Mk8UrlSanitizer.cs:66: $"Invalid character '{ch}' in hostname."); +E:\source\SharpClaw\sharpclaw-vs2026\SharpClawOptionsPage.cs:27: [Description("Hostname or IP address of the SharpClaw backend (default: 127.0.0.1).")] +E:\source\SharpClaw\SharpClaw.Application.API\Program.cs:619: var webhookRegistry = new WebhookRouteRegistry( +E:\source\SharpClaw\SharpClaw.Application.API\Program.cs:622: app.Services.GetRequiredService>()); +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1238: case ChatStreamEventType.TextDelta: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1243: case ChatStreamEventType.ToolCallStart: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1248: case ChatStreamEventType.ToolCallResult: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1252: case ChatStreamEventType.ApprovalRequired: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1257: case ChatStreamEventType.ApprovalResult: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1261: case ChatStreamEventType.Error: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:1266: case ChatStreamEventType.Done: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:2762: case TaskOutputEventType.Output: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:2765: case TaskOutputEventType.Log: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:2768: case TaskOutputEventType.StatusChange: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:2771: case TaskOutputEventType.Done: +E:\source\SharpClaw\SharpClaw.Application.API\Cli\CliDispatcher.cs:2777: if (evt.Type == TaskOutputEventType.Done) +E:\source\SharpClaw\SharpClaw.Application.API\Handlers\TaskStreamHandlers.cs:62: if (evt.Type == TaskOutputEventType.Done) +E:\source\SharpClaw\SharpClaw.Application.API\Webhooks\WebhookRouteRegistry.cs:12: /// ASP.NET Core implementation of . +E:\source\SharpClaw\SharpClaw.Application.API\Webhooks\WebhookRouteRegistry.cs:22: public sealed class WebhookRouteRegistry( +E:\source\SharpClaw\SharpClaw.Application.API\Webhooks\WebhookRouteRegistry.cs:25: ILogger logger) : IWebhookRouteRegistrar +E:\source\SharpClaw\SharpClaw.Application.API\Webhooks\WebhookRouteRegistry.cs:30: // ── IWebhookRouteRegistrar ──────────────────────────────────── +E:\source\SharpClaw\SharpClaw.Application.Core\Modules\ModuleEventDispatcher.cs:43: "Event sink {SinkType} threw while handling {EventType}.", +E:\source\SharpClaw\SharpClaw.Application.Core\Modules\ModuleHealthCheckService.cs:137: SharpClawEventType.ModuleHealthFailed, +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ChatService.cs:109: new ThreadActivityEvent(ThreadActivityEventType.Processing, request.ClientType)); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ChatService.cs:210: new ThreadActivityEvent(ThreadActivityEventType.NewMessages, request.ClientType)); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ChatService.cs:731: /// and +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ChatService.cs:779: new ThreadActivityEvent(ThreadActivityEventType.Processing, request.ClientType)); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ChatService.cs:1013: new ThreadActivityEvent(ThreadActivityEventType.NewMessages, request.ClientType)); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ModuleService.cs:218: SharpClawEventType.ModuleEnabled, +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ModuleService.cs:286: SharpClawEventType.ModuleDisabled, +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskAgentOrchestrationBridge.cs:58: if (evt.Type == ChatStreamEventType.TextDelta && evt.Delta is not null) +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:109: await runtime.WriteEventAsync(TaskOutputEventType.StatusChange, "Running"); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:188: await runtime.WriteEventAsync(TaskOutputEventType.Log, $"SharedData: {description}"); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:330: await runtime.WriteEventAsync(TaskOutputEventType.Output, outputJson); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:344: await runtime.WriteEventAsync(TaskOutputEventType.StatusChange, status.ToString()); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:345: await runtime.WriteEventAsync(TaskOutputEventType.Done, null); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:360: await runtime.WriteEventAsync(TaskOutputEventType.StatusChange, $"Failed: {error}"); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskOrchestrator.cs:361: await runtime.WriteEventAsync(TaskOutputEventType.Done, null); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskRuntimeHost.cs:139: await WriteEventAsync(instanceId, TaskOutputEventType.StatusChange, "Paused"); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskRuntimeHost.cs:160: await WriteEventAsync(instanceId, TaskOutputEventType.StatusChange, "Running"); +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskRuntimeHost.cs:178: internal async Task WriteEventAsync(Guid instanceId, TaskOutputEventType type, string? data) +E:\source\SharpClaw\SharpClaw.Application.Core\Services\TaskRuntimeHost.cs:326: public Task WriteEventAsync(TaskOutputEventType type, string? data) +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ThreadActivitySignal.cs:83: public enum ThreadActivityEventType +E:\source\SharpClaw\SharpClaw.Application.Core\Services\ThreadActivitySignal.cs:92: public sealed record ThreadActivityEvent(ThreadActivityEventType Type, string? ClientType = null); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:570: Set(nameof(t.CronExpression), t.CronExpression); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:571: Set(nameof(t.CronTimezone), t.CronTimezone); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:572: Set(nameof(t.EventType), t.EventType); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:573: Set(nameof(t.EventFilter), t.EventFilter); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:574: Set(nameof(t.WatchPath), t.WatchPath); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:575: Set(nameof(t.FilePattern), t.FilePattern); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:576: if (t.FileEvents != default) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:577: p[nameof(t.FileEvents)] = t.FileEvents.ToString(); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:578: Set(nameof(t.ProcessName), t.ProcessName); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:579: Set(nameof(t.WebhookRoute), t.WebhookRoute); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:580: Set(nameof(t.WebhookSecretEnvVar), t.WebhookSecretEnvVar); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:581: Set(nameof(t.WebhookSignatureHeader), t.WebhookSignatureHeader); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:582: Set(nameof(t.HostName), t.HostName); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:583: if (t.HostPort.HasValue) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:584: p[nameof(t.HostPort)] = t.HostPort.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:585: Set(nameof(t.SourceTaskName), t.SourceTaskName); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:586: Set(nameof(t.HotkeyCombo), t.HotkeyCombo); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:587: if (t.IdleMinutes.HasValue) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:588: p[nameof(t.IdleMinutes)] = t.IdleMinutes.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:589: Set(nameof(t.NetworkSsid), t.NetworkSsid); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:592: Set(nameof(t.DeviceClass), t.DeviceClass); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:593: Set(nameof(t.DeviceNamePattern), t.DeviceNamePattern); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:594: Set(nameof(t.SqlQuery), t.SqlQuery); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:595: if (t.QueryPollIntervalSecs.HasValue) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:596: p[nameof(t.QueryPollIntervalSecs)] = t.QueryPollIntervalSecs.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:597: Set(nameof(t.MetricSource), t.MetricSource); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:598: if (t.MetricThreshold.HasValue) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:599: p[nameof(t.MetricThreshold)] = t.MetricThreshold.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:600: if (t.MetricDirection != default) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:601: p[nameof(t.MetricDirection)] = t.MetricDirection.ToString(); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:602: if (t.MetricPollIntervalSecs.HasValue) +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:603: p[nameof(t.MetricPollIntervalSecs)] = t.MetricPollIntervalSecs.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:604: Set(nameof(t.ShortcutLabel), t.ShortcutLabel); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:605: Set(nameof(t.ShortcutIcon), t.ShortcutIcon); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:606: Set(nameof(t.ShortcutCategory), t.ShortcutCategory); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:607: Set(nameof(t.CustomSourceName), t.CustomSourceName); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:608: Set(nameof(t.CustomSourceFilter), t.CustomSourceFilter); +E:\source\SharpClaw\SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:662: private static bool IsHotkeyComboValid(string? combo) +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:13: public required ChatStreamEventType Type { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:15: /// Partial text token from the assistant. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:18: /// Tool call being executed. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:21: /// Tool call result. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:24: /// Job awaiting approval. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:27: /// Approval decision result. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:30: /// Error message. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:33: /// Final persisted messages. Set when is . +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:37: new() { Type = ChatStreamEventType.TextDelta, Delta = delta }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:40: new() { Type = ChatStreamEventType.ToolCallStart, Job = job }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:43: new() { Type = ChatStreamEventType.ToolCallResult, Result = result }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:46: new() { Type = ChatStreamEventType.ApprovalRequired, PendingJob = job }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:49: new() { Type = ChatStreamEventType.ApprovalResult, ApprovalOutcome = outcome }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:52: new() { Type = ChatStreamEventType.Error, Error = message }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:55: new() { Type = ChatStreamEventType.Done, FinalResponse = response }; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Chat\ChatStreamDtos.cs:58: public enum ChatStreamEventType +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:8: /// Create a new scheduled job. Exactly one of or +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:18: string? CronExpression = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:19: string? CronTimezone = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:32: string? CronExpression = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:33: string? CronTimezone = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:47: string? CronExpression, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:48: string? CronTimezone, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\TaskDtos.cs:164: TaskOutputEventType Type, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\TaskDtos.cs:173: public enum TaskOutputEventType +E:\source\SharpClaw\SharpClaw.Contracts\Modules\ISharpClawEventSink.cs:19: SharpClawEventType SubscribedEvents { get; } +E:\source\SharpClaw\SharpClaw.Contracts\Modules\SharpClawEvent.cs:9: public enum SharpClawEventType : long +E:\source\SharpClaw\SharpClaw.Contracts\Modules\SharpClawEvent.cs:57: SharpClawEventType Type, +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\ITaskMetricProvider.cs:9: /// Unique name used in [OnMetricThreshold("MetricName", …)]. +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\IWebhookRouteRegistrar.cs:14: public interface IWebhookRouteRegistrar +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\IWebhookTriggerHost.cs:17: void SetRouteRegistrar(IWebhookRouteRegistrar registrar); +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:18: public string? CronExpression { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:19: public string? CronTimezone { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:22: /// SharpClawEventType flag name(s) as a comma-separated string. +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:23: public string? EventType { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:24: public string? EventFilter { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:27: public string? WatchPath { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:28: public string? FilePattern { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:29: public FileWatchEvent FileEvents { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:32: public string? ProcessName { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:35: public string? WebhookRoute { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:36: public string? WebhookSecretEnvVar { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:37: public string? WebhookSignatureHeader { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:40: public string? HostName { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:41: public int? HostPort { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:44: public string? SourceTaskName { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:47: public string? HotkeyCombo { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:50: public int? IdleMinutes { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:53: public string? NetworkSsid { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:57: public string? DeviceClass { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:58: public string? DeviceNamePattern { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:61: public string? SqlQuery { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:62: public int? QueryPollIntervalSecs { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:65: public string? MetricSource { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:66: public double? MetricThreshold { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:67: public ThresholdDirection MetricDirection { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:68: public int? MetricPollIntervalSecs { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:71: public string? ShortcutLabel { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:72: public string? ShortcutIcon { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:73: public string? ShortcutCategory { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:79: public string? CustomSourceName { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:80: public string? CustomSourceFilter { get; init; } +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\TaskTriggerDefinition.cs:93: /// (e.g. "WebhookRoute", "CronExpression") verbatim so +E:\source\SharpClaw\SharpClaw.Contracts\Tasks\ThresholdDirection.cs:3: /// Direction of a metric threshold crossing for the [OnMetricThreshold] trigger. +E:\source\SharpClaw\SharpClaw.Tests\Providers\LlamaSharp\LlamaSharpSchemaFormatTests.cs:76: [TestCase("hostname")] +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:69: CronExpression = "0 9 * * MON-FRI", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:70: CronTimezone = (string?)null, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:75: public void Parse_ScheduleWithTimezone_ExtractsCronTimezone() +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:82: CronExpression = "0 9 * * *", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:83: CronTimezone = "America/New_York", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:99: EventType = "ModelAdded", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:111: EventType = "ModelAdded", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:112: EventFilter = "provider=openai", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:128: WatchPath = "/tmp/data", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:129: FileEvents = FileWatchEvent.Any, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:138: t.FilePattern.Should().Be("*.json"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:139: t.FileEvents.Should().Be(FileWatchEvent.Created | FileWatchEvent.Deleted); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:154: ProcessName = "chrome", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:166: ProcessName = "chrome", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:182: WebhookRoute = "/hook/deploy", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:191: t.WebhookSecretEnvVar.Should().Be("GH_SECRET"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:192: t.WebhookSignatureHeader.Should().Be("X-Hub-Signature-256"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:207: HostName = "db.internal", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:217: t.HostPort.Should().Be(5432); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:225: public void Parse_OnTaskCompleted_PopulatesSourceTaskName() +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:232: SourceTaskName = "IngestData", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:237: public void Parse_OnTaskFailed_PopulatesSourceTaskName() +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:244: SourceTaskName = "IngestData", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:260: ProcessName = "notepad", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:272: ProcessName = "notepad", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:288: HotkeyCombo = "Ctrl+Shift+F10", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:311: public void Parse_OnSystemIdle_PopulatesIdleMinutesWithSystemIdleTriggerKey() +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:318: IdleMinutes = 15, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:367: t.NetworkSsid.Should().Be("HomeNet"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:381: t.DeviceClass.Should().Be("USB"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:382: t.DeviceNamePattern.Should().Be("YubiKey*"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:417: .Which.QueryPollIntervalSecs.Should().Be(30); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:421: // [OnMetricThreshold] → MetricThreshold +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:425: public void Parse_OnMetricThreshold_PopulatesMetricFields() +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:427: var def = ParseOk(Wrap("", """[OnMetricThreshold("System.CpuPercent", Threshold = 90.0, Direction = ThresholdDirection.Above)]""")); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:429: t.TriggerKey.Should().Be(MetricTriggerKeys.MetricThreshold); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:430: t.MetricSource.Should().Be("System.CpuPercent"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:431: t.MetricThreshold.Should().Be(90.0); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:432: t.MetricDirection.Should().Be(ThresholdDirection.Above); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:465: t.ShortcutLabel.Should().Be("Run Ingest"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:466: t.ShortcutIcon.Should().Be("ingest.ico"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:467: t.ShortcutCategory.Should().Be("Data"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:490: .Which.CustomSourceFilter.Should().Be("type=foo"); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:21: private SpyWebhookRouteRegistrar _registrar = null!; +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:27: _registrar = new SpyWebhookRouteRegistrar(); +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:299: WebhookRoute = route, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:300: WebhookSecretEnvVar = secretEnvVar, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:301: WebhookSignatureHeader = signatureHeader, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:315: private sealed class SpyWebhookRouteRegistrar : IWebhookRouteRegistrar +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:351: WebhookRoute = route, +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:63: ReadOnlyMemory eventTypeMem = default; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:75: eventTypeMem = line.AsMemory(7); +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:77: else if (line.StartsWith("data: ") && eventTypeMem.Length > 0) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:79: var evtSpan = eventTypeMem.Span; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:122: eventTypeMem = default; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:651: var events = System.Threading.Channels.Channel.CreateUnbounded<(string EventType, string DataJson)>( +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:655: // lines, and push parsed (eventType, dataJson) pairs into the channel. +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:663: string? currentEventType = null; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:685: if (line.Length == 0) { currentEventType = null; continue; } +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:690: currentEventType = line[7..]; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:694: if (line.StartsWith("data: ", StringComparison.Ordinal) && currentEventType is not null) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:697: events.Writer.TryWrite((currentEventType, dataJson)); +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:698: currentEventType = null; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:713: await foreach (var (eventType, dataJson) in events.Reader.ReadAllAsync(ct)) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:716: ChatLog($"[Stream] SSE #{eventCount}: {eventType} {TerminalUI.Truncate(dataJson, 120)}"); +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:718: if (ProcessSseEvent(eventType, dataJson, bubble, channelId)) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:751: private bool ProcessSseEvent(string eventType, string dataJson, ChatBubbleRow bubble, Guid channelId) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:753: switch (eventType) +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:903: ChatLog($"[Stream] Unknown event type: {eventType}"); +E:\source\SharpClaw\SharpClaw.Uno\Presentation\SettingsPage.xaml.cs:1645: ("ImapHost", "imap.example.com", "IMAP server hostname for receiving email."), +E:\source\SharpClaw\SharpClaw.Uno\Presentation\SettingsPage.xaml.cs:1647: ("SmtpHost", "smtp.example.com", "SMTP server hostname for sending replies."), diff --git a/docs/Tasks-documentation.md b/docs/Tasks-documentation.md index 3a99a7fa..99a16dad 100644 --- a/docs/Tasks-documentation.md +++ b/docs/Tasks-documentation.md @@ -45,7 +45,7 @@ runtime. - [SSE streaming](#sse-streaming) - [Response shapes](#response-shapes) - [Module authoring — extending the task step system](#module-authoring--extending-the-task-step-system) - - [Step keys and WellKnownTaskStepKeys](#step-keys-and-wellknowntaskstepkeys) + - [Step keys](#step-keys) - [TaskStepRegistry](#taskstepregistry) - [TaskStepDescriptor](#taskstepdescriptor) - [Implementing ITaskParserModuleExtension](#implementing-itaskparsermoduleextension) @@ -166,7 +166,6 @@ trigger host service. [Schedule("0 */15 * * *", Timezone = "UTC")] [OnFileChanged("C:\\watch", Pattern = "*.pdf")] [OnTrigger("MyCustomSource", Filter = "important")] -[ConcurrencyPolicy(TriggerConcurrency.SkipIfRunning)] public class NotifyOnChangeTask { public async Task RunAsync(CancellationToken ct) { } @@ -185,7 +184,6 @@ Common trigger attributes: | `[OnStartup]` / `[OnShutdown]` | Fire on application lifecycle transitions. | | `[OsShortcut("Label")]` | Install an OS launcher shortcut for the task. | | `[OnTrigger("SourceName")]` | Bind to a custom module-provided trigger source. | -| `[ConcurrencyPolicy(...)]` | Control what happens when a trigger fires while a prior instance is still running. | --- @@ -310,8 +308,9 @@ method calls to outside types. ### Step reference -Each call in `RunAsync` maps to a registered step key (a `WellKnownTaskStepKeys` string -constant). The following built-in methods are available inside a task body: +Each call in `RunAsync` maps to a registered step key (a stable +`{moduleId}.{step_name}` wire-format string contributed by a module). The +following built-in methods are available inside a task body: #### Agent interaction @@ -972,14 +971,23 @@ new callable steps to the task scripting language. --- -### Step keys and `WellKnownTaskStepKeys` +### Step keys Every parsed step carries a `StepKey` string stored in -`TaskStepDefinition.StepKey`. The orchestrator switches on this value to -route execution. Core steps use the string constants defined in -`WellKnownTaskStepKeys` (e.g. `core.chat`, `core.http_request`). Module -steps must use namespaced keys of the form `{moduleId}.{step_name}` to -avoid collisions with core or other modules. +`TaskStepDefinition.StepKey`. The orchestrator routes execution by looking +the key up in `TaskStepRegistry.Default`, where every descriptor is owned +by a module. There are no core-owned step keys; the built-in step set is +contributed by the bundled modules `sharpclaw_task_scripting`, +`sharpclaw_agent_orchestration`, and `sharpclaw_http`, which expose their +keys through the module-local constant classes `TaskScriptingStepKeys`, +`AgentOrchestrationStepKeys`, and `HttpStepKeys` respectively. The literal +string values (for example `core.chat`, `core.http_request`, +`core.declare_variable`) are kept stable for backward compatibility with +serialized task scripts; only the C# location of the constants changed. + +New modules should use namespaced keys of the form +`{moduleId}.{step_name}` to avoid collisions with the bundled modules or +with other third-party modules. ```csharp // Recommended pattern — define your keys as constants in your module project. @@ -991,10 +999,6 @@ public static class TranscriptionStepKeys } ``` -Core step keys are not a closed set from a module's perspective: the -`WellKnownTaskStepKeys` class lists every key the built-in orchestrator -handles, but module authors should never reuse or shadow those values. - --- ### `TaskStepRegistry` @@ -1171,8 +1175,7 @@ state of the current task instance. Key members: If your module adds a new event trigger type (backed by `ITaskParserModuleExtension.EventTriggerMappings`), the trigger key is stored -in `TaskStepDefinition.ModuleTriggerKey` for steps of kind -`TaskTriggerKind.ModuleEvent`. You are responsible for firing those triggers +in `TaskStepDefinition.ModuleTriggerKey`. You are responsible for firing those triggers through the `TaskTriggerHostService` extension point — typically by registering a custom `ITaskTriggerSource` implementation and including its source ID in the trigger key mapping. diff --git a/docs/guides/Module-Creation-Guide.md b/docs/guides/Module-Creation-Guide.md index 83d454b7..8c26f27c 100644 --- a/docs/guides/Module-Creation-Guide.md +++ b/docs/guides/Module-Creation-Guide.md @@ -441,16 +441,14 @@ Guard with an existence check so re-seeding manually doesn't produce duplicates. ## Owning task trigger sources A module can fire tasks in response to external events by implementing -`ITaskTriggerSourceProvider`. The task system routes activation for the declared -`TaskTriggerKind` values to your module. +`ITaskTriggerSource`. The task system routes activation for the declared +module-owned trigger keys to your module. ```csharp -public sealed class MyTriggerSource : ITaskTriggerSourceProvider +public sealed class MyTriggerSource : ITaskTriggerSource { - public string SourceName => "my_module"; - - public IReadOnlyList SupportedKinds => - [TaskTriggerKind.OnHotkey, TaskTriggerKind.OnProcessStarted]; + public IReadOnlyList TriggerKeys => + ["my_module.on_hotkey", "my_module.on_process_started"]; public Task EnableTriggerAsync(TaskDefinition def, CancellationToken ct) { diff --git a/typed-prop-refs.txt b/typed-prop-refs.txt new file mode 100644 index 00000000..d1bb5a51 --- /dev/null +++ b/typed-prop-refs.txt @@ -0,0 +1,15 @@ +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\DeviceTriggerSource.cs:158: var pattern = ctx.Definition.DeviceNamePattern; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\HotkeyTriggerSource.cs:66: var combo = ctx.Definition.HotkeyCombo; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:69: var threshold = (ctx.Definition.IdleMinutes ?? 5) * 60; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:79: wasIdle = _contexts.Any(c => GetIdleSeconds() >= (c.Definition.IdleMinutes ?? 5) * 60); +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:70: var name = ctx.Definition.ProcessName; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:70: var processName = ctx.Definition.ProcessName; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:56: .Select(c => c.Definition.QueryPollIntervalSecs ?? 60) +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:64: var sql = ctx.Definition.SqlQuery; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:32: var path = ctx.Definition.WatchPath; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:42: var pattern = ctx.Definition.FilePattern ?? "*"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:57: var events = ctx.Definition.FileEvents == 0 ? FileWatchEvent.Any : ctx.Definition.FileEvents; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:76: var host = ctx.Definition.HostName; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:79: var port = ctx.Definition.HostPort; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:63: var requiredState = ctx.Definition.NetworkState; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:69: var ssid = ctx.Definition.NetworkSsid; diff --git a/typed-props-search.txt b/typed-props-search.txt new file mode 100644 index 00000000..445d4040 --- /dev/null +++ b/typed-props-search.txt @@ -0,0 +1,126 @@ +=== CronExpression === +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:43: request.CronExpression, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:61: CronExpression = request.CronExpression, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:96: var effectiveCron = request.CronExpression ?? entity.CronExpression; +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:101: if (request.CronExpression is not null || request.CronTimezone is not null || +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:115: if (request.CronExpression is not null) entity.CronExpression = request.CronExpression; +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:168: if (!string.IsNullOrEmpty(entity.CronExpression)) +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:171: entity.CronExpression, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:200: if (entity is null || string.IsNullOrEmpty(entity.CronExpression)) +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:204: .GetNextOccurrences(entity.CronExpression, DateTimeOffset.UtcNow, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:208: return new CronPreviewResponse(entity.CronExpression, entity.CronTimezone, occurrences); +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:290: e.RepeatInterval, e.CronExpression, e.CronTimezone, e.MissedFirePolicy, +DefaultModules\AgentOrchestration\Services\ScheduledJobWorker.cs:160: if (!string.IsNullOrEmpty(job.CronExpression)) +DefaultModules\AgentOrchestration\Services\ScheduledJobWorker.cs:163: job.CronExpression, now, job.CronTimezone); +DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:647: task.CronExpression, +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:570: Set(nameof(t.CronExpression), t.CronExpression); +=== CronTimezone === +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:44: request.CronTimezone, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:62: CronTimezone = request.CronTimezone, +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:97: var effectiveTz = request.CronTimezone ?? entity.CronTimezone; +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:101: if (request.CronExpression is not null || request.CronTimezone is not null || +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:116: if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone; +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:173: entity.CronTimezone); +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:205: entity.CronTimezone, count) +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:208: return new CronPreviewResponse(entity.CronExpression, entity.CronTimezone, occurrences); +DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:290: e.RepeatInterval, e.CronExpression, e.CronTimezone, e.MissedFirePolicy, +DefaultModules\AgentOrchestration\Services\ScheduledJobWorker.cs:163: job.CronExpression, now, job.CronTimezone); +DefaultModules\AgentOrchestration\AgentOrchestrationModule.cs:648: task.CronTimezone, +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:571: Set(nameof(t.CronTimezone), t.CronTimezone); +=== EventType === +DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:51: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType), +DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:93: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:572: Set(nameof(t.EventType), t.EventType); +=== EventFilter === +DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:58: ? def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter) +DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:97: def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:573: Set(nameof(t.EventFilter), t.EventFilter); +=== WatchPath === +DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:32: var path = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath); +DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:83: ? def.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:574: Set(nameof(t.WatchPath), t.WatchPath); +=== FilePattern === +DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:42: var pattern = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FilePattern) ?? "*"; +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:575: Set(nameof(t.FilePattern), t.FilePattern); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:138: t.FilePattern.Should().Be("*.json"); +=== FileEvents === +DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:57: var rawEvents = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FileEvents); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:576: if (t.FileEvents != default) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:577: p[nameof(t.FileEvents)] = t.FileEvents.ToString(); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:139: t.FileEvents.Should().Be(FileWatchEvent.Created | FileWatchEvent.Deleted); +=== ProcessName === +DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:70: var name = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName); +DefaultModules\ComputerUse\Triggers\ProcessTriggerSource.cs:99: .Select(p => p.ProcessName) +DefaultModules\ComputerUse\Triggers\WindowFocusTriggerSource.cs:70: var processName = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName); +DefaultModules\ComputerUse\DesktopAwarenessService.cs:97: processName = process.ProcessName, +DefaultModules\ComputerUse\DesktopAwarenessService.cs:136: processName = process.ProcessName; +DefaultModules\ComputerUse\DesktopAwarenessService.cs:226: if (!proc.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) +DefaultModules\ModuleDev\Services\ProcessInspectionService.cs:71: process.Id, process.ProcessName, modules, windowClasses, threads, null); +mk8.shell\Engine\Mk8ShellExecutor.cs:832: .OrderBy(p => p.ProcessName, StringComparer.OrdinalIgnoreCase) +mk8.shell\Engine\Mk8ShellExecutor.cs:836: try { return $"{p.Id}\t{p.ProcessName}"; } +mk8.shell\Engine\Mk8ShellExecutor.cs:2096: .Where(p => p.ProcessName.Contains(name, StringComparison.OrdinalIgnoreCase)) +mk8.shell\Engine\Mk8ShellExecutor.cs:2097: .OrderBy(p => p.ProcessName)) +mk8.shell\Engine\Mk8ShellExecutor.cs:2102: sb.AppendLine($"{proc.Id}\t{proc.ProcessName}\t{proc.WorkingSet64 / 1024}KB"); +mk8.shell\Engine\Mk8ShellExecutor.cs:2106: sb.AppendLine($"{proc.Id}\t{proc.ProcessName}\t(access denied)"); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:578: Set(nameof(t.ProcessName), t.ProcessName); +=== WebhookRoute === +DefaultModules\Http\WebhookTriggerSource.cs:64: var route = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute); +DefaultModules\Http\WebhookTriggerSource.cs:99: ? def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:579: Set(nameof(t.WebhookRoute), t.WebhookRoute); +SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:298: [HttpTriggerKeys.WebhookRoute] = route, +SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:360: [HttpTriggerKeys.WebhookRoute] = route, +=== WebhookSecretEnvVar === +DefaultModules\Http\WebhookTriggerSource.cs:120: var secretEnvVar = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSecretEnvVar); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:580: Set(nameof(t.WebhookSecretEnvVar), t.WebhookSecretEnvVar); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:191: t.WebhookSecretEnvVar.Should().Be("GH_SECRET"); +SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:301: parameters[HttpTriggerKeys.WebhookSecretEnvVar] = secretEnvVar; +=== WebhookSignatureHeader === +DefaultModules\Http\WebhookTriggerSource.cs:132: var configuredSigHeader = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSignatureHeader); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:581: Set(nameof(t.WebhookSignatureHeader), t.WebhookSignatureHeader); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:192: t.WebhookSignatureHeader.Should().Be("X-Hub-Signature-256"); +SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:303: parameters[HttpTriggerKeys.WebhookSignatureHeader] = signatureHeader; +=== HostName === +DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:58: def.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName), +DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:77: var host = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:582: Set(nameof(t.HostName), t.HostName); +=== HostPort === +DefaultModules\NetworkTriggers\HostProbeTriggerSource.cs:80: var port = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostPort)); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:583: if (t.HostPort.HasValue) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:584: p[nameof(t.HostPort)] = t.HostPort.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:217: t.HostPort.Should().Be(5432); +=== SourceTaskName === +DefaultModules\AgentOrchestration\TaskScripting\TaskChainTriggerSource.cs:46: def.Parameters.GetValueOrDefault(TaskScriptingTriggerKeys.SourceTaskName); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:585: Set(nameof(t.SourceTaskName), t.SourceTaskName); +=== HotkeyCombo === +DefaultModules\ComputerUse\Triggers\HotkeyTriggerSource.cs:66: var combo = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.HotkeyCombo); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:586: Set(nameof(t.HotkeyCombo), t.HotkeyCombo); +=== IdleMinutes === +DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:69: var minutes = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5; +DefaultModules\ComputerUse\Triggers\IdleTriggerSource.cs:82: var m = TryParseInt(c.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5; +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:587: if (t.IdleMinutes.HasValue) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:588: p[nameof(t.IdleMinutes)] = t.IdleMinutes.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +=== NetworkSsid === +DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:53: ? def.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid) +DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:71: var ssid = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:589: Set(nameof(t.NetworkSsid), t.NetworkSsid); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:367: t.NetworkSsid.Should().Be("HomeNet"); +=== NetworkState === +DefaultModules\NetworkTriggers\NetworkTriggerSource.cs:65: var requiredState = ParseState(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkState)); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:590: if (t.NetworkState != default) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:591: p[nameof(t.NetworkState)] = t.NetworkState.ToString(); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:368: t.NetworkState.Should().Be(NetworkState.Connected); +=== DeviceClass === +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:592: Set(nameof(t.DeviceClass), t.DeviceClass); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:381: t.DeviceClass.Should().Be("USB"); +=== DeviceNamePattern === +DefaultModules\ComputerUse\Triggers\DeviceTriggerSource.cs:158: var pattern = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.DeviceNamePattern); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:593: Set(nameof(t.DeviceNamePattern), t.DeviceNamePattern); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:382: t.DeviceNamePattern.Should().Be("YubiKey*"); +=== SqlQuery === +DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:64: var sql = ctx.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.SqlQuery); +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:594: Set(nameof(t.SqlQuery), t.SqlQuery); +=== QueryPollIntervalSecs === +DefaultModules\DatabaseAccess\Triggers\QueryRowsTriggerSource.cs:56: .Select(c => TryParseInt(c.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.QueryPollIntervalSecs)) ?? 60) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:595: if (t.QueryPollIntervalSecs.HasValue) +SharpClaw.Application.Infrastructure.Tasks\Parsing\TaskScriptParser.cs:596: p[nameof(t.QueryPollIntervalSecs)] = t.QueryPollIntervalSecs.Value.ToString(System.Globalization.CultureInfo.InvariantCulture); +SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:417: .Which.QueryPollIntervalSecs.Should().Be(30); diff --git a/writers.txt b/writers.txt new file mode 100644 index 00000000..251d1e59 --- /dev/null +++ b/writers.txt @@ -0,0 +1,87 @@ +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:19: public const string EventType = "EventType"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\AgentOrchestrationTriggerKeys.cs:22: public const string EventFilter = "EventFilter"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\EventBusTriggerSource.cs:92: var eventType = +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:61: CronExpression = request.CronExpression, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:62: CronTimezone = request.CronTimezone, +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:115: if (request.CronExpression is not null) entity.CronExpression = request.CronExpression; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\Services\ScheduledJobService.cs:116: if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:38: EventType = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:39: EventFilter = context.GetNamedStringArg("Filter"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:49: CronExpression = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:50: CronTimezone = context.GetNamedStringArg("Timezone"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:72: SourceTaskName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:82: SourceTaskName = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\AgentOrchestrationTriggerAttributeHandlers.cs:92: CustomSourceFilter = context.GetNamedStringArg("Filter"), +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:34: public const string SourceTaskName = "SourceTaskName"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:37: public const string CronExpression = "CronExpression"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:40: public const string CronTimezone = "CronTimezone"; +E:\source\SharpClaw\DefaultModules\AgentOrchestration\TaskScripting\TaskScriptingTriggerKeys.cs:43: public const string CustomSourceFilter = "CustomSourceFilter"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:99: HotkeyCombo = combo, +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:112: IdleMinutes = context.GetNamedIntArg("Minutes") ?? context.GetIntArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:123: DeviceClass = context.GetNamedStringArg("Class"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:124: DeviceNamePattern = context.GetNamedStringArg("Pattern"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:134: ShortcutLabel = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:135: ShortcutIcon = context.GetNamedStringArg("Icon"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerAttributeHandlers.cs:136: ShortcutCategory = context.GetNamedStringArg("Category"), +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:29: public const string HotkeyCombo = "HotkeyCombo"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:30: public const string IdleMinutes = "IdleMinutes"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:31: public const string DeviceClass = "DeviceClass"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\ComputerUseTriggerKeys.cs:32: public const string DeviceNamePattern = "DeviceNamePattern"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:20: public const string ShortcutLabel = "ShortcutLabel"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:26: public const string ShortcutIcon = "ShortcutIcon"; +E:\source\SharpClaw\DefaultModules\ComputerUse\Triggers\OsShortcutTriggerKeys.cs:32: public const string ShortcutCategory = "ShortcutCategory"; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerAttributeHandlers.cs:37: SqlQuery = query, +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerAttributeHandlers.cs:38: QueryPollIntervalSecs = interval, +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerKeys.cs:15: public const string SqlQuery = "SqlQuery"; +E:\source\SharpClaw\DefaultModules\DatabaseAccess\Triggers\DatabaseAccessTriggerKeys.cs:16: public const string QueryPollIntervalSecs = "QueryPollIntervalSecs"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FileChangedTriggerSource.cs:57: var events = ctx.Definition.FileEvents == 0 ? FileWatchEvent.Any : ctx.Definition.FileEvents; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:24: WatchPath = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:25: FilePattern = context.GetNamedStringArg("Pattern"), +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerAttributeHandlers.cs:26: FileEvents = context.GetNamedEnumArg("Events") ?? FileWatchEvent.Any, +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:15: public const string WatchPath = "WatchPath"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:16: public const string FilePattern = "FilePattern"; +E:\source\SharpClaw\DefaultModules\FilesystemTriggers\FilesystemTriggerKeys.cs:17: public const string FileEvents = "FileEvents"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:26: WebhookRoute = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:27: WebhookSecretEnvVar = context.GetNamedStringArg("Secret"), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerAttributeHandlers.cs:28: WebhookSignatureHeader = context.GetNamedStringArg("SignatureHeader"), +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:15: public const string WebhookRoute = "WebhookRoute"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:16: public const string WebhookSecretEnvVar = "WebhookSecretEnvVar"; +E:\source\SharpClaw\DefaultModules\Http\HttpTriggerKeys.cs:17: public const string WebhookSignatureHeader = "WebhookSignatureHeader"; +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:24: MetricSource = context.GetStringArg(0), +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:25: MetricThreshold = context.GetNamedDoubleArg("Threshold"), +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:26: MetricDirection = context.GetNamedEnumArg("Direction") ?? ThresholdDirection.Either, +E:\source\SharpClaw\DefaultModules\Metrics\MetricsTriggerAttributeHandlers.cs:27: MetricPollIntervalSecs = context.GetNamedIntArg("PollInterval"), +E:\source\SharpClaw\DefaultModules\Metrics\MetricTriggerKeys.cs:10: public const string MetricThreshold = "MetricThreshold"; +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:115: var filePattern = pattern +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:119: if (string.IsNullOrWhiteSpace(filePattern) || filePattern == "**") +E:\source\SharpClaw\DefaultModules\ModuleDev\Services\ModuleWorkspaceService.cs:120: filePattern = "*"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:29: HostPort = context.GetNamedIntArg("Port"), +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerAttributeHandlers.cs:39: NetworkSsid = context.GetNamedStringArg("Ssid"), +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:16: public const string HostPort = "HostPort"; +E:\source\SharpClaw\DefaultModules\NetworkTriggers\NetworkTriggerKeys.cs:17: public const string NetworkSsid = "NetworkSsid"; +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:18: string? CronExpression = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:19: string? CronTimezone = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:32: string? CronExpression = null, +E:\source\SharpClaw\SharpClaw.Contracts\DTOs\Tasks\ScheduledJobDtos.cs:33: string? CronTimezone = null, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:69: CronExpression = "0 9 * * MON-FRI", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:70: CronTimezone = (string?)null, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:82: CronExpression = "0 9 * * *", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:83: CronTimezone = "America/New_York", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:99: EventType = "ModelAdded", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:111: EventType = "ModelAdded", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:112: EventFilter = "provider=openai", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:128: WatchPath = "/tmp/data", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:129: FileEvents = FileWatchEvent.Any, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:182: WebhookRoute = "/hook/deploy", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:232: SourceTaskName = "IngestData", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:244: SourceTaskName = "IngestData", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:288: HotkeyCombo = "Ctrl+Shift+F10", +E:\source\SharpClaw\SharpClaw.Tests\Tasks\TaskTriggerAttributeParserTests.cs:318: IdleMinutes = 15, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:299: WebhookRoute = route, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:300: WebhookSecretEnvVar = secretEnvVar, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:301: WebhookSignatureHeader = signatureHeader, +E:\source\SharpClaw\SharpClaw.Tests\Tasks\WebhookTriggerSourceTests.cs:351: WebhookRoute = route, +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:663: string? currentEventType = null; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:685: if (line.Length == 0) { currentEventType = null; continue; } +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:690: currentEventType = line[7..]; +E:\source\SharpClaw\SharpClaw.Uno\Presentation\MainPage.Chat.cs:698: currentEventType = null; From b5a00d055086dc20de1a46c21733831dad07391c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 14:27:32 +0000 Subject: [PATCH 2/2] Bump LLamaSharp from 0.26.0 to 0.27.0 --- updated-dependencies: - dependency-name: LLamaSharp dependency-version: 0.27.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 401e8fb3..d1c4794a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,7 +37,7 @@ - +