From b5bf97ae747dfdf6e21db444796d7e919e7f612f Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 10 Aug 2026 10:48:34 +0200 Subject: [PATCH] feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar --- ClawdDotNet.csproj | 13 + ClawdDotNet.slnx | 15 +- Directory.Build.props | 24 + Models/AppSettings.cs | 82 --- Models/InstanceSettingsViewModel.cs | 39 ++ NuGet.Config | 12 + Program.cs | 101 ++- Services/BackupScheduler.cs | 147 ----- Services/SettingsManager.cs | 64 -- UI/BackupPanel.cs | 2 +- docs/Agentenkommunikation-Konzept.md | 318 ++++++++++ docs/Audit-Konzept.md | 91 +++ docs/Avalonia-Portierungsleitfaden.md | 186 ++++++ docs/Bestandsaufnahme-2026-07.md | 11 +- docs/Deploymentcenter-Anbindung-Review.md | 308 ++++++++++ docs/Deploymentcenter-Integration.md | 338 +++++++++++ docs/Konzepte-Backup-Finanz-Analyse.md | 8 +- docs/Linux-Portierung-Analyse.md | 574 ++++++++++++++++++ ...HardwareId-v2-Implementierungsvorschlag.md | 520 ++++++++++++++++ docs/Roadmap.md | 301 +++++++++ docs/RocketChat-Nextcloud-Konzept.md | 544 +++++++++++++++++ docs/Staging-Konzept.md | 108 ++++ docs/Taskboard-Konzept.md | 334 ++++++++++ frm_main.cs | 124 ++-- src/ClawdDotNet.App/AppHost.cs | 485 +++++++++++++++ src/ClawdDotNet.App/ClawdDotNet.App.csproj | 49 ++ .../ClawdDotNet.App/Models}/AgentListEntry.cs | 2 +- .../ClawdDotNet.App/Models}/InstanceInfo.cs | 2 +- .../Models}/JobHistoryEntry.cs | 2 +- .../Models}/TokenUsageRecord.cs | 2 +- .../Models/ToolSettingsViewModels.cs | 566 +++++++++++++++++ .../Services/BackupScheduler.cs | 169 ++++++ .../Services/DeploymentcenterService.cs | 269 ++++++++ .../Services/ILicensePrompt.cs | 93 +++ .../Services}/InstanceDirectoryManager.cs | 4 +- .../Services}/JobHistoryService.cs | 4 +- src/ClawdDotNet.App/Services/LicenseGate.cs | 237 ++++++++ src/ClawdDotNet.App/Services/LicenseInfo.cs | 38 ++ src/ClawdDotNet.App/Services/LicenseWatch.cs | 110 ++++ src/ClawdDotNet.App/Services/LogTail.cs | 154 +++++ .../Services}/OpenRouterStatusService.cs | 49 +- src/ClawdDotNet.App/Settings/AppSettings.cs | 153 +++++ .../Settings/SettingsManager.cs | 124 ++++ src/ClawdDotNet.Core/Audit/AuditModels.cs | 110 ++++ .../Audit/IAuditRepository.cs | 29 + .../Audit/SqliteAuditRepository.cs | 182 ++++++ src/ClawdDotNet.Core/Backup/BackupService.cs | 9 +- src/ClawdDotNet.Core/Config/AgentConfig.cs | 8 + src/ClawdDotNet.Core/Config/InstanceConfig.cs | 4 + src/ClawdDotNet.Core/Config/ServiceConfig.cs | 12 + src/ClawdDotNet.Core/Config/WatchdogConfig.cs | 60 ++ .../Deploymentcenter/BugtrackerClient.cs | 89 +++ .../Deploymentcenter/DeploymentcenterApi.cs | 172 ++++++ .../Deploymentcenter/ErrorReporter.cs | 170 ++++++ .../Deploymentcenter/TokenProvisioner.cs | 73 +++ .../Watchdog/InstanceHealth.cs | 54 ++ .../Watchdog/InstanceHealthProvider.cs | 128 ++++ .../Watchdog/WatchdogClient.cs | 174 ++++++ .../Watchdog/WatchdogHeartbeatService.cs | 197 ++++++ src/ClawdDotNet.Core/Engine/AgentEngine.cs | 265 +++++++- src/ClawdDotNet.Core/Engine/ChatEntry.cs | 1 + .../Scheduling/AgentScheduler.cs | 143 ----- .../Scheduling/CronExpression.cs | 34 +- src/ClawdDotNet.Core/Scheduling/TimeZones.cs | 96 +++ .../Scheduling/ToolJobScheduler.cs | 213 ------- .../Security/ConfigSecrets.cs | 3 + .../Security/SecretKeyStore.cs | 107 ++++ .../Security/SecretProtector.cs | 147 ++++- .../Staging/SqliteStagingRepository.cs | 139 +++++ src/ClawdDotNet.Core/Staging/StagingGate.cs | 119 ++++ src/ClawdDotNet.Core/Staging/StagingModels.cs | 95 +++ src/ClawdDotNet.Core/Staging/StagingPolicy.cs | 52 ++ .../Staging/StagingService.cs | 156 +++++ src/ClawdDotNet.Core/Storage/AppPaths.cs | 123 ++++ src/ClawdDotNet.Core/Storage/AtomicFile.cs | 6 +- src/ClawdDotNet.Core/Storage/PathBoundary.cs | 155 +++++ .../Storage/PortableFileName.cs | 55 ++ src/ClawdDotNet.Core/Storage/SqliteStorage.cs | 106 ++++ src/ClawdDotNet.Core/Storage/SystemShell.cs | 101 +++ .../Tasks/CoordinationMigration.cs | 123 ++++ .../Tasks/EngineTaskDispatcher.cs | 171 ++++++ src/ClawdDotNet.Core/Tasks/ITaskRepository.cs | 65 ++ .../Tasks/SchedulerTaskMigration.cs | 106 ++++ .../Tasks/SqliteTaskRepository.cs | 318 ++++++++++ src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs | 313 ++++++++++ src/ClawdDotNet.Core/Tasks/TaskModels.cs | 233 +++++++ src/ClawdDotNet.Core/Tasks/TaskScanner.cs | 262 ++++++++ src/ClawdDotNet.Core/Tasks/TaskSchedule.cs | 205 +++++++ src/ClawdDotNet.Core/Tasks/TaskText.cs | 70 +++ .../Tasks/TaskboardService.cs | 186 ++++++ .../Tools/AgentToolContext.cs | 4 +- src/ClawdDotNet.Desktop/App.axaml | 17 + src/ClawdDotNet.Desktop/App.axaml.cs | 213 +++++++ src/ClawdDotNet.Desktop/Assets/app.ico | Bin 0 -> 177470 bytes .../ClawdDotNet.Desktop.csproj | 38 ++ .../Converters/ChatBubbleBrushConverter.cs | 65 ++ .../Converters/LogLevelBrushConverter.cs | 33 + src/ClawdDotNet.Desktop/Program.cs | 33 + .../Services/AvaloniaLicensePrompt.cs | 85 +++ src/ClawdDotNet.Desktop/Styles/Shell.axaml | 44 ++ src/ClawdDotNet.Desktop/ViewLocator.cs | 40 ++ .../ViewModels/AddAgentViewModel.cs | 76 +++ .../ViewModels/AddJobViewModel.cs | 111 ++++ .../ViewModels/AddServiceViewModel.cs | 67 ++ .../ViewModels/AgentsPageViewModel.cs | 280 +++++++++ .../ViewModels/BackupPageViewModel.cs | 397 ++++++++++++ .../ViewModels/ChatPageViewModel.cs | 158 +++++ .../ViewModels/InfoPageViewModel.cs | 89 +++ .../ViewModels/InstancePickerViewModel.cs | 103 ++++ .../ViewModels/LicenseViewModel.cs | 46 ++ .../ViewModels/LogPageViewModel.cs | 112 ++++ .../ViewModels/MainWindowViewModel.cs | 102 ++++ .../ViewModels/RestoreBackupViewModel.cs | 77 +++ .../ViewModels/SettingsPageViewModel.cs | 183 ++++++ .../ViewModels/TasksPageViewModel.cs | 446 ++++++++++++++ .../ViewModels/ToolSettingsViewModel.cs | 221 +++++++ .../ViewModels/ViewModelBase.cs | 15 + .../Views/AddAgentWindow.axaml | 51 ++ .../Views/AddAgentWindow.axaml.cs | 21 + .../Views/AddJobWindow.axaml | 80 +++ .../Views/AddJobWindow.axaml.cs | 21 + .../Views/AddServiceWindow.axaml | 53 ++ .../Views/AddServiceWindow.axaml.cs | 21 + .../Views/AgentsPageView.axaml | 155 +++++ .../Views/AgentsPageView.axaml.cs | 46 ++ .../Views/BackupPageView.axaml | 100 +++ .../Views/BackupPageView.axaml.cs | 56 ++ .../Views/ChatPageView.axaml | 74 +++ .../Views/ChatPageView.axaml.cs | 25 + .../Views/InfoPageView.axaml | 71 +++ .../Views/InfoPageView.axaml.cs | 11 + .../Views/InstancePickerWindow.axaml | 73 +++ .../Views/InstancePickerWindow.axaml.cs | 24 + .../Views/LicenseWindow.axaml | 45 ++ .../Views/LicenseWindow.axaml.cs | 16 + .../Views/LogPageView.axaml | 67 ++ .../Views/LogPageView.axaml.cs | 21 + .../Views/MainWindow.axaml | 72 +++ .../Views/MainWindow.axaml.cs | 28 + .../Views/PlaceholderPageView.axaml | 19 + .../Views/PlaceholderPageView.axaml.cs | 8 + .../Views/RestoreBackupWindow.axaml | 66 ++ .../Views/RestoreBackupWindow.axaml.cs | 42 ++ .../Views/SettingsPageView.axaml | 100 +++ .../Views/SettingsPageView.axaml.cs | 11 + .../Views/TasksPageView.axaml | 102 ++++ .../Views/TasksPageView.axaml.cs | 46 ++ .../Views/ToolSettingsWindow.axaml | 180 ++++++ .../Views/ToolSettingsWindow.axaml.cs | 21 + src/ClawdDotNet.Tools.FTP/FtpTool.cs | 10 +- src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs | 41 +- .../SocialMediaManagerTool.cs | 40 +- .../ClawdDotNet.Tools.Taskboard.csproj | 14 + .../TaskboardTool.cs | 374 ++++++++++++ .../Audit/AuditRepositoryTests.cs | 127 ++++ .../Audit/EngineAuditTests.cs | 125 ++++ .../Backup/BackupServiceTests.cs | 5 + .../Deploymentcenter/CapturingHandler.cs | 32 + .../DeploymentcenterApiTests.cs | 69 +++ .../Deploymentcenter/ErrorReporterTests.cs | 97 +++ .../InstanceHealthProviderTests.cs | 107 ++++ .../Deploymentcenter/TokenProvisionerTests.cs | 50 ++ .../Deploymentcenter/WatchdogClientTests.cs | 137 +++++ .../Engine/RequestShapeTests.cs | 38 ++ .../Infrastructure/EngineFixture.cs | 11 +- .../Infrastructure/FakeTimeProvider.cs | 19 + .../Infrastructure/InMemoryAuditRepository.cs | 40 ++ .../InMemoryStagingRepository.cs | 52 ++ .../Infrastructure/PlatformFacts.cs | 31 + .../Infrastructure/SecretKeyCollection.cs | 33 + .../Scheduling/CronExpressionTests.cs | 90 +++ .../Scheduling/TimeZonesTests.cs | 88 +++ .../Security/SecretProtectorTests.cs | 138 ++++- .../Staging/EngineStagingTests.cs | 96 +++ .../Staging/StagingPolicyTests.cs | 49 ++ .../Staging/StagingRepositoryTests.cs | 96 +++ .../Staging/StagingServiceTests.cs | 156 +++++ .../Storage/PathBoundaryTests.cs | 152 +++++ .../Tasks/CoordinationMigrationTests.cs | 118 ++++ .../Tasks/TaskAssigneeTests.cs | 27 + .../Tasks/TaskFrontmatterTests.cs | 188 ++++++ .../Tasks/TaskRepositoryTests.cs | 310 ++++++++++ .../Tasks/TaskScannerTests.cs | 248 ++++++++ .../Tasks/TaskScheduleTests.cs | 246 ++++++++ .../Tasks/TaskboardServiceTests.cs | 163 +++++ .../Tasks/ToolJobDispatchTests.cs | 93 +++ .../FileRW/WorkspacePathTests.cs | 30 +- 187 files changed, 20054 insertions(+), 882 deletions(-) create mode 100644 Directory.Build.props delete mode 100644 Models/AppSettings.cs delete mode 100644 Services/BackupScheduler.cs delete mode 100644 Services/SettingsManager.cs create mode 100644 docs/Agentenkommunikation-Konzept.md create mode 100644 docs/Audit-Konzept.md create mode 100644 docs/Avalonia-Portierungsleitfaden.md create mode 100644 docs/Deploymentcenter-Anbindung-Review.md create mode 100644 docs/Deploymentcenter-Integration.md create mode 100644 docs/Linux-Portierung-Analyse.md create mode 100644 docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md create mode 100644 docs/Roadmap.md create mode 100644 docs/RocketChat-Nextcloud-Konzept.md create mode 100644 docs/Staging-Konzept.md create mode 100644 docs/Taskboard-Konzept.md create mode 100644 src/ClawdDotNet.App/AppHost.cs create mode 100644 src/ClawdDotNet.App/ClawdDotNet.App.csproj rename {Models => src/ClawdDotNet.App/Models}/AgentListEntry.cs (95%) rename {Models => src/ClawdDotNet.App/Models}/InstanceInfo.cs (91%) rename {Models => src/ClawdDotNet.App/Models}/JobHistoryEntry.cs (94%) rename {Models => src/ClawdDotNet.App/Models}/TokenUsageRecord.cs (97%) create mode 100644 src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs create mode 100644 src/ClawdDotNet.App/Services/BackupScheduler.cs create mode 100644 src/ClawdDotNet.App/Services/DeploymentcenterService.cs create mode 100644 src/ClawdDotNet.App/Services/ILicensePrompt.cs rename {Services => src/ClawdDotNet.App/Services}/InstanceDirectoryManager.cs (99%) rename {Services => src/ClawdDotNet.App/Services}/JobHistoryService.cs (97%) create mode 100644 src/ClawdDotNet.App/Services/LicenseGate.cs create mode 100644 src/ClawdDotNet.App/Services/LicenseInfo.cs create mode 100644 src/ClawdDotNet.App/Services/LicenseWatch.cs create mode 100644 src/ClawdDotNet.App/Services/LogTail.cs rename {Services => src/ClawdDotNet.App/Services}/OpenRouterStatusService.cs (84%) create mode 100644 src/ClawdDotNet.App/Settings/AppSettings.cs create mode 100644 src/ClawdDotNet.App/Settings/SettingsManager.cs create mode 100644 src/ClawdDotNet.Core/Audit/AuditModels.cs create mode 100644 src/ClawdDotNet.Core/Audit/IAuditRepository.cs create mode 100644 src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs create mode 100644 src/ClawdDotNet.Core/Config/WatchdogConfig.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs create mode 100644 src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs delete mode 100644 src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs create mode 100644 src/ClawdDotNet.Core/Scheduling/TimeZones.cs delete mode 100644 src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs create mode 100644 src/ClawdDotNet.Core/Security/SecretKeyStore.cs create mode 100644 src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs create mode 100644 src/ClawdDotNet.Core/Staging/StagingGate.cs create mode 100644 src/ClawdDotNet.Core/Staging/StagingModels.cs create mode 100644 src/ClawdDotNet.Core/Staging/StagingPolicy.cs create mode 100644 src/ClawdDotNet.Core/Staging/StagingService.cs create mode 100644 src/ClawdDotNet.Core/Storage/AppPaths.cs create mode 100644 src/ClawdDotNet.Core/Storage/PathBoundary.cs create mode 100644 src/ClawdDotNet.Core/Storage/PortableFileName.cs create mode 100644 src/ClawdDotNet.Core/Storage/SystemShell.cs create mode 100644 src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs create mode 100644 src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs create mode 100644 src/ClawdDotNet.Core/Tasks/ITaskRepository.cs create mode 100644 src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs create mode 100644 src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskModels.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskScanner.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskSchedule.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskText.cs create mode 100644 src/ClawdDotNet.Core/Tasks/TaskboardService.cs create mode 100644 src/ClawdDotNet.Desktop/App.axaml create mode 100644 src/ClawdDotNet.Desktop/App.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Assets/app.ico create mode 100644 src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj create mode 100644 src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs create mode 100644 src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs create mode 100644 src/ClawdDotNet.Desktop/Program.cs create mode 100644 src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs create mode 100644 src/ClawdDotNet.Desktop/Styles/Shell.axaml create mode 100644 src/ClawdDotNet.Desktop/ViewLocator.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs create mode 100644 src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs create mode 100644 src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/AddJobWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/AddServiceWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/AgentsPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/BackupPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/BackupPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/ChatPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/ChatPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/InfoPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/InfoPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/InstancePickerWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/LicenseWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/LogPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/LogPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/MainWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/PlaceholderPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/RestoreBackupWindow.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/TasksPageView.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/TasksPageView.axaml.cs create mode 100644 src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml create mode 100644 src/ClawdDotNet.Desktop/Views/ToolSettingsWindow.axaml.cs create mode 100644 src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj create mode 100644 src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Audit/AuditRepositoryTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/CapturingHandler.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/DeploymentcenterApiTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/ErrorReporterTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/InstanceHealthProviderTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/TokenProvisionerTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Deploymentcenter/WatchdogClientTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Engine/RequestShapeTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTimeProvider.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryStagingRepository.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/PlatformFacts.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Infrastructure/SecretKeyCollection.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Scheduling/CronExpressionTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Scheduling/TimeZonesTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Staging/EngineStagingTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Staging/StagingPolicyTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Storage/PathBoundaryTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskAssigneeTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskFrontmatterTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskScheduleTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs create mode 100644 tests/ClawdDotNet.Core.Tests/Tasks/ToolJobDispatchTests.cs diff --git a/ClawdDotNet.csproj b/ClawdDotNet.csproj index add38ea..145952a 100644 --- a/ClawdDotNet.csproj +++ b/ClawdDotNet.csproj @@ -34,6 +34,14 @@ + + + + + @@ -43,6 +51,7 @@ + @@ -57,7 +66,11 @@ + + diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx index 2ee4af4..24ceb0a 100644 --- a/ClawdDotNet.slnx +++ b/ClawdDotNet.slnx @@ -1,5 +1,7 @@ + + @@ -15,10 +17,21 @@ + - + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..fbc3730 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,24 @@ + + + + + 0.1.0 + + + diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs deleted file mode 100644 index 4917a71..0000000 --- a/Models/AppSettings.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.ComponentModel; -using System.Text.Json.Serialization; - -namespace ClawdDotNet.Models; - -[TypeConverter(typeof(ExpandableObjectConverter))] -public sealed class AppSettings -{ - [Category("Allgemein")] - [DisplayName("Log-Verzeichnis")] - [Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")] - [JsonPropertyName("logDirectory")] - public string LogDirectory { get; set; } = "./Logs"; - - [Category("Allgemein")] - [DisplayName("Instanzen-Verzeichnis")] - [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")] - [JsonPropertyName("instancesDirectory")] - public string InstancesDirectory { get; set; } = "./Instances"; - - [Category("Allgemein")] - [DisplayName("Standard-Konfigurations-Datei")] - [Description("Pfad zur Standard-Instanz-Konfiguration (Legacy). Neue Instanzen nutzen das Instanzen-Verzeichnis.")] - [JsonPropertyName("defaultConfigPath")] - public string DefaultConfigPath { get; set; } = "./configs/config.json"; - - [Category("Allgemein")] - [DisplayName("Minimaler Log-Level")] - [Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")] - [JsonPropertyName("minimumLogLevel")] - public string MinimumLogLevel { get; set; } = "Info"; - - [Category("UI")] - [DisplayName("Max. Log-Zeilen in UI")] - [Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")] - [JsonPropertyName("maxLogLinesInUi")] - public int MaxLogLinesInUi { get; set; } = 2000; - - [Category("UI")] - [DisplayName("Log-Aktualisierungsintervall (ms)")] - [Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")] - [JsonPropertyName("logRefreshIntervalMs")] - public int LogRefreshIntervalMs { get; set; } = 500; - - [Category("API")] - [DisplayName("Status-Check-Intervall (Sek)")] - [Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")] - [JsonPropertyName("statusCheckIntervalSeconds")] - public int StatusCheckIntervalSeconds { get; set; } = 60; - - [Category("API")] - [DisplayName("OpenRouter Base-URL")] - [Description("Basis-URL der OpenRouter-API.")] - [JsonPropertyName("openRouterBaseUrl")] - public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/"; - - [Category("Backup")] - [DisplayName("Backup-Verzeichnis")] - [Description("Ordner, in dem Sicherungen abgelegt werden.")] - [JsonPropertyName("backupDirectory")] - public string BackupDirectory { get; set; } = "./Backups"; - - [Category("Backup")] - [DisplayName("Automatisch sichern")] - [Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")] - [JsonPropertyName("autoBackupEnabled")] - public bool AutoBackupEnabled { get; set; } - - [Category("Backup")] - [DisplayName("Uhrzeit der automatischen Sicherung")] - [Description("Tageszeit im Format HH:mm.")] - [JsonPropertyName("autoBackupTime")] - public string AutoBackupTime { get; set; } = "03:00"; - - [Category("Backup")] - [DisplayName("Aufbewahrte Sicherungen")] - [Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")] - [JsonPropertyName("backupKeepCount")] - public int BackupKeepCount { get; set; } = 14; - - public override string ToString() => "Anwendungseinstellungen"; -} diff --git a/Models/InstanceSettingsViewModel.cs b/Models/InstanceSettingsViewModel.cs index 6d31361..603b4a1 100644 --- a/Models/InstanceSettingsViewModel.cs +++ b/Models/InstanceSettingsViewModel.cs @@ -78,6 +78,45 @@ public sealed class InstanceSettingsViewModel [ReadOnly(true)] public int AgentCount => _config.Agents.Count; + // ─── WatchDog (Instanz-Heartbeat) ─── + // Server-URL und Master-Token stehen app-weit in den Anwendungseinstellungen. Die + // Instanz registriert sich damit selbst; hier stehen nur Source/Gruppe/Intervall. + // Ein/Aus läuft über den Service „Instanz-Watchdog" im Worker-Tab. Änderungen greifen + // beim nächsten Start der Instanz. + + [Category("WatchDog")] + [DisplayName("Source")] + [Description("Dienst-Kennung im WatchDog. Alle Instanzen teilen sich dieselbe Source.")] + public string WatchdogSource + { + get => _config.Watchdog.Source; + set => _config.Watchdog.Source = value; + } + + [Category("WatchDog")] + [DisplayName("Gruppe")] + [Description("Gruppierung im WatchDog-Dashboard.")] + public string WatchdogGroup + { + get => _config.Watchdog.Group; + set => _config.Watchdog.Group = value; + } + + [Category("WatchDog")] + [DisplayName("Intervall (Sek)")] + [Description("Sende-Takt der Heartbeats in Sekunden (mindestens 5).")] + public int WatchdogIntervalSeconds + { + get => _config.Watchdog.IntervalSeconds; + set => _config.Watchdog.IntervalSeconds = value; + } + + [Category("WatchDog")] + [DisplayName("Token registriert")] + [Description("Ob diese Instanz bereits einen eigenen Agent-Token vom WatchDog erhalten hat.")] + [ReadOnly(true)] + public bool WatchdogTokenRegistered => _config.Watchdog.HasToken; + [Browsable(false)] public InstanceConfig UnderlyingConfig => _config; diff --git a/NuGet.Config b/NuGet.Config index d293173..d4597c1 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -26,6 +26,18 @@ + + + + + + + + + + + + diff --git a/Program.cs b/Program.cs index 9551f0b..1d792a9 100644 --- a/Program.cs +++ b/Program.cs @@ -3,7 +3,6 @@ using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Logging; using ClawdDotNet.Core.Security; -using ClawdDotNet.Core.Scheduling; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Storage; @@ -106,6 +105,13 @@ internal static class Program instanceConfig.InstanceName, instanceConfig.InstanceId); coreLogger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath); + // ─── 5b. Lizenzprüfung ─── + // Entfällt in dieser Fassung. Startprüfung, laufende Nachprüfung und die + // Deploymentcenter-Anbindung (Watchdog, Updates, Fehler-Stream, Bugtracker) + // liegen in ClawdDotNet.App (AppHost); Einstiegspunkt ist + // src/ClawdDotNet.Desktop. Diese Datei ist nur noch Vorlage — siehe + // ClawdDotNet.slnx. + // ─── 6. Core-Komponenten erzeugen ─── var toolRegistry = new ToolRegistry(); toolRegistry.Register(new FileRWTool()); @@ -121,6 +127,7 @@ internal static class Program toolRegistry.Register(new AgentSpawnTool()); toolRegistry.Register(new AgentEditorTool()); toolRegistry.Register(new ClawdDotNet.Tools.Memory.MemoryTool()); + toolRegistry.Register(new ClawdDotNet.Tools.Taskboard.TaskboardTool()); // ─── 6a. TelegramClient (MTProto User-API) ─── TelegramClientManager? tgClientManager = null; @@ -142,8 +149,9 @@ internal static class Program OpenRouterClient? openRouterClient = null; AgentEngine? agentEngine = null; - AgentScheduler? agentScheduler = null; - ToolJobScheduler? toolJobScheduler = null; + ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null; + ClawdDotNet.Core.Staging.StagingService? stagingService = null; + SqliteUsageRepository? usageRepository = null; if (!string.IsNullOrWhiteSpace(instanceConfig.OpenRouterApiKey)) { @@ -159,7 +167,12 @@ internal static class Program var storage = new SqliteStorage(Path.Combine(instancePath, "state.db")); var stateStore = new SqliteStateStore(storage); var memoryRepository = new SqliteMemoryRepository(storage); - var usageRepository = new SqliteUsageRepository(storage); + var taskRepository = new ClawdDotNet.Core.Tasks.SqliteTaskRepository(storage); + var auditRepository = new ClawdDotNet.Core.Audit.SqliteAuditRepository(storage); + var stagingRepository = new ClawdDotNet.Core.Staging.SqliteStagingRepository(storage); + var stagingGate = new ClawdDotNet.Core.Staging.StagingGate( + new ClawdDotNet.Core.Staging.StagingPolicy(), stagingRepository); + usageRepository = new SqliteUsageRepository(storage); // Preise fürs Budget: Ohne sie greift nur die Token-Grenze. var pricingCatalog = new ModelPricingCatalog(); @@ -171,7 +184,7 @@ internal static class Program agentEngine = new AgentEngine( openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory, - memoryRepository, usageRepository, pricingCatalog) + memoryRepository, usageRepository, pricingCatalog, taskRepository, auditRepository, stagingGate) { InstanceBudget = instanceConfig.Budget }; @@ -185,13 +198,65 @@ internal static class Program return string.IsNullOrWhiteSpace(agent.AgentDir) ? null : agent.AgentDir; }); agentEngine.LoadPersistedChats(); - agentScheduler = new AgentScheduler(agentEngine, instanceConfig.InstanceId, loggerFactory); - agentScheduler.RegisterAll(instanceConfig.Agents); - toolJobScheduler = new ToolJobScheduler(agentEngine, toolRegistry, stateStore, loggerFactory, instanceConfig.InstanceId); - toolJobScheduler.RegisterAll(instanceConfig.Agents); + // ─── Taskboard: Dateien in die DB spiegeln (Reconciliation beim Start) ─── + // Verwaiste Claims eines abgestürzten Laufs lösen, dann alle Task-Dateien + // importieren. Nicht blockierend — der Start soll nicht auf das Dateisystem warten. + var sharedWorkspace = instanceConfig.Agents + .Select(a => a.SharedWorkspacePath) + .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)); + if (!string.IsNullOrWhiteSpace(sharedWorkspace)) + { + var board = new ClawdDotNet.Core.Tasks.TaskboardService( + taskRepository, Path.Combine(sharedWorkspace, "tasks")); - coreLogger.LogInformation("AgentEngine und Scheduler erstellt, Chat-Verläufe geladen"); + // Staging (A2): Freigabe-/Ablehnungs-Dienst für die Review-Oberfläche. + // Weckt den Agenten nach der Entscheidung über einen Folge-Task (A1). + stagingService = new ClawdDotNet.Core.Staging.StagingService( + stagingRepository, agentEngine, board, loggerFactory, auditRepository); + + // Scanner (A1): der Taktgeber. Startet erst, wenn die Reconciliation durch + // ist — sonst könnte er einen noch nicht bereinigten Claim antreffen. + // Der Scanner ist der EINZIGE periodische Treiber (A1) — er ersetzt die + // früheren AgentScheduler/ToolJobScheduler. Geplante Agent-Läufe wie auch + // Tool-Job-Polls sind jetzt Tasks. + var dispatcher = new ClawdDotNet.Core.Tasks.EngineTaskDispatcher( + agentEngine, () => instanceConfig.Agents, instanceConfig.InstanceId, + toolRegistry, stateStore, loggerFactory); + taskScanner = new ClawdDotNet.Core.Tasks.TaskScanner( + taskRepository, dispatcher, loggerFactory); + + _ = Task.Run(async () => + { + try + { + var reset = await taskRepository.ReleaseStaleClaimsAsync( + DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None); + + // Einmalige Übernahme der alten coordination/task_*-Dateien … + var coordMigration = new ClawdDotNet.Core.Tasks.CoordinationMigration( + board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory); + var migratedCoord = await coordMigration.RunAsync(CancellationToken.None); + + // … und der alten scheduler/toolJobs-Konfiguration in Tasks. + var schedMigration = new ClawdDotNet.Core.Tasks.SchedulerTaskMigration( + board, taskRepository, loggerFactory); + var migratedSched = await schedMigration.RunAsync(instanceConfig.Agents, CancellationToken.None); + + var imported = await board.ImportAllAsync(CancellationToken.None); + coreLogger.LogInformation( + "Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + {Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt", + imported, migratedCoord, migratedSched, reset); + taskScanner.Start(); + } + catch (Exception ex) + { + coreLogger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen"); + } + }); + } + + coreLogger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen"); } else { @@ -234,6 +299,11 @@ internal static class Program } } + // ─── 6c. Instanz-Watchdog ─── + // Entfällt hier ebenfalls. Der Heartbeat ans Deploymentcenter (ein Monitor je + // Instanz, mit Gesundheitsprüfungen und angekündigtem Ende) steht in + // ClawdDotNet.App/Services/DeploymentcenterService.cs. + // ─── 7. MainForm starten ─── var form = new frm_main( settingsManager, @@ -244,19 +314,16 @@ internal static class Program toolRegistry, dirManager, agentEngine, - agentScheduler, - toolJobScheduler); + taskScanner, + stagingService); Application.Run(form); // ─── 8. Aufräumen ─── coreLogger.LogInformation("ClawdDotNet wird beendet"); - if (toolJobScheduler is not null) - toolJobScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult(); - - if (agentScheduler is not null) - agentScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult(); + if (taskScanner is not null) + taskScanner.DisposeAsync().AsTask().GetAwaiter().GetResult(); if (tgClientManager is not null) tgClientManager.DisposeAsync().AsTask().GetAwaiter().GetResult(); diff --git a/Services/BackupScheduler.cs b/Services/BackupScheduler.cs deleted file mode 100644 index 958c359..0000000 --- a/Services/BackupScheduler.cs +++ /dev/null @@ -1,147 +0,0 @@ -using ClawdDotNet.Core.Backup; -using Microsoft.Extensions.Logging; - -namespace ClawdDotNet.Services; - -/// -/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung. -/// -/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und -/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten -/// mitsichern will, macht das von Hand. -/// -/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit -/// eine Änderung ohne Neustart greift. -/// -public sealed class BackupScheduler : IDisposable -{ - private readonly string _instanceDir; - private readonly string _instanceName; - private readonly SettingsManager _settings; - private readonly ILogger _logger; - private readonly BackupService _service = new(); - private readonly System.Windows.Forms.Timer _timer; - - /// Verhindert mehrere Sicherungen innerhalb derselben Minute. - private DateTime? _lastRun; - - private bool _running; - - public event Action? OnBackupCreated; - - public BackupScheduler(string instanceDir, string instanceName, - SettingsManager settings, ILogger logger) - { - _instanceDir = instanceDir; - _instanceName = instanceName; - _settings = settings; - _logger = logger; - - // Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt. - _timer = new System.Windows.Forms.Timer { Interval = 60_000 }; - _timer.Tick += async (_, _) => await TickAsync(); - } - - public void Start() => _timer.Start(); - - public void Stop() => _timer.Stop(); - - private async Task TickAsync() - { - if (_running) - return; - - var settings = _settings.AppSettings; - if (!settings.AutoBackupEnabled) - return; - - if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled)) - return; - - var now = DateTime.Now; - - // Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief. - if (now.TimeOfDay < scheduled) - return; - - if (_lastRun?.Date == now.Date) - return; - - _running = true; - try - { - await RunAsync(settings.BackupDirectory, settings.BackupKeepCount); - _lastRun = now; - } - catch (Exception ex) - { - // Ein Fehlschlag darf die Anwendung nicht stören; er wird protokolliert - // und morgen erneut versucht. - _logger.LogError(ex, "Automatische Sicherung fehlgeschlagen"); - _lastRun = now; - } - finally - { - _running = false; - } - } - - private async Task RunAsync(string folder, int keepCount) - { - var safeName = string.Concat( - _instanceName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); - - var file = Path.Combine( - Path.GetFullPath(folder), - $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip"); - - var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions - { - Secrets = SecretMode.Exclude, - IncludeChatHistory = true, - IncludeLogs = false - }); - - _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)", - result.ZipPath, result.SizeBytes); - - ApplyRotation(Path.GetFullPath(folder), safeName, keepCount); - - OnBackupCreated?.Invoke(result.ZipPath); - } - - /// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest. - private void ApplyRotation(string folder, string safeName, int keepCount) - { - if (keepCount <= 0 || !Directory.Exists(folder)) - return; - - try - { - var prefix = $"backup_{safeName}_"; - - var obsolete = new DirectoryInfo(folder) - .GetFiles("*.zip") - .Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(f => f.LastWriteTime) - .Skip(keepCount) - .ToList(); - - foreach (var file in obsolete) - { - file.Delete(); - _logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen"); - } - } - - public void Dispose() - { - _timer.Stop(); - _timer.Dispose(); - } -} diff --git a/Services/SettingsManager.cs b/Services/SettingsManager.cs deleted file mode 100644 index 8d67edb..0000000 --- a/Services/SettingsManager.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Text.Json; -using ClawdDotNet.Models; - -namespace ClawdDotNet.Services; - -public sealed class SettingsManager -{ - private const string SettingsFileName = "Settings.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - PropertyNameCaseInsensitive = true - }; - - private readonly string _settingsPath; - - public AppSettings AppSettings { get; private set; } = new(); - - public SettingsManager(string? basePath = null) - { - var dir = basePath ?? AppDomain.CurrentDomain.BaseDirectory; - _settingsPath = Path.Combine(dir, SettingsFileName); - } - - public void Load() - { - if (!File.Exists(_settingsPath)) - { - AppSettings = new AppSettings(); - Save(); // Defaults schreiben - return; - } - - try - { - var json = File.ReadAllText(_settingsPath); - AppSettings = JsonSerializer.Deserialize(json, JsonOptions) - ?? new AppSettings(); - } - catch - { - AppSettings = new AppSettings(); - } - } - - public void Save() - { - try - { - var json = JsonSerializer.Serialize(AppSettings, JsonOptions); - ClawdDotNet.Core.Storage.AtomicFile.WriteAllText(_settingsPath, json); - } - catch (Exception ex) - { - // Logging ist hier ggf. noch nicht verfügbar – Fallback auf MessageBox - MessageBox.Show( - $"Settings konnten nicht gespeichert werden:\n{ex.Message}", - "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } -} diff --git a/UI/BackupPanel.cs b/UI/BackupPanel.cs index 5f57a12..9e1e8d6 100644 --- a/UI/BackupPanel.cs +++ b/UI/BackupPanel.cs @@ -335,7 +335,7 @@ public sealed partial class BackupPanel : UserControl { if (SelectedBackupPath is not { } path) return; - System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); + ClawdDotNet.Core.Storage.SystemShell.RevealFile(path); } private void DeleteSelected() diff --git a/docs/Agentenkommunikation-Konzept.md b/docs/Agentenkommunikation-Konzept.md new file mode 100644 index 0000000..fa21fb9 --- /dev/null +++ b/docs/Agentenkommunikation-Konzept.md @@ -0,0 +1,318 @@ +# Agentenkommunikation — Erfassung, Ansicht, Auswertung + +Ziel: Die **gesamte** Kommunikation zwischen Agenten wird erfasst, ist im WhatsApp-Stil +paarweise nachlesbar und lässt sich von einem Agenten automatisiert auswerten — um zu +finden, wo die Zusammenarbeit klemmt. + +Abgegrenzt davon: Rocket.Chat (siehe [RocketChat-Nextcloud-Konzept](RocketChat-Nextcloud-Konzept.md)) +trägt **ausschließlich** das, was ein Mensch wissen soll. Interne Absprachen der Agenten +gehen dort nie hin. + +Aufbauend auf [Audit-Konzept](Audit-Konzept.md) (A3) und [Taskboard-Konzept](Taskboard-Konzept.md) (A1). + +--- + +## 1 — Beschlüsse (August 2026) + +| # | Beschluss | +|---|---| +| 1 | **Korrelation**: `ParentRunId` + `RootRunId` in Audit-Log und Receipts. Eine Delegationskette wird damit zu einer Abfrage. | +| 2 | **Nachrichtenspeicher**: eigene Tabelle `AgentMessages`, die alle Wege gleich behandelt und beide Richtungen festhält. | +| 3 | **Rocket.Chat bleibt außen vor** — kein Spiegeln der Agentenkommunikation dorthin. Ein Gruppenchat mit allem drin wäre unlesbar. | +| 4 | **Ansicht**: Paar auswählen (Agent A / Agent B), Verlauf im Chat-Stil scrollen. | +| 5 | **Auswertung**: ein Analyse-Tool, das ein dafür vorgesehener Agent bekommt. | + +--- + +## 2 — AgentComm behalten oder durch das Taskboard ersetzen? + +Das war die offene Frage. Der Befund zuerst, die Empfehlung danach. + +### 2.1 Was heute passiert + +`AgentComm.send_message` ist ein **synchroner Aufruf**: A ruft, `SendMessageAsync` startet +`ChatAsync(B)`, wartet auf den vollständigen Lauf von B und gibt dessen Schlussnachricht +als Tool-Ergebnis an A zurück. Drei Eigenschaften folgen daraus: + +- **Es gibt keine Tiefenbegrenzung** (B8). A→B→A→B… läuft, bis ein Timeout greift. +- **Es kann echt verklemmen.** Seit B2 serialisiert ein Gate je Agent alle Läufe. A hält + sein Gate, während es auf B wartet. Ruft B nun `send_message(A)`, wartet B auf As Gate — + das A hält, während es auf B wartet. Das löst nur der Timeout auf. Der Selbstaufruf + A→A wurde damals abgefangen, der Zweierzyklus nicht. +- **Der Fehler ist teuer**: Jeder Hop ist ein vollständiger, bezahlter Lauf. + +### 2.2 Was ein Task nicht kann + +Trotzdem ist „einfach alles über Tasks" nicht ohne Verlust. Zwei Dinge kann der +asynchrone Weg strukturell nicht: + +- **Antwort im selben Lauf.** Bei `send_message` kommt die Antwort als Tool-Ergebnis + zurück, und A arbeitet damit sofort weiter. Über einen Task endet As Lauf; die Antwort + kommt später als neuer Weckvorgang, und A muss seinen Gedankengang neu aufnehmen. Für + eine Rückfrage sind das **zwei Läufe statt einem** — der asynchrone Weg ist hier also + nicht nur langsamer, sondern *teurer*. +- **Antwortzeit.** Der Scanner tickt im Minutentakt. Eine Rückfrage „hast du die Datei + schon abgelegt?" braucht damit im Mittel eine halbe Minute plus den Lauf des anderen. + +### 2.3 Empfehlung: nach Zweck trennen, nicht beides parallel führen + +Der Fehler in der jetzigen Lage ist nicht, dass es zwei Mechanismen gibt — es ist, dass +**beide dasselbe können**. Ein Agent kann Arbeit sowohl per Task delegieren als auch per +`send_message` „mal eben" abschieben, und der zweite Weg ist der gefährliche. + +Vorschlag: + +> **Delegation gehört ausschließlich ins Taskboard.** `AgentComm` verliert diese Rolle +> vollständig — kein „mach du mal", kein Auftrag, kein Arbeitspaket. +> +> **Die kurze Rückfrage bleibt**, aber als eigenes, eng gefasstes Werkzeug: `ask_agent`. + +`ask_agent` mit harten Grenzen: + +| Grenze | Begründung | +|---|---| +| **Tiefe 1** — wer gerade eine Rückfrage *beantwortet*, darf selbst keine stellen | Beendet die Rekursion an der Wurzel. Prüfbar, sobald `ParentRunId` steht (Punkt 1) — die Synergie ist der Grund, warum das jetzt fast umsonst zu haben ist | +| **Zyklusprüfung** — Ziel darf nicht im aktuellen Aufrufpfad liegen | Schließt den Deadlock aus 2.1 aus, statt auf den Timeout zu hoffen | +| **Kurzer Timeout + kleines Schrittbudget** (z. B. 60 s, 5 Schritte) | Eine Rückfrage, die fünf Schritte braucht, war keine Rückfrage, sondern ein Auftrag | +| **Eigene Beschreibung im Prompt**: „für kurze Fragen an einen Kollegen, nicht um Arbeit abzugeben" | Der häufigste Missbrauch ist der falsche Griff, nicht die böse Absicht | + +Damit gibt es weiterhin zwei Wege, aber sie überschneiden sich nicht mehr: Der eine ist +ein Auftrag (dauerhaft, nachvollziehbar, mit Abnahme), der andere eine Frage +(flüchtig, sofort, begrenzt). + +**Die Gegenposition, fairerweise:** Man kann `AgentComm` auch ersatzlos streichen und die +Rückfrage über einen Task mit hoher Priorität abbilden. Das wäre die konsequentere +Umsetzung des „alles ist ein Task"-Prinzips und spart ein Tool. Der Preis sind die zwei +Läufe je Rückfrage und die Minute Wartezeit. **Meine Empfehlung ist die Trennung**, weil +Rückfragen im Mehr-Agenten-Betrieb häufig sind und der Aufpreis sich dann summiert — aber +das ist eine Abwägung, keine technische Notwendigkeit. + +`AgentSpawn` geht in beiden Varianten im Taskboard auf (`assignee: @new:` ist genau +das) und wird zurückgebaut. + +--- + +## 3 — Korrelation: `ParentRunId` und `RootRunId` + +Heute erzeugt jeder Lauf eine frische `runId`; der Lauf des Empfängers weiß nichts vom +Lauf des Absenders. Eine Kette A→B→C ist deshalb nur über Zeitstempel zu erraten. + +Zwei Felder auf `AuditEntry` und `RunReceipt`: + +- **`ParentRunId`** — der Lauf, aus dem dieser hervorging. `null` bei einem Lauf, den ein + Mensch oder der Scanner auslöst. +- **`RootRunId`** — die Wurzel der Kette. Das ist faktisch die **Vorgangs-Id**: Alles, was + aus einer Anweisung entstand, trägt denselben Wert. + +Regeln: + +- **Die Engine stempelt.** Wie beim Audit gilt: Herkunft wird nie vom Agenten behauptet. +- Ein Lauf ohne Vorgänger ist seine eigene Wurzel (`RootRunId = RunId`). +- Weitergereicht wird über die Aufrufstellen, an denen ein Lauf einen anderen auslöst: + `ask_agent`, Task-Dispatch, Staging-Folgetask. +- **Migration**: Bestandszeilen bekommen `RootRunId = RunId` und `ParentRunId = NULL`. + Das ist nicht rückwirkend korrekt, aber ehrlich — alte Ketten bleiben unbekannt, statt + falsch zusammengesetzt zu werden. + +Der Nutzen reicht über die Analyse hinaus: Was eine Delegationskette insgesamt gekostet +hat, ist danach ein `SUM` über `RunReceipts` gruppiert nach `RootRunId`. Damit fällt ein +Teil von C7 nebenbei ab. + +--- + +## 4 — Der Nachrichtenspeicher + +### 4.1 Tabelle + +Neue Tabelle in der Instanz-DB, neben `AuditLog` und `RunReceipts`: + +```sql +CREATE TABLE IF NOT EXISTS AgentMessages ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + PairKey TEXT NOT NULL, -- sortiertes Paar: "agentA|agentB" + FromAgentId TEXT NOT NULL, + ToAgentId TEXT NOT NULL, + Channel TEXT NOT NULL, -- ask | task | task_comment | task_result + Direction TEXT NOT NULL, -- request | response + Content TEXT NOT NULL, -- vollständig, nach Scrubbing + RunId TEXT NOT NULL, + ParentRunId TEXT, + RootRunId TEXT NOT NULL, + TaskId TEXT, + Status TEXT NOT NULL, -- delivered | failed | timeout | denied + OccurredAt TEXT NOT NULL +); +CREATE INDEX IX_AgentMessages_Pair ON AgentMessages(PairKey, OccurredAt); +CREATE INDEX IX_AgentMessages_Root ON AgentMessages(RootRunId, OccurredAt); +``` + +Dazu ein FTS5-Index auf `Content` — dieselbe Technik wie beim geplanten Historien-Umzug +(K6), damit die Suche in der Ansicht und im Analyse-Tool nicht über `LIKE` läuft. + +### 4.2 Die Entscheidungen dahinter + +**`PairKey` als sortiertes Paar.** Die geforderte Ansicht („A und B auswählen, scrollen") +wird damit zu `WHERE PairKey = ? ORDER BY OccurredAt` — eine Abfrage auf einem Index, +unabhängig davon, wer gerade wen anspricht. + +**Beide Richtungen als eigene Zeilen.** Eine Rückfrage erzeugt zwei Zeilen (`request` +A→B, `response` B→A) mit derselben `RootRunId`. Nur so entsteht ein Verlauf, der sich wie +ein Chat liest. Das Audit-Log kann das nicht leisten: Es speichert nur `Arguments`, die +Antwort landet dort nirgends. + +**Inhalt ungekappt.** Das Audit kappt bei 4.000 Zeichen — richtig, denn es dient der +Nachvollziehbarkeit. Für die Auswertung braucht es den vollen Text. Gekappt wird erst +dort, wo Text in einen LLM-Kontext zurückfließt (Abschnitt 6). + +**Alle Wege in einer Tabelle.** `ask_agent`, Task-Delegation, `task_comment` und das +Ergebnis eines Tasks landen im selben Format. Sonst müsste die Auswertung drei Quellen +zusammensuchen — und genau daran scheitert sie heute. + +**Fan-out statt Sammelzeile.** Eine Nachricht an mehrere Empfänger wird zu mehreren +Zeilen. Etwas redundant, dafür bleibt jede Zeile paarweise auswertbar. + +### 4.3 Wer schreibt + +Ein `IAgentMessageLog` im Core, aufgerufen an genau den Stellen, an denen eine Nachricht +eine Agentengrenze überschreitet: + +| Aufrufstelle | Zeilen | +|---|---| +| `AgentEngine` — `ask_agent` | `request` beim Absenden, `response` beim Rückgabewert (auch bei Fehler/Timeout, mit passendem `Status`) | +| Taskboard — `task_create` mit fremdem Assignee | `request` | +| Taskboard — `task_comment` | `request` bzw. `response`, je nach Richtung | +| Taskboard — Task abgeschlossen/geblockt | `response` mit Ergebnis oder Blocker-Grund | + +Drei bis vier Stellen, alle im Core. Kein Tool schreibt selbst — sonst könnte ein Agent +seine eigene Kommunikationsakte färben. + +**Fehlschläge werden mitgeschrieben.** Eine nicht zugestellte Nachricht ist für die +Analyse wertvoller als eine erfolgreiche. + +### 4.4 Was hier nicht hineingehört + +- **Mensch↔Agent-Chat.** Das ist die Chat-Historie, ein anderer Gegenstand mit anderem + Umzugsplan (K6). *Ausnahme mit gutem Preis-Leistungs-Verhältnis:* Tasks mit + `assignee: @human` durchlaufen dieselben Aufrufstellen — man kann sie als Paar + (Agent, `@human`) mitschreiben und bekommt die Ansicht dafür geschenkt. Vorschlag: ja, + aber als Nachzügler, nicht als Teil der ersten Fassung. +- **Tool-Aufrufe.** Die stehen im Audit-Log und gehören nicht in einen Gesprächsverlauf. +- **Rocket.Chat-Nachrichten.** Anderer Gegenstand, andere Vertrauensgrenze. + +### 4.5 Zwei Pflichten + +- **Output-Scrubbing vor dem Schreiben.** Nachrichteninhalte enthalten Tool-Ergebnisse. + Ohne Maskierung bekannter Geheimnisse wird dieser Speicher zur zweiten Fundstelle für + Zugangsdaten — und über den MySQL-Spiegel (A6) verlässt er sogar die Maschine. Der + Roadmap-Punkt „Output-Scrubbing" ist damit **Voraussetzung**, nicht Beiwerk. +- **Aufbewahrung.** Die Tabelle wächst unbegrenzt. Ein Instanz-Wert + (`agentMessageRetentionDays`, 0 = unbegrenzt) plus ein Aufräum-Task gehören von Anfang + an dazu, nicht erst, wenn die DB groß ist. + +--- + +## 5 — Die Ansicht + +Neuer Reiter im Hauptfenster: **Agentenkommunikation**. + +**Bedienung**: zwei Auswahlfelder (Agent A, Agent B) — dazu „alle" für einen Agenten, um +zu sehen, mit wem er überhaupt spricht. Zeitraum, Kanalfilter, Freitextsuche. + +**Darstellung**: Chat-Stil, A rechts, B links, Zeitstempel, Tagestrenner. Jede Blase +trägt eine kleine Kennzeichnung des Kanals (`Rückfrage` / `Auftrag` / `Kommentar` / +`Ergebnis`) und, wo vorhanden, die anklickbare Task-Id. + +**Technisch**: über den vorhandenen **WebView2**-Unterbau, wie ihn `frm_chat` schon nutzt +(inkl. Virtual-Host-Mapping auf einen lokalen Ordner). Der Verlauf wird als HTML +gerendert. Handgezeichnete Sprechblasen in WinForms wären ein Vielfaches an Aufwand für +ein schlechteres Ergebnis. + +**Paging**: die jüngsten ~200 Nachrichten, „ältere laden" nach oben. Ein Paar mit 50.000 +Zeilen darf die Oberfläche nicht am Start blockieren. + +**Der eigentliche Mehrwert** liegt über dem flachen Verlauf: Ein Klick auf eine Nachricht +zeigt die ganze Kette zu ihrer `RootRunId` — also den Vorgang von der auslösenden +Anweisung bis zum letzten Beitrag, über alle beteiligten Agenten hinweg, mit den Kosten +aus den Receipts. Das ist die Ansicht, die die Frage „warum hat das drei Stunden und +vier Dollar gekostet" tatsächlich beantwortet. + +--- + +## 6 — Automatisierte Auswertung + +Ein Tool `AgentCommAnalysis`, das **bewusst nur einem dafür vorgesehenen Agenten** +zugewiesen wird (die Zuweisung je Agent gibt es ohnehin). + +| Aktion | Zweck | +|---|---| +| `list_pairs` | Wer spricht mit wem, wie oft, seit wann — der Einstieg | +| `stats` | Kennzahlen je Paar/Kanal/Zeitraum (siehe unten) | +| `read_conversation` | Verlauf eines Paares, gekappt und seitenweise | +| `chain` | Ein kompletter Vorgang über `RootRunId`, inkl. Kosten | +| `search` | Volltext über FTS5 | + +**Fragen, die das beantworten soll** — sie sind der Grund für den Schnitt des Schemas: + +- Wie viele Delegationen führen zu einem Ergebnis, und wie viele versanden? +- Wie tief werden Ketten, und ab welcher Tiefe steigt die Fehlerquote? +- Welche Paare stellen sich wiederholt dieselbe Rückfrage? (Ein Hinweis auf unklare + Zuständigkeit oder einen fehlenden Skill — genau die Art Problem, die man sucht.) +- Was kostet ein Vorgang von der Anweisung bis zum Ergebnis? +- Wo häufen sich `failed`/`timeout`? + +**Drei Sicherungen**, weil ein Agent hier fremde Kommunikation liest: + +1. Ergebnisse werden als `` gerahmt. Der Inhalt stammt aus anderen + Läufen und kann Anweisungen enthalten — auch ohne böse Absicht. +2. Standardmäßig liefert das Tool **Kennzahlen**, Rohtext nur auf ausdrückliche Anfrage + und mit harter Obergrenze. Ein unbedachtes „lies mir alles vor" ist sonst ein + Kontext-Überlauf mit Rechnung. +3. Das Tool ist **lesend**. Es gibt keine Schreibaktion. + +--- + +## 7 — Verhältnis zu Rocket.Chat + +Klargestellt, weil es die vorherige Überlegung ablöst: + +- Agentenkommunikation wird **nicht** nach Rocket.Chat gespiegelt. Ein Raum, in dem jede + interne Absprache mitläuft, ist nach einer Woche unlesbar und verdeckt genau das, was + man sehen soll. +- Nach Rocket.Chat geht nur, was ein Mensch wissen soll oder muss — über den + `ChannelRouter` bzw. eine bewusste Handlung des Agenten. +- Wer den internen Verlauf sehen will, nimmt die Ansicht aus Abschnitt 5. Die ist dafür + gebaut; ein Gruppenchat ist es nicht. + +--- + +## 8 — Schnitt + +| Phase | Inhalt | Abhängigkeit | +|---|---|---| +| **1** | `ParentRunId` + `RootRunId` in `AuditLog`/`RunReceipts`, Migration, Stempelung in der Engine | — | +| **2** | `AgentMessages` + FTS5 + `IAgentMessageLog`, Schreiben an den Aufrufstellen | 1 | +| **3** | `ask_agent` (Tiefe 1, Zyklusprüfung), Rückbau von `AgentComm`/`AgentSpawn` | 1 — die Tiefe kommt aus der Kette | +| **4** | Ansicht (WebView2-Reiter) | 2 | +| **5** | `AgentCommAnalysis`-Tool | 2 | +| **—** | Output-Scrubbing | **vor** 2 | + +Phase 1 zuerst, weil Phase 3 die Kette braucht und Phase 2 die Felder mitschreibt. Das +Scrubbing muss vor Phase 2 stehen — sonst legen wir einen Speicher an, der erst +nachträglich bereinigt werden müsste. + +Zur Einstufung: Phasen 1, 2, 4 und 5 sind gut spezifizierbar. Phase 3 fasst das +Agent-Gate an, an dem schon einmal ein Deadlock lauerte (B2/B8) — dafür gehören die Tests +aus der [Teststrategie](Teststrategie.md) mit dazu, insbesondere der dort vorgesehene, +bis heute fehlende Fall **A13** (`A→B→A` wird begrenzt statt zu verklemmen). + +--- + +## 9 — Offene Punkte + +1. **`ask_agent` behalten oder ersatzlos streichen?** Meine Empfehlung steht in 2.3 + (behalten, eng gefasst) — die Gegenposition ist dort ebenfalls notiert. +2. **`@human`-Tasks mitschreiben?** Gibt die Paar-Ansicht auch für Mensch↔Agent, fast + ohne Zusatzaufwand. Vorschlag: ja, aber nach Phase 4. +3. **Aufbewahrungsdauer** — Vorgabewert? (Vorschlag: unbegrenzt, bis der Spiegel aus A6 + steht; dann 180 Tage lokal.) +4. **Wer bekommt `AgentCommAnalysis`?** Ein eigener Analyse-Agent oder ein bestehender? diff --git a/docs/Audit-Konzept.md b/docs/Audit-Konzept.md new file mode 100644 index 0000000..a8ba239 --- /dev/null +++ b/docs/Audit-Konzept.md @@ -0,0 +1,91 @@ +# Audit-Log & Receipts — Nachvollziehbarkeit + +Setzt A3 aus der [Roadmap](Roadmap.md) um (F-A2). Zwei zusammengehörige Dinge: + +- **Audit-Log** — ein Eintrag je Tool-Aufruf: wer, wann, welches Tool, mit welchem + Ausgang. +- **Receipts** — ein Abschluss-Beleg je Lauf: Ergebnis, Schritte, Tokens, Kosten, + verknüpft mit dem Task. + +A3 ist das Fundament für A2 (jede Staging-Entscheidung wird als Datensatz verankert) +und für C7 („Kosten pro Ergebnis", fällt aus den Receipts ab). Die Tool-Fehlerquote aus +der Leistungsanalyse liest sich direkt aus dem Log. + +## Warum eigene Tabellen, nicht der State-Store + +Dieselbe Überlegung wie bei Gedächtnis und Taskboard: `IStateStore` ist Schlüssel-Wert. +Ein Log, das man nach Lauf, Task oder Tool filtern und dessen Fehlerquote man auswerten +will, braucht typisierte Spalten. Zwei Tabellen auf dem vorhandenen +[`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs): `AuditLog` und +`RunReceipts`. + +## Provenienz — von der Engine gestempelt, nie vom Agenten behauptet + +Die entscheidende Regel (aus dem OpenAlice-Provenance-Konzept): + +- **Herkunft stempelt die Engine.** `AgentId`, `Model` und `Source` kommen aus dem + Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Ein Tool kann seine + Herkunft nicht fälschen, weil es sie gar nicht schreibt. +- **Einträge sind unveränderlich.** Das Repository hat kein Update und kein Delete — + eine Korrektur ist ein neuer Eintrag. Das ist die eigentliche Zusage, keine fehlende + Funktion. +- **Unbekanntes wird als unbekannt markiert, nicht geraten.** Fehlt die Quelle, steht + `unknown`, nicht ein plausibel geratener Kanal. +- **Worker-Typ und Session sind getrennt.** `Model` (das ausführende Modell) und + `Source` (die verantwortliche Session: `webview`, `telegram`, `task`, `agentcomm`, + `job`, `direct`) sind verschiedene Begriffe und stehen in eigenen Spalten. + +## Audit-Log + +Gestempelt an genau einer Stelle: `AgentEngine.ExecuteToolCallAsync` — dort, wo jeder +Tool-Aufruf durchläuft. Je Aufruf ein Eintrag mit Ausgang: + +| Status | Wann | +|---|---| +| `Ok` | Tool lief und lieferte ein Ergebnis | +| `Error` | Tool meldete einen Fehler oder warf | +| `Denied` | das `PermissionGate` hat abgelehnt | +| `NotFound` | Tool dem Agenten nicht zugewiesen/unbekannt | + +Ein Abbruch (Cancellation) wird **nicht** protokolliert — der Aufruf kam nicht zum +Abschluss. Die Argumente werden roh, aber gekappt abgelegt (4 000 Zeichen); die +Ausgangsnotiz kurz (500). + +**Best effort:** Ein Fehler beim Schreiben des Audits darf den Lauf nie scheitern +lassen — dieselbe Linie wie bei der Verbrauchserfassung. Der Eintrag wird geschrieben, +nachdem die eigentliche Arbeit getan ist. + +## Receipts + +Je Lauf ein Beleg, geschrieben beim Abschluss von `RunAsync`/`ChatAsync` (neben der +vorhandenen `RunUsage`-Erfassung). Er trägt Status, Schritte, Prompt-/Completion-/ +Cached-Tokens, geschätzte Kosten (aus dem `ModelPricingCatalog`, mit +`CostIsKnown`-Flag) und einen kurzen Ergebnis-Verweis. + +**Verknüpfung `RunUsage` ↔ Task:** Der Receipt trägt die `TaskId`, wenn der Lauf aus dem +Taskboard kam — der `EngineTaskDispatcher` reicht sie (samt `source: task`) durch. Damit +ist „Kosten pro Ergebnis" (C7) ein Abfallprodukt: `ListReceiptsForTaskAsync` liefert +alle Belege zu einem Task. + +## RunId — die Klammer + +Jeder Lauf bekommt zu Beginn eine `RunId` (GUID). Alle Audit-Einträge **und** der +Receipt eines Laufs tragen sie. So lässt sich ein Lauf lückenlos rekonstruieren: +`ListForRunAsync(runId)` gibt die Aufrufe in Reihenfolge, `GetReceiptForRunAsync(runId)` +den Abschluss. + +## Verdrahtung + +`IAuditRepository` ist optional (wie Gedächtnis und Taskboard): ohne Repo läuft die +Engine unverändert. In `Program.cs` wird ein `SqliteAuditRepository` auf der Instanz-DB +erzeugt und der Engine übergeben. + +## Offen + +- **Output-Scrubbing** — die `Arguments` können Secrets enthalten. Das zentrale + Maskieren bekannter Secret-Werte (eigener beschlossener Roadmap-Punkt) greift, sobald + es steht; der Andockpunkt (`ExecuteToolCallAsync`) ist derselbe. +- **Review-Oberfläche** — die Anzeige/Durchsicht des Logs und der Receipts gehört zu A2 + (Staging-Review im Hauptfenster); die Abfragemethoden dafür stehen bereit. +- **Export** — ein JSONL-Export des Logs wäre für externe Auswertung nützlich (später, + passt zum A6-Spiegel). diff --git a/docs/Avalonia-Portierungsleitfaden.md b/docs/Avalonia-Portierungsleitfaden.md new file mode 100644 index 0000000..5b26cf7 --- /dev/null +++ b/docs/Avalonia-Portierungsleitfaden.md @@ -0,0 +1,186 @@ +# Avalonia-Portierung — Leitfaden + +Für alle, die weitere Ansichten von WinForms nach Avalonia übertragen. +Stand: 2026-08-07. + +--- + +## 1. Auftrag + +Drei Bereiche des Hauptfensters sind noch Platzhalter. In dieser Reihenfolge portieren — +sie steigen im Umfang, und jede baut auf dem Muster der vorigen auf: + +| # | Bereich | WinForms-Vorlage | Daten aus | +|---|---|---|---| +| 1 | **Info** | `frm_main.Designer.cs`, Suchwort `tabPage_Info` | `AppHost.AppVersion`, `AppHost.BuildSummary`, `host.Instance` | +| 2 | **Sicherung** | `UI/BackupPanel.cs` + `UI/BackupPanel.Designer.cs` | `host.InstancePath`, `host.Settings`, `Core.Backup.BackupService` | +| 3 | **Aufgaben** (Jobs/Services/Verlauf) | `frm_main.cs`, Abschnitt `WORKER TAB` ab Zeile 932 | `host.Instance.Agents`, `App.Services.JobHistoryService` | + +**Nicht anfassen:** Chat und Einstellungen. Beide sind Entwurfsarbeit, nicht Übersetzung, +und werden gesondert gemacht. + +Die WinForms-Dateien liegen noch im Repository, sind aber **nicht mehr Teil des Builds** +(siehe Kommentar in `ClawdDotNet.slnx`). Sie sind Vorlage zum Lesen — nicht zum Kompilieren, +nicht zum Reparieren. + +--- + +## 2. Drei Regeln, die nicht verletzt werden dürfen + +### 2.1 Der Schichtschnitt + +``` +src/ClawdDotNet.App ← Fachlogik. KEIN Verweis auf Avalonia. Niemals. +src/ClawdDotNet.Desktop ← Oberfläche. Darf App und Core verwenden. +``` + +`ClawdDotNet.App` muss ohne Fenster laufen — darauf setzt der geplante systemd-Dienst auf. +Sobald dort ein `using Avalonia…` steht, ist der Schnitt kaputt und fällt erst Wochen +später auf. + +**Faustregel:** Alles, was Dateien liest, rechnet oder mit der Engine spricht, gehört nach +`App`. Alles, was etwas anzeigt, nach `Desktop`. + +### 2.2 Fäden + +Ereignisse aus `AgentEngine`, `TaskScanner`, `OpenRouterStatusService` und +`BackupScheduler` kommen auf **Hintergrundfäden**. Eine `ObservableCollection` von dort aus +zu ändern wirft entweder oder beschädigt still die Anzeige. + +```csharp +// Aus einem Ereignis der Fachschicht heraus: +Dispatcher.UIThread.Post(() => Lines.Add(neu)); + +// Wenn ein Rückgabewert gebraucht wird: +await Dispatcher.UIThread.InvokeAsync(() => …); +``` + +Ein `DispatcherTimer` läuft dagegen bereits auf dem Oberflächenfaden — dort ist kein +Wechsel nötig (siehe `LogPageViewModel`). + +### 2.3 Avalonia **12**, nicht 11 + +Praktisch alle Anleitungen im Netz sind für Avalonia 11 und lassen sich hier nicht +übernehmen. Bekannte Unterschiede: + +- `BindingPlugins` ist nicht mehr öffentlich. Das übliche + `DisableAvaloniaDataAnnotationValidation()` aus den 11er-Vorlagen **entfällt ersatzlos** — + nicht nachbauen. +- `ShutdownMode` voll qualifizieren: `Avalonia.Controls.ShutdownMode`. + +Diese Fehler brechen den Build. Das ist gut — sie fallen sofort auf. + +--- + +## 3. Das Muster + +Der Logs-Bereich ist als vollständiges Beispiel gebaut. Drei Dateien, drei Aufgaben: + +**`src/ClawdDotNet.App/Services/LogTail.cs`** — die Fachlogik. Liest Dateien, kennt keine +Oberfläche, wäre ohne Fenster lauffähig. + +**`src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs`** — das Ansichtsmodell. Erbt von +`PageViewModel`, hält Zustand und Befehle. Kennt keine Steuerelemente. + +**`src/ClawdDotNet.Desktop/Views/LogPageView.axaml`** — die Ansicht. Nur Aufbau und +Bindungen. + +### Ein neuer Bereich in vier Schritten + +**1.** Ansichtsmodell anlegen, von `PageViewModel` erbend: + +```csharp +public sealed partial class InfoPageViewModel : PageViewModel +{ + public InfoPageViewModel(AppHost? host) : base("Info") { … } +} +``` + +`AppHost?` ist **nullbar** — der Entwurfsmodus des Editors erzeugt das Ansichtsmodell ohne +laufenden Aufbau. Bei `null` einfach nichts starten und Beispielwerte zeigen. + +**2.** Ansicht anlegen: `Views/InfoPageView.axaml` + `.axaml.cs`. Der Name muss der +Konvention folgen — `ViewLocator` sucht `…ViewModels.FooViewModel` → `…Views.FooView`. +Passt der Name nicht, steht der gesuchte Typ im Fenster statt der Ansicht. + +**3.** In `MainWindowViewModel` den Platzhalter ersetzen: + +```csharp +new PlaceholderPageViewModel("Info", "…") // vorher +new InfoPageViewModel(host) // nachher +``` + +**4.** `x:DataType` in der AXAML setzen. Ohne das greifen die kompilierten Bindungen nicht +und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf. + +### Werkzeugkasten + +- Zustand: `[ObservableProperty] private string _text = "";` → erzeugt `Text` samt + Benachrichtigung. +- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als + `SpeichernCommand`. +- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente. + Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`. +- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`. +- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`. + +--- + +## 4. Prüfliste für die leisen Fehler + +Diese Klasse bricht weder den Build noch die Tests. Vor jeder Abgabe durchgehen: + +- [ ] **Fenster-Schließen behandelt?** Wartet der Code auf eine Antwort aus einem Fenster + (`TaskCompletionSource`), muss `window.Closed` als Abbruch gelten. Sonst hängt der + Ablauf lautlos für immer. +- [ ] **Sammlungen nur vom Oberflächenfaden geändert?** Siehe 2.2. +- [ ] **Wächst etwas unbegrenzt?** Listen, die im Betrieb volllaufen, brauchen eine + Obergrenze (`LogPageViewModel.MaxLines = 2000` als Vorbild). +- [ ] **Timer beendet?** `DispatcherTimer` in einem Ansichtsmodell läuft weiter, auch wenn + der Bereich nicht sichtbar ist. Bei teuren Abfragen anhalten. +- [ ] **Farben aus dem Thema?** Keine festen Farbwerte — die Anwendung läuft hell und + dunkel. `{DynamicResource …}` verwenden. +- [ ] **Keine relativen Pfade.** `./Backups` und Ähnliches hängt vom Arbeitsverzeichnis ab + und zeigt unter Linux ins Leere. `AppPaths.DataDirectory` verwenden. +- [ ] **Kein `MessageBox`, kein `System.Windows.Forms`, kein `System.Drawing`.** + +--- + +## 5. Abnahme + +```bash +dotnet build ClawdDotNet.slnx +``` + +```bash +dotnet test tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj +``` + +Beide müssen fehlerfrei sein — 567 Tests, keine neuen Fehlschläge. + +**Und dann tatsächlich starten.** Die Oberfläche hat keine Testabdeckung; die Fehler aus +Abschnitt 4 fallen ausschließlich beim Laufen auf. + +```bash +dotnet run --project src/ClawdDotNet.Desktop +``` + +Hinweis: Ein Starttest hinterlässt unter Windows einen Prozess, der die `.exe` sperrt und +den nächsten Build mit `MSB3021` scheitern lässt. Aufräumen mit: + +```bash +powershell -Command "Get-Process ClawdDotNet -EA SilentlyContinue | Stop-Process -Force" +``` + +--- + +## 6. Wenn etwas unklar ist + +Lieber nachfragen als raten. Zwei Dinge sind besonders leicht falsch zu machen: + +- **Was gehört in welche Schicht?** Im Zweifel nach `App` — von dort kann die Oberfläche + es holen, umgekehrt nicht. +- **Wie kommen Daten aus der Engine in die Ansicht?** `AppHost` gibt `Engine`, `Scanner`, + `Staging`, `Status` und `Usage` heraus; alle sind **nullbar**, wenn kein + OpenRouter-Schlüssel hinterlegt ist. Diesen Fall mitdenken — die Anwendung läuft dann + bewusst ohne Agenten. diff --git a/docs/Bestandsaufnahme-2026-07.md b/docs/Bestandsaufnahme-2026-07.md index ebc16eb..9c43c94 100644 --- a/docs/Bestandsaufnahme-2026-07.md +++ b/docs/Bestandsaufnahme-2026-07.md @@ -523,6 +523,11 @@ Siehe K3. ## 6. Vorgeschlagene Reihenfolge +> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte +> werden dort weitergeführt; dieser Abschnitt bleibt als Stand der Bestandsaufnahme +> eingefroren. F-A1/S4, F-A2, F-A5, T6, T7 sowie B6–B8 sind in den Roadmap-Vorhaben +> A1–A4 aufgegangen. + **Sofort — es blockiert oder gefährdet den Betrieb** 1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben 2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben @@ -535,9 +540,9 @@ Siehe K3. 6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`) 7. ~~T2 Tool-Ergebnisse kappen (= B5)~~ ✅ umgesetzt 8. ~~T3 Günstiges Compaction-Modell~~ ✅ umgesetzt -9. ~~B4 Kostenerfassung korrigieren~~ ✅ teilweise: Prompt/Completion werden jetzt - getrennt erfasst statt 50/50 geschätzt. Offen bleibt die veraltete, hartcodierte - Preistabelle (`ModelPricing`) — Preise sollten vom `/models`-Endpoint kommen. +9. ~~B4 Kostenerfassung korrigieren~~ ✅ vollständig: Prompt/Completion getrennt + erfasst, Preise kommen live vom `/models`-Endpunkt (`ModelPricingCatalog`), + Modelle ohne Preisdaten werden sichtbar gemeldet statt still mit 0 gerechnet. 10. ~~B12 Retry/Backoff~~ ✅ umgesetzt 11. T4 Proaktiv statt reaktiv kompaktieren diff --git a/docs/Deploymentcenter-Anbindung-Review.md b/docs/Deploymentcenter-Anbindung-Review.md new file mode 100644 index 0000000..40dc6a0 --- /dev/null +++ b/docs/Deploymentcenter-Anbindung-Review.md @@ -0,0 +1,308 @@ +# Deploymentcenter-Anbindung — Durchsicht + +> **Nachtrag 2026-08-08 — die Anbindung ist umgestellt, Server und SDK stehen auf 2.1.** +> Abschnitt 4 und 5 sind abgearbeitet; wie es jetzt aussieht, steht in +> [Deploymentcenter-Integration](Deploymentcenter-Integration.md). +> +> Mit **SDK 2.1 erledigt** (waren Befunde aus Abschnitt 3 bzw. aus der Durchsicht der +> 2.0-Anbindung): +> +> - `HttpClient` ohne Zeitgrenze → intern 15 s. Unsere Umgehung (eigener Client mit +> 8 s) ist zurückgebaut. +> - HTTP 429/5xx entzogen die Lizenz, ohne den Zwischenspeicher zu befragen → jeder +> Nicht-Erfolg führt jetzt in denselben Offline-Zweig, `IsTransient` macht den +> Unterschied sichtbar. Unsere Behelfsprüfung auf `unknown_error` ist entfernt. +> - `cache_ttl_hours` wurde ignoriert, die Gnadenfrist war faktisch unbegrenzt. +> - `app_version` fest `"1.0.0"` → kommt jetzt aus `ReleaseInfo.Version`. +> - `BuildInfo.targets` war nicht einbindbar (CS0433/CS0103) → erzeugt die Klasse im +> eigenen Namensraum, ist eingebunden. +> - `UpdateClient`: API-Zweig las snake_case in ein camelCase-Modell → eigenes Modell +> `ApiReleaseInfo`, `is_critical` von der obersten Ebene. +> - `DeactivateAsync` schickte den Shared Key zusätzlich als `X-Watchdog-Key`. +> - Kein `CancellationToken` in der Lizenz-API. +> +> **Weiterhin offen** — betrifft das Deploymentcenter, nicht ClawdDotNet: +> +> - **2.1 (keine Signaturprüfung)** — unverändert. `LicenseInfo.PublicKeyBase64` ist +> gestrichen, damit nichts Totes stehenbleibt und niemand Schutz vermutet, wo keiner +> ist. Kommt die Signatur, kommt das Feld mit ihr zurück. +> - **2.2 (v1-Ersatzhash)** und **2.3 (Klartext-Rückfall)** — unverändert, beides im +> SDK zu beheben. +> - **3 (HW-ID bei jedem Aufruf neu)** — clientseitig umgangen: einmal berechnet und +> behalten. +> - **`parent_source` ist nur eine `source`, kein Paar** — damit schließen sich „ein +> Monitor je Instanz" und instanzweise Alarmunterdrückung gegenseitig aus. + +Stand: 2026-08-06. Geprüft: `J:\Softwareprojekte\Deploymentcenter` (Client, Server, +Schema, beide Integrationsleitfäden) gegen den +[HW-ID-v2-Vorschlag](Lizenz-HardwareId-v2-Implementierungsvorschlag.md) und die +[Linux-Analyse](Linux-Portierung-Analyse.md). + +**Ergebnis vorweg: Die Lizenz blockiert den Linux-Umzug nicht mehr.** Alles, was +an Hardware-ID v2 plattformrelevant war, ist da und richtig. Was hier steht, sind +Punkte aus derselben Durchsicht — drei davon würden beim Ausrollen wehtun. + +--- + +## 1. Was erledigt ist + +| Punkt aus dem Vorschlag | Umsetzung | +|---|---| +| Format `2::` | [HardwareId.cs:125](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs) | +| **Kein `MachineName` im Hash** | `ComputeV2Hash`, `:138` — der wichtigste Punkt, sauber umgesetzt | +| Quellenkette Windows/Linux | `:42–107`, inklusive `dmi-uuid` | +| `IsPlausibleMachineId` (Länge, `uninitialized`, nur Nullen) | `:161` | +| MAC-Filter über locally-administered-Bit | `:224` | +| `/sys/class/net//device`-Prüfung | `:228` | +| Erweiterte Stoppwortliste | `:23` — inkl. `br-`, `virbr`, `cni`, `cali` | +| `machine.key` mit `0600` | `:290`, `SetUnixPermissions` mit `#if NET8_0_OR_GREATER` | +| Vorgabe per Umgebungsvariable | `LicenseConfig.HardwareIdOverride`, beide Namen | +| XDG-Auflösungskette, nie leerer Pfad | [LicenseConfig.cs:28](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs), mit `ValidateNonEmpty` | +| Mehrfachziel `netstandard2.0;net8.0` | csproj, BouncyCastle nur im netstandard-Zweig | +| `LLS2`-Hülle, AES-GCM, HKDF | [StateStore.cs:169](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) — Schlüssel aus HW-ID abgeleitet, bindet den Cache also echt an die Maschine | +| `ILicensePrompt` + Konsolenfassung | vorhanden — genau das, was der kopflose Host braucht | +| Servermigration v1→v2 | [LicenseService.php:108](../../Deploymentcenter/src/Modules/License/LicenseService.php), mit Prüfprotokolleintrag `hwid_migrated` | +| Schema `hwid_version`/`hwid_source`/`platform` | `sql/migrations/v2_hardware_id.sql`, rückwärtskompatibel | +| Verwaltungsansicht zeigt Quelle/Plattform | `public/index.php:1227` | + +`OperatingSystemHelpers` nutzt jetzt `RuntimeInformation`. Der Client hat auf dem +Linux-Pfad keine Windows-Laufzeitabhängigkeit — `ProtectedData` wird nur unter +`IsWindows()` aufgerufen. + +**Für die Portierung heißt das:** Punkt 4 aus der Entscheidungsliste der +Linux-Analyse („Erlaubt LicenseLabrador den Wechsel der Hardware-ID?") ist +beantwortet. Der Aufwandsblock „Lizenz" schrumpft von 3–5 PT auf **2–3 PT** — +das ist jetzt reine Anschlussarbeit in ClawdDotNet, keine Konzeptarbeit mehr. + +--- + +## 2. Drei Befunde, die vor dem Ausrollen geklärt sein sollten + +### 2.1 Es wird nichts signiert — die Lizenzprüfung ist eine Vertrauensfrage an DNS + +[LicenseClient.cs:62](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs): + +```csharp +string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown"; +if (status.Equals("valid", StringComparison.OrdinalIgnoreCase)) +{ + // → gültig +} +``` + +Das ist die vollständige Prüfung. Es gibt im neuen Client **kein `Signature.cs`, +keinen hinterlegten öffentlichen Schlüssel, keine Hüllenprüfung** — die Dateien +`Signature.cs`, `LicenseResult.cs` und `LicenseState.cs` aus dem alten +LicenseLabrador-SDK sind beim Umzug nicht mitgekommen. + +Folge: Wer die HTTP-Anfrage umlenken kann, hat eine gültige Lizenz. Ein Eintrag +in `/etc/hosts`, ein Proxy, ein eigener DNS — die Antwort `{"status":"valid"}` +genügt. Auf einem Linux-Server, den der Betreiber ohnehin vollständig +kontrolliert, ist das kein Kunststück. + +Serverseitig sieht es passend dazu aus. `public/index.php:45`: + +```php +'signature' => 'ED25519_SIG_' . base64_encode(hash('sha256', $lic['license_key'] . 'DC_OFFLINE_SECRET', true)) +``` + +Das ist ein SHA-256 über den Lizenzschlüssel plus eine fest verdrahtete +Zeichenkette — keine Signatur, sondern ein Wert, der jeder erzeugen kann, der den +Quelltext kennt. Und `public/index.php:1993` im JavaScript: + +```javascript +"ED25519_SIG_" + btoa(key + hwId).substring(0, 32) +``` + +Base64 der Eingabe, abgeschnitten. Auch kein Hash. + +Das ist erkennbar ein Platzhalter — nur trägt er einen Namen, der nach fertigem +Verfahren klingt, und darauf verlässt sich [LicenseGate](Services/LicenseGate.cs) +mit seiner harten Startsperre. **Es ist keine Portierungsfrage** (unter Windows +gilt heute dasselbe) und auch kein Grund, den Linux-Umzug aufzuhalten — aber es +sollte eine bewusste Entscheidung sein und nicht in dem Glauben untergehen, die +Signaturprüfung sei bereits da. + +Wenn das Verfahren zurückkommen soll: Ed25519 über die kanonisch serialisierte +Antwort, öffentlicher Schlüssel im Client einkompiliert, `nonce` aus der Anfrage +in der signierten Nutzlast gegenprüfen (gegen Wiedereinspielung). Der alte +`Signer.php` und `Signature.cs` sind im LicenseLabrador-Repo noch vorhanden und +lassen sich als Vorlage nehmen. + +### 2.2 Der v1-Ersatzhash trifft die alten Aktivierungen nicht + +Der Migrationsweg ist auf beiden Seiten korrekt gebaut — er wird nur nie +auslösen, weil der Client eine andere v1-ID berechnet als die, die in der +Datenbank steht. + +Alt ([LicenseLabrador/HardwareId.cs:20](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)): + +```csharp +rawBuilder.Append(machineId); // MachineGuid, sonst MAC +rawBuilder.Append(Environment.MachineName); // direkt angehängt, kein Trenner +→ sha256(machineGuid + machineName) +``` + +Neu ([HardwareId.cs:149](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs)): + +```csharp +string raw = $"{Environment.MachineName}:{firstMac}"; +→ sha256(machineName + ":" + mac) +``` + +Andere Reihenfolge, anderer Trenner, und **MAC statt MachineGuid**. Auf jedem +Windows-Rechner, auf dem `MachineGuid` lesbar war — also praktisch allen — +stimmen die Hashes nicht überein. Der Server sucht die Altaktivierung, findet +nichts und legt eine neue an: **genau der Platzverbrauch, den die Migration +verhindern sollte.** Bei `max_activations = 2` ist danach ein Platz für den +Linux-Server weniger da. + +Auch `GetFirstPhysicalMacLegacy` (`:254`) weicht ab: keine Stoppwortfilterung, +keine Sortierung, erste Schnittstelle in Aufzählungsreihenfolge. Die alte +Fassung nahm die alphabetisch erste *gefilterte* MAC. + +Zu tun: `GetLegacyHardwareId()` muss den v1-Algorithmus zeichengenau +nachbilden — inklusive der alten Stichwortliste (`virtual`, `veth`, `docker`, +`hyper-v`, `wsl`, `mullvad`, `wireguard`, `tap`, `tun`, `vpn`, `bluetooth`, +`vmware`, `box`, `pseudo`, `loopback`, `npcap`, `pcap`), `OrderBy(…, Ordinal)` +und `FirstOrDefault()`. Der Code steht im LicenseLabrador-Repo noch da und kann +weitgehend übernommen werden. + +Am besten mit einem Test absichern, der einen bekannten Eingabewert gegen den +erwarteten v1-Hash prüft — sonst fällt eine Abweichung erst auf, wenn die +Aktivierungsplätze schon verbraucht sind. + +### 2.3 Der Klartext-Rückfall ist noch da, nur woanders + +[StateStore.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) — +„Legacy Migration Check": + +```csharp +string legacyJson = Encoding.UTF8.GetString(payloadBytes); +var legacyData = JsonSerializer.Deserialize(legacyJson); +if (legacyData != null) +{ + legacyData.SchemaVersion = 2; + Save(productSlug, hardwareId, legacyData); + return legacyData; +} +``` + +Der LLS2-Zweig darüber ist genau richtig — Entschlüsselung fehlgeschlagen heißt +Cache-Fehltreffer, kein Klartext. Der Zweig darunter hebt das wieder auf: Jede +Datei ohne `LLS2`-Kennung wird als JSON gelesen und, wenn sie sich deserialisieren +lässt, **übernommen und anschließend verschlüsselt neu geschrieben**. + +Durchgespielt: Eine von Hand angelegte `state.dat` mit + +```json +{"SchemaVersion":2,"Status":"valid","ExpiresAt":99999999999,"MaxSeenTime":0} +``` + +wird angenommen. In `ValidateAsync` greift bei fehlender Verbindung der +Cache-Zweig (`:110`): `Status == "valid"` ✓, `now < MaxSeenTime` ✗, `now > +ExpiresAt` ✗ → **`IsValid = true`**. Die Bindung an die Hardware, die +`DeriveKey(hardwareId, …)` sonst herstellt, ist auf diesem Weg umgangen; die +Datei ist zwischen Maschinen übertragbar. + +Der Zweig hilft dabei nicht einmal beim eigentlichen Zweck. Die alte +`LocalCacheData` hieß `last_envelope`, `max_seen_time`, `endpoints`, +`last_license_key`; die neue `SchemaVersion`, `Status`, `ExpiresAt`, … Kein +gemeinsames Feld, und `JsonSerializer` ist ohne +`PropertyNameCaseInsensitive`/`JsonPropertyName` bei den Namen streng. Eine echte +v1-Datei ergibt also ein Objekt mit lauter Vorgabewerten (`Status = "invalid"`) +und ist als Cache wertlos. + +**Empfehlung: den Zweig ersatzlos streichen.** Er kostet Sicherheit und leistet +nichts. Alte Cachedateien sollen verworfen werden — eine einmalige +Online-Prüfung ist der ganze Preis. + +Nebenbei: `Checksum = hwInfo.HardwareId` ([LicenseClient.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs)) +ist keine Prüfsumme, sondern eine Kopie der HW-ID. Das Feld ist damit ohne +Funktion — entweder mit einem HMAC über die übrigen Felder füllen oder entfernen, +damit niemand später Schutz vermutet, wo keiner ist. + +--- + +## 3. Kleinere Punkte + +| Fundstelle | Sache | +|---|---| +| [LicenseClient.cs:26](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs) | Eigener `HttpClient` je Instanz, nie freigegeben, **ohne Zeitgrenze** (Vorgabe 100 s). Der alte `LicenseConfig.HttpTimeout` war 6 s. In `LicenseGate.RunStartupCheck` bedeutet das bis zu 100 s Standbild beim Start, wenn der Server nicht antwortet. | +| `:107` | `catch (Exception ex)` um den gesamten Block: Auch ein Fehler beim Auswerten einer *erfolgreichen* Antwort landet im Offline-Zweig. Ein defekter Server gilt dann als „offline". | +| `:47` | `app_version = "1.0.0"` fest verdrahtet. ClawdDotNet hat `BuildInfo.Build` — sollte Parameter sein, sonst steht in der Verwaltungsansicht bei jeder Instanz dasselbe. | +| `:32`, `:172` | `HardwareId.GetHardwareId()` bei jedem Aufruf neu: liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. Einmal berechnen und halten. | +| `HardwareId.cs:205` | MAC-Auswahl überspringt Schnittstellen, die nicht `Up` oder `Unknown` sind. Ein Kabel, das beim Start nicht steckt, ändert damit die Hardware-ID. Für die Ausweichlösung sollte der Betriebszustand keine Rolle spielen — sonst ist sie genau in dem Moment instabil, in dem sie gebraucht wird. | +| `HardwareId.cs:231` | `/sys/class/net//device` ist ein Symlink. `Directory.Exists`/`File.Exists` folgen ihm — funktioniert, ist aber Zufall und sollte kommentiert sein. | + +--- + +## 4. Watchdog: die Anbindung passt noch nicht + +Kein Linux-Thema, fällt aber in dieselbe Umbauarbeit. + +[WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) sendet an: + +| ClawdDotNet | Deploymentcenter | +|---|---| +| `POST /api/heartbeat` | `POST /api/watchdog/v1/ping` (nimmt auch `/heartbeat`) | +| `POST /api/event` | `POST /api/watchdog/v1/event` | +| `POST /api/register` | **existiert nicht** | + +Die Pfade sind also alle um `/watchdog/v1` zu ergänzen. Der Kopfzeilenname passt: +`public/api/watchdog/v1/index.php:30` akzeptiert `X-Watchdog-Key`, +`Authorization` und `X-Agent-Token`. + +Der Selbstregistrierungsweg aus [Program.cs:334](Program.cs:334) — mit dem +Master-Token einen eigenen Agent-Token holen und in der Instanzkonfiguration +zwischenspeichern — hat serverseitig kein Gegenstück mehr. Zu klären: Tokens +künftig von Hand in der Verwaltung anlegen und in die Instanzkonfiguration +eintragen, oder `/register` im Deploymentcenter nachziehen. Für den ersten Weg +spricht, dass er den Master-Token gar nicht erst auf die Instanzen verteilt. + +Die Feldnamen des Ping-Rumpfs (`source`, `instance`, `type`, `status`, `message`, +`interval`, `group`, `os`) sind gegen +[InstanceHealthProvider](src/ClawdDotNet.Core/Watchdog/InstanceHealthProvider.cs) +abzugleichen. + +--- + +## 5. Was in ClawdDotNet zu tun ist + +| Datei | Was | +|---|---| +| [ClawdDotNet.csproj](ClawdDotNet.csproj) | Projektverweis von `..\LicenseLabrador\client-dotnet\…` auf `..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\…` umhängen. Langfristig als Submodul unter `external/` — der Kommentar dazu steht schon im csproj. | +| [Services/LicenseGate.cs](Services/LicenseGate.cs) | Neu gegen `LicenseValidationResult` schreiben. `LicenseState` gibt es nicht mehr, `Status` ist jetzt eine Zeichenkette — `DescribeProblem` (`:125`) muss auf `revoked`/`expired`/`activation_limit`/`not_found` umgestellt werden. `MessageBox` durch `ILicensePrompt` ersetzen; die Konsolenfassung bringt der Client mit. | +| [Services/LicenseInfo.cs](Services/LicenseInfo.cs) | `PublicKeyBase64` hat ohne Signaturprüfung keine Funktion mehr — entweder mit 2.1 zurückholen oder streichen, damit nichts Totes stehenbleibt. | +| [Program.cs:112](Program.cs:112) | Lizenzprüfung so verlagern, dass sie ohne Fenster auskommt (kopfloser Host). | +| Host (neu) | `--license-status`, `--license-set-key`, `--license-deactivate` — der Client bringt alles Nötige mit. | +| [WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) | Pfade auf `/api/watchdog/v1/…`; Registrierungsweg klären (Abschnitt 4). | +| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Abgelöst durch [Deploymentcenter-Integration](Deploymentcenter-Integration.md). | + +--- + +## 6. Antwort auf die Ausgangsfrage + +**Ja — Avalonia und Linux sind damit machbar.** Die einzige Frage, die ich als +möglicher Blocker außerhalb unserer Hand markiert hatte, ist geklärt: Der Client +läuft auf beiden Plattformen, zielt auf `net8.0` (von net10.0 problemlos +verwendbar), löst seinen Ablageort auch ohne `HOME` auf, und die HW-ID ist +container- und umbenennungsfest. + +Der Lizenzblock in der Aufwandsschätzung fällt von 3–5 PT auf **2–3 PT**. Die +Gesamtspanne bleibt bei **50–80 PT**, weil die Lizenz nie der große Posten war — +das sind PropertyGrid und Chat-Ansicht. + +Zwei Dinge sollten aber vor dem Ausrollen erledigt sein, unabhängig von Linux: + +- **2.2 (v1-Ersatzhash)** — klein, aber wenn es beim Ausrollen falsch ist, sind + Aktivierungsplätze verbraucht und man bekommt sie nur einzeln über die + Verwaltung zurück. Das ist der Punkt mit dem schlechtesten Verhältnis von + Aufwand zu Schaden. +- **2.3 (Klartext-Rückfall)** — eine Zeile weniger Code, dafür wieder das + Verhalten, das der `LLS2`-Umbau eigentlich herstellen sollte. + +**2.1 (keine Signaturprüfung)** ist eine eigene Entscheidung mit eigenem Umfang +und hält den Umzug nicht auf. Sie sollte nur getroffen und nicht übersehen +werden — der Name `ED25519_SIG_` im Serverquelltext legt sonst nahe, dass die +Sache erledigt sei. diff --git a/docs/Deploymentcenter-Integration.md b/docs/Deploymentcenter-Integration.md new file mode 100644 index 0000000..b213983 --- /dev/null +++ b/docs/Deploymentcenter-Integration.md @@ -0,0 +1,338 @@ +# Deploymentcenter-Integration + +Stand: 2026-08-08, Deploymentcenter **2.1**. Ersetzt den früheren +`Integrationsplan-WatchDog-LicenseLabrador.md`. + +ClawdDotNet spricht das [Deploymentcenter](../../Deploymentcenter/docs/README.md) als +**eine** Gegenstelle an. Vorher waren es zwei Fremdprojekte mit je eigenem Server, +eigenem Schlüssel und eigener Anleitung: + +| Vorher | Jetzt | +|---|---| +| WatchDog (`watchdog.mhdf.de`, `X-Watchdog-Key`) | Deploymentcenter-Modul Watchdog, `Authorization: Bearer` | +| LicenseLabrador (`license.mhdf.de`, Ed25519-Public-Key) | Deploymentcenter-Modul Lizenz | +| — | Update-Prüfung | +| — | Fehler-Stream (ungefangene Ausnahmen) | +| — | Bugtracker | + +Eine Adresse, ein Token. Beides steht in den Anwendungseinstellungen. + +--- + +## 1. Was der Betreiber einzutragen hat + +| Ort | Wert | +|---|---| +| Einstellungen → Deploymentcenter → **Server-URL** | `https://dc.mhdf.de` (Vorgabe) | +| Einstellungen → Deploymentcenter → **Token** | Master-Token mit `watchdog:ping` + `bugtracker:report` | +| Einstellungen → Lizenz → **Lizenzschlüssel** | Der Schlüssel für das Projekt `clawddotnet` | +| Worker-Tab → Dienst **Instanz-Watchdog** | einschalten, greift beim nächsten Start der Instanz | + +Das Token entsteht im WebUI unter **Token-Verwaltung → Master-Token erstellen**. Es +wird verschlüsselt (DPAPI) in `Settings.json` abgelegt. + +> **Bis die Avalonia-Einstellungsansicht steht**, gibt es für diese Felder noch keine +> Oberfläche — die Seite „Einstellungen" ist ein Platzhalter. Die Werte kommen +> vorläufig von Hand in `Settings.json` (Ort steht beim Start im Protokoll: +> `%APPDATA%\ClawdDotNet\Settings.json`, unter Linux `$XDG_CONFIG_HOME`) bzw. in die +> `instance.json` der Instanz. Das Token wird beim ersten Speichern durch die Anwendung +> verschlüsselt; im Klartext eingetragen funktioniert es ebenfalls, weil der +> `SecretProtector` beide Richtungen verträgt. + +**Serverseitig ist eine Sache Pflicht**, sonst ist die Überwachung wertlos: der +Evaluator-Cron. Ohne ihn ändert sich ein Monitor-Zustand nur beim Eintreffen eines +Heartbeats — eine abgestürzte Instanz bliebe dauerhaft grün. + +```bash +* * * * * curl -fsS -H "Authorization: Bearer " https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null +``` + +--- + +## 2. Watchdog — ein Monitor je Instanz + +Das war die Vorgabe und ist jetzt sauber abgedeckt: Der Server führt Monitore über das +Paar `source` + `instance` (`UNIQUE KEY uq_monitor (source, instance)` in +`sql/schema.sql`). Alle Instanzen melden unter `source = "clawddotnet"` und tragen ihre +eigene `instance`. Fällt eine von dreien aus, fällt genau deren Monitor — und nur der +schlägt Alarm. + +`instance` ist standardmäßig die `InstanceId` (stabil, aber im Dashboard nichtssagend). +In den Instanz-Einstellungen lässt sich stattdessen ein Name eintragen +([`WatchdogConfig.Instance`](../src/ClawdDotNet.Core/Config/WatchdogConfig.cs)); ein +späterer Wechsel legt allerdings einen neuen Monitor an. + +**Eine Registrierung vorab gibt es nicht mehr.** Der Monitor entsteht beim ersten +Heartbeat von selbst (`INSERT … ON DUPLICATE KEY UPDATE`). Der frühere Weg über +`POST /api/register` hatte im Deploymentcenter nie ein Gegenstück — die alte Anbindung +lief in dieser Form also gegen einen Endpunkt, den es nicht gibt. + +### Was der Heartbeat trägt + +``` +POST /api/watchdog/v1/ping +Authorization: Bearer +``` + +| Feld | Inhalt | +|---|---| +| `source` / `instance` | `clawddotnet` / InstanceId bzw. eingestellter Name | +| `status` | `ok`, `warning`, `error` — beim Beenden `stopped` | +| `interval` | 60 s (Vorgabe). Daraus leitet der Evaluator ab: 2× → `warning`, 4× → `down` | +| `message` | Instanzname + Kurzbegründung | +| `os` | Betriebssystem + .NET-Version | +| `version` | Produktversion (2.1). Landet in `watchdog_monitors.app_version` — bei mehreren Instanzen der Unterschied zwischen „läuft" und „läuft noch auf der alten Fassung" | +| `checks` | `agents`, `scheduler`, `budget` — siehe unten | +| `metrics` | `agentCount`, `runningChats`, `todayCostUsd`, `todayTokens` | + +### `checks` — der eigentliche Gewinn + +Ein Heartbeat beweist nur, dass ein Faden läuft. Deshalb geht der selbst ermittelte +Zustand je Teilbereich mit; schlägt eine Prüfung fehl, stuft der Server einen als `ok` +gemeldeten Beat auf `warning` herab und nennt in der Antwort die betroffene. + +| Prüfung | Fehlschlag bedeutet | +|---|---| +| `agents` | Kein OpenRouter-Key — die Instanz läuft, arbeitet aber nichts ab | +| `scheduler` | Die Taktschleife des Aufgaben-Scanners ist ausgestiegen | +| `budget` | Tagesgrenze für Kosten oder Token erreicht | + +„Scanner noch nicht gestartet" gilt **nicht** als Fehlschlag: Er läuft erst nach der +Startabgleichung an, der erste Heartbeat geht sofort raus. Sonst gäbe es bei jedem +Start ein `warning_raised` und kurz darauf ein `recovered` — zwei Einträge im +Ereignisprotokoll für einen Normalvorgang. + +### Metriken sind nur Zahlen + +Der Server legt numerische Werte mit Zeitstempel ab (14 Tage) und vergleicht den +aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische Werte +verwirft er dabei stillschweigend — `instanceId`, `instanceName` und `buildVersion` +standen früher in den Metriken und waren dort wirkungslos. Beschreibendes steht jetzt +in `message` und `os`. + +### Angekündigtes Ende + +Beim Herunterfahren geht ein Heartbeat mit `status: "stopped"` raus, danach das Ereignis +`stopped_graceful`. Der Evaluator lässt einen so gemeldeten Monitor in Ruhe. Ohne das +erzeugte jedes geplante Beenden wenige Minuten später einen Fehlalarm. + +Nebenbei korrigiert: Die alte Anbindung schickte die Ereignisarten `start` und `stop` — +beide stehen nicht auf der Liste des Servers und landeten stillschweigend als `started`. +Jetzt sind es `started` und `stopped_graceful`. + +### Token je Instanz + +Beim ersten Start tauscht die Instanz das anwendungsweite Token über +`POST /api/tokens/v1/provision` gegen ein eigenes, auf `watchdog:ping` und +`bugtracker:report` beschränktes Sub-Token und legt es verschlüsselt in der +Instanzkonfiguration ab. Danach liegt auf der Instanz nicht mehr das Master-Token, und +ein einzelner Zugang lässt sich widerrufen, ohne die anderen mitzunehmen. + +Das ist derselbe Zweck, den die frühere Selbstregistrierung hatte. Scheitert es (etwa +weil das hinterlegte Token selbst ein Sub-Token ist und keine weiteren ausstellen darf), +wird mit dem hinterlegten Token gemeldet — Monitoring, das nur bei perfekter Rechtelage +läuft, ist genau dann still, wenn man es braucht. + +--- + +## 3. Lizenz + +Startprüfung in [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs). Die +Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung einen mit +AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an (`LLS2`, +seit 2.1 Schema 3) und trägt damit über Ausfälle hinweg. + +### Urteil und Fehlversuch sind zwei verschiedene Dinge + +Das ist der Kern der 2.1-Anpassung. `LicenseValidationResult.IsTransient` unterscheidet: + +| | Statuswerte | Folge | +|---|---|---| +| **Urteil des Servers** | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Anwendung startet nicht bzw. beendet sich | +| **Kein Urteil erhalten** | `server_unavailable`, `cache_expired` | Warnung, Betrieb läuft weiter | + +Nur das Urteil sperrt. Ein Serverausfall darf nicht jede Installation gleichzeitig +aussperren — und eine Drosselung (`429`) oder ein `500` sind Aussagen über den Server, +nicht über die Lizenz. Das gilt an beiden Stellen gleich: Startprüfung und laufende +Nachprüfung fragen dasselbe Merkmal ab. + +> **Bewusst in Kauf genommen:** Ein Rechner, der die Gegenstelle nie erreicht, läuft +> damit auf Dauer mit Warnung weiter — auch nach Ablauf der Gnadenfrist +> (`cache_expired` ist laut SDK-Vertrag vorübergehend). Wer das anders will, prüft in +> [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs) zusätzlich auf +> `cache_expired` und behandelt es als Urteil. Es sollte eine Entscheidung sein, nicht +> ein Nebeneffekt. + +### Offline-Gnadenfrist ist echt begrenzt + +Seit 2.1 wertet der Client `cache_ttl_hours` des Projekts aus (Vorgabe 168 h). Vorher +galt faktisch das Ablaufdatum der Lizenz — bei einer Lizenz bis 2040 also unbegrenzt. +Der verbleibende Rest steht in `CacheExpiresAt` und wird beim Start angezeigt, wenn die +Prüfung aus dem Zwischenspeicher kam. + +`state.dat` steigt auf Schema 3; Schema 2 wird weiter gelesen. Ein Rückschritt auf ein +älteres SDK verwirft den Zwischenspeicher — dann ist einmal eine Online-Prüfung nötig. + +### Kein Public-Key mehr + +Die frühere Fassung führte einen Ed25519-Public-Key als „Vertrauensanker". Im +Deploymentcenter gibt es dazu keine Gegenseite — der Client liest ausschließlich das Feld +`status`. Ein Schlüssel, der nichts prüft, ist schlimmer als keiner: Er lässt Schutz +vermuten, wo keiner ist. Details in +[Deploymentcenter-Anbindung-Review](Deploymentcenter-Anbindung-Review.md), Abschnitt 2.1. + +### Der Projekt-Slug ist `clawddotnet` + +[`LicenseInfo.ProductSlug`](../src/ClawdDotNet.App/Services/LicenseInfo.cs) gilt für +**alle** Module — Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf +dieselbe Tabelle `dc_projects` zu. + +Bis zur Umstellung stand hier `clawd`, der Name aus dem LicenseLabrador-Backend. Das +ist eine Falle mit langer Zündschnur: Der Server beantwortet ein unbekanntes Projekt mit +demselben `not_found` wie einen unbekannten Schlüssel — der Unterschied steht +ausschließlich in `message` (`"Project not found"` gegen `"Invalid license key"`). Wer +den Text nicht durchreicht, sucht den Fehler beim Lizenzschlüssel, während das Projekt +gar nicht existiert. Der Torwächter gibt die Serverantwort deshalb mit aus und schreibt +sie ins Protokoll. + +### Deaktivieren läuft über das WebUI + +`POST /api/license/v1/deactivate` verlangt den `shared_key` des Servers. Der gehört nicht +in eine ausgelieferte Anwendung, deshalb ist der Weg die Hardware-Liste im WebUI +(Schaltfläche „Freigeben"). + +### Laufende Nachprüfung + +[`LicenseWatch`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) prüft alle zwölf +Stunden nach — dieselbe Unterscheidung wie oben. Ohne das wirkt ein Widerruf erst beim +nächsten Start, bei einem wochenlang laufenden Dienst also praktisch nie. + +--- + +## 4. Version und Updates + +### Eine Stelle für die Version + +`` in [Directory.Build.props](../Directory.Build.props) ist die Wahrheit. +`Deploymentcenter.BuildInfo.targets` (seit 2.1 einbindbar) erzeugt daraus zur +Übersetzungszeit `ClawdDotNet.App.ReleaseInfo` mit `Version`, `GitCommit`, +`GitCommitShort`, `BuildDateUtc`, `Channel` und `Summary`. + +Der Wert geht an vier Stellen nach draußen, die vorher alle geraten haben: + +| Stelle | Vorher | +|---|---| +| Aktivierungsliste (`app_version`) | fest `"1.0.0"` im SDK — jede Installation gleich | +| Heartbeat (`version`) | gab es nicht | +| Fehlermeldungen (`build`) | — | +| Versionsvergleich der Update-Prüfung | `0.0.`, behelfsweise | + +Die Klasse heißt bewusst `ReleaseInfo`, nicht `BuildInfo`: Diesen Namen trägt in +`ClawdDotNet.Core` schon ein von Hand geführter Zähler mit Änderungstext. Zwei +gleichnamige Klassen mit verschiedener Bedeutung wären eine Falle. Umgestellt über +`DeploymentcenterBuildInfoClass` in der csproj. + +### Prüfung + +Einmalig beim Start gegen `GET /api/updateservice/v1/check`, über +`Deploymentcenter.Client.UpdateClient`. Läuft nebenher und blockiert nichts; liegt eine +neuere Version vor, erscheint ein Hinweis mit Changelog. Ob und wann aktualisiert wird, +entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu +erneuern, ist genau dann im Weg, wenn man sie braucht. + +Seit 2.1 liefern beide Wege vollständige Daten: die statische `latest.json` in camelCase, +die API in snake_case, jeweils über ein eigenes Modell (`VersionInfo` bzw. +`ApiReleaseInfo`). Vorher kam über den API-Zweig außer der Versionsnummer nichts an — und +der ist genau der Rückfall, wenn die `latest.json` fehlt. Die Download-Adresse wird +mitgeführt (`UpdateAvailability.DownloadUrl`), damit der `update-agent` später ohne +weitere Änderung anschließen kann. + +Der `update-agent` ist noch **nicht** eingebunden, und solange kein Release über +`pack-and-deploy` veröffentlicht wird, hat die Prüfung nichts zu finden. + +--- + +## 5. Fehler-Stream + +Ungefangene Ausnahmen gehen an `POST /api/errors/v1/report`. Verdrahtet in +[`App.axaml.cs`](../src/ClawdDotNet.Desktop/App.axaml.cs) an drei Stellen: +`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException` und +`Dispatcher.UIThread.UnhandledException`. + +Erst nach dem Aufbau verdrahtet, nicht in `Main`: Vorher gibt es weder Einstellungen +noch Token. Die Kehrseite ist bewusst in Kauf genommen — ein Absturz *während* des +Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll. + +**Eigene Drosselung** in +[`ErrorReporter`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs): Derselbe +Fehler (Typ + oberste Stelle im Stacktrace) geht höchstens einmal alle fünf Minuten +raus. Der Server drosselt auch, aber erst, nachdem die Anfragen über die Leitung waren. +Die Fehlermeldung selbst gehört nicht zum Kennzeichen — sie enthält oft wechselnde +Werte, und dann wäre jeder Aufruf ein neuer Fehler. + +Bekannte, harmlose Fehler lassen sich serverseitig unter **Bugtracker → Ignore-Regeln** +stummschalten. Sie werden weiter gezählt; der Zähler ist der Zweck: Dass ein bekannter +Fehler auftritt, ist normal — dass er plötzlich hundertmal so oft auftritt, bedeutet, +dass sich etwas geändert hat. + +--- + +## 6. Bugtracker + +[`BugtrackerClient`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) für +bewusst formulierte Einträge (Fehler, Wunsch, Idee) mit Titel und Beschreibung, gegen +`POST /api/bugtracker/v1/report`. Der Absender wird serverseitig aus dem Token +abgeleitet und lässt sich nicht frei wählen. + +Der Client ist da und über `AppHost.Deploymentcenter.Bugtracker` erreichbar; **eine +Oberfläche dafür fehlt noch** („Fehler melden"-Schaltfläche). Ein Agenten-Tool wäre der +nächste sinnvolle Schritt — Agenten könnten dann selbst Wünsche und Fehler eintragen, +und der Agenten-Workflow des Deploymentcenters (Claim/Lease über +`manage?action=next`) würde sie abarbeiten. + +--- + +## 7. Wo was liegt + +| Datei | Inhalt | +|---|---| +| [`Deploymentcenter/DeploymentcenterApi.cs`](../src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs) | Gemeinsamer Unterbau: Bearer-Header, HTTPS-Pflicht, Umschlag auspacken, `DeploymentcenterException` mit stabilem `Code` | +| [`Deploymentcenter/Watchdog/`](../src/ClawdDotNet.Core/Deploymentcenter/Watchdog) | Heartbeat-Client, Zustandsermittlung, Takt-Dienst | +| [`Deploymentcenter/ErrorReporter.cs`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs) | Fehler-Stream mit Drosselung | +| [`Deploymentcenter/BugtrackerClient.cs`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) | Bugtracker-Einträge | +| [`Deploymentcenter/TokenProvisioner.cs`](../src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs) | Sub-Token je Instanz | +| [`Services/DeploymentcenterService.cs`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs) | Verdrahtung: Token beschaffen, Heartbeat starten, Update prüfen | +| [`Services/LicenseGate.cs`](../src/ClawdDotNet.App/Services/LicenseGate.cs) | Startprüfung | +| [`Services/LicenseWatch.cs`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) | Laufende Nachprüfung | + +Die Lizenz läuft bewusst **nicht** über `DeploymentcenterApi`: Sie hat ein eigenes +Antwortformat (kein `status`/`error`-Umschlag — `status` trägt dort den Lizenzzustand), +einen eigenen Zwischenspeicher und muss vor allem anderen laufen. + +Watchdog, Fehler-Stream und Bugtracker deckt das SDK `Deploymentcenter.Client` nicht ab; +dafür ist der eigene Unterbau da. Hardware-ID v2, Lizenz-Zwischenspeicher und +Update-Prüfung kommen aus dem SDK — die nachzubauen wäre Verdopplung. + +--- + +## 8. Tests + +[`tests/ClawdDotNet.Core.Tests/Deploymentcenter/`](../tests/ClawdDotNet.Core.Tests/Deploymentcenter): +Bearer-Header, Fehlerumschlag → Ausnahme mit Code (auch bei HTTP 200), HTTPS-Pflicht mit +Localhost-Ausnahme, Heartbeat-Pfad und -Rumpf, zwei Instanzen → zwei Monitore, Checks +und Metriken, Antwort-Auswertung, Drosselung des Fehler-Streams, Sub-Token-Bezug. + +--- + +## 9. Offen + +1. **Oberfläche für den Bugtracker** — Client vorhanden, Schaltfläche fehlt. +2. **Agenten-Tool für den Bugtracker** — würde den Agenten-Workflow des + Deploymentcenters nutzbar machen. +3. **Release-Strecke** — `pack-and-deploy` aufrufen und `` dabei mitgeben, + danach `update-agent` einbinden. Die Versionsnummer selbst ist mit 2.1 erledigt. +4. **SDK als Git-Submodul** unter `external/` statt Cross-Repo-Pfad. +5. **Hierarchie** (`parent_source`): Läuft die Instanz auf einem Host, der selbst als + Monitor geführt wird, sollte sie ihn als übergeordnete Entität eingetragen bekommen — + sonst erzeugt ein Hostausfall eine Meldung je Instanz. Das ist im WebUI zu pflegen, + nicht im Client (siehe aber Anmerkung 2 in den Rückmeldungen). diff --git a/docs/Konzepte-Backup-Finanz-Analyse.md b/docs/Konzepte-Backup-Finanz-Analyse.md index 691d8a8..59e9a51 100644 --- a/docs/Konzepte-Backup-Finanz-Analyse.md +++ b/docs/Konzepte-Backup-Finanz-Analyse.md @@ -278,10 +278,14 @@ Rohzahlen. # Vorgeschlagene Reihenfolge +> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte +> laufen dort als C1–C8 weiter; S4 + K2 sind in den Vorhaben A2/A3 +> (Staging-Freigabe, Audit-Log) aufgegangen. + | # | Was | Warum zuerst | |---|---|---| -| 1 | Atomares Schreiben | Datenverlust ist bereits eingetreten | -| 2 | Backup + Restore mit Test | Schützt alles Folgende | +| 1 | ~~Atomares Schreiben~~ ✅ | umgesetzt (`File.Replace`-Muster) | +| 2 | ~~Backup + Restore mit Test~~ ✅ | umgesetzt inkl. Oberfläche im Settings-Tab | | 3 | Marktkalender | Spart sofort Kosten, verbessert Datenlage | | 4 | `Indicators` | Qualität hoch, Tokens runter | | 5 | Ergebnisregister (Stufe 2) | Grundlage jeder Bewertung | diff --git a/docs/Linux-Portierung-Analyse.md b/docs/Linux-Portierung-Analyse.md new file mode 100644 index 0000000..a9ec1b8 --- /dev/null +++ b/docs/Linux-Portierung-Analyse.md @@ -0,0 +1,574 @@ +# Linux-Portierung — Analyse + +Stand: 2026-08-06. Reine Bestandsaufnahme und Aufwandsschätzung, **kein** Umbau. + +Frage: Was ist nötig, damit ClawdDotNet unter Linux läuft, und was kostet das? + +--- + +## 0. Kurzfassung + +Die gute Nachricht zuerst: **Der Kern ist bereits portabel.** Alle 16 Bibliotheks- +und beide Testprojekte zielen auf `net10.0` (nicht `net10.0-windows`), es gibt im +gesamten Repository **kein einziges `DllImport`, keinen Registry-Zugriff und keine +`System.Drawing`-Nutzung** in `src/`. Windows steckt an genau drei Stellen im Kern: +DPAPI-Verschlüsselung, Groß-/Kleinschreibung bei Pfadvergleichen und die +Zeitzonen-IDs. + +Die schlechte Nachricht: Die gesamte Bedienoberfläche — rund **8.900 Zeilen** in +`frm_*.cs`, `UI/`, `Models/` und `Services/` — hängt an Windows Forms, an WebView2 +und, am unangenehmsten, an vier `PropertyGrid`-Instanzen, die praktisch die +komplette Einstellungsverwaltung ausmachen. Dafür gibt es in Avalonia keine +Eins-zu-eins-Entsprechung. + +**Empfehlung: den Umzug in zwei Schnitte teilen.** Ein kopfloser Host (ohne GUI) +auf Linux ist in etwa **12–18 Personentagen** erreichbar und liefert den +eigentlichen Nutzen — Agenten laufen auf einem Server, nicht auf einem +Windows-Desktop. Die Avalonia-Oberfläche ist ein davon unabhängiges Vorhaben +von **32–52 Personentagen**, das man danach in Ruhe angehen kann. + +Gesamt für „alles auf Linux, mit GUI": **50–80 Personentage.** + +--- + +## 1. Bestandsaufnahme + +### 1.1 Was bereits portabel ist + +| Bereich | Zeilen | Zielframework | Windows-Abhängigkeit | +|---|---:|---|---| +| `src/ClawdDotNet.Core` | 8.959 | `net10.0` | nur DPAPI (1 Datei) | +| 15 Tool-Projekte | 6.415 | `net10.0` | nur `.exe`-Pfade im SocialMediaManager | +| `tests/` (348 Tests, 39 Dateien) | 6.888 | `net10.0` | 3 Testfälle mit `C:\`-Pfaden | + +Alle NuGet-Pakete laufen unter Linux: `Microsoft.Data.Sqlite` (bringt +`e_sqlite3` nativ für linux-x64/arm64 mit), `MySqlConnector`, `Npgsql`, +`Microsoft.Data.SqlClient`, `MongoDB.Driver`, `MailKit`, `FluentFTP`, +`Telegram.Bot`, `WTelegramClient`, `SharpCompress`, `Snappier`, +`Microsoft.Extensions.Logging`. Kein Paket muss ersetzt werden — mit zwei +Ausnahmen (siehe 2.1 und 2.3). + +Auch die Dinge, bei denen man Ärger erwarten würde, sind sauber gelöst: + +- [AtomicFile.cs:167](src/ClawdDotNet.Core/Storage/AtomicFile.cs:167) — `Commit` + prüft `File.Exists` und weicht auf `File.Move` aus. `File.Replace` verlangt + unter Unix ebenfalls eine vorhandene Zieldatei; der Fall ist also schon + abgedeckt. Die Wiederholschleife ist unter Linux überflüssig, aber harmlos. +- [TaskFrontmatter.cs:27](src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs:27) — + normalisiert `\r\n` und `\r` vor dem Zerlegen. Task-Dateien von einem + Windows-Rechner werden unter Linux korrekt gelesen. +- Textdateien werden durchgängig als **UTF-8 ohne BOM** geschrieben + (`AtomicFile`, `FileLogWriter`, `AgentEditorTool`). Kein `Encoding.Default`, + keine Codepage-Fallen. +- Zeitstempel gehen als `DateTime.UtcNow` in die Datenbank und werden mit + `DateTimeStyles.RoundtripKind` gelesen. + +### 1.2 Was am Windows-Teil hängt + +| Bereich | Zeilen | davon Designer | +|---|---:|---:| +| `frm_*.cs` (6 Formulare + Dialoge) | 4.946 | 1.865 | +| `UI/` (BackupPanel, WebViewBridge, EmbeddedUiManager) | 1.087 | 428 | +| `Models/` (PropertyGrid-ViewModels) | 1.257 | — | +| `Services/` (4 Dienste, an WinForms-Timer gekoppelt) | 1.501 | — | +| `Properties/` | 123 | — | +| **Summe** | **8.914** | **2.293** | + +Dazu drei `.resx`-Dateien à ~272 KB (eingebettete Symbole/Bilder) und eine +`frm_main.en.resx` für die englische Lokalisierung über den +WinForms-Resx-Mechanismus. + +Steuerelement-Inventar aus den Designer-Dateien: 24 `Label`, 19 +`ToolStripButton`, 12 `TabPage`, 12 `Button`, 9 `TextBox`, 6 `DataGridView`, 6 +`ToolStrip`, 5 `ComboBox`, **4 `PropertyGrid`**, 4 `TableLayoutPanel`, 4 +`FlowLayoutPanel`, 3 `TabControl`, 3 `SplitContainer`, 1 `RichTextBox`, 1 +`ListView`, 1 `NotifyIcon`, 1 `DateTimePicker`, 1 `NumericUpDown`. + +Tabs in `frm_main`: Chat, Logs, Settings (mit Unter-Tabs App-Settings, +Instance-Settings), Agent Settings, Jobs/Services (mit Unter-Tabs Jobs, +Services, Job History), Info, Backup. + +--- + +## 2. Die harten Brocken + +### 2.1 WebView2 → kein Linux (Chat- und Übersichts-Ansicht) + +`Microsoft.Web.WebView2` ist die einzige Windows-only-Paketabhängigkeit des +Hauptprojekts und trägt die zwei sichtbarsten Ansichten: +[frm_main.cs:235](frm_main.cs:235) und [frm_chat.cs:44](frm_chat.cs:44) laden +`overview.html` bzw. `chat.html` aus `EmbeddedUI/` über +`SetVirtualHostNameToFolderMapping` unter `https://ui.clwd.internal/`. Die +Kommunikation läuft über [WebViewBridge.cs](UI/WebViewBridge.cs) — +`WebMessageReceived` in die eine, `ExecuteScriptAsync` in die andere Richtung. + +Drei Wege, jeder mit einem eigenen Preis: + +| Variante | Was passiert | Aufwand | Risiko | +|---|---|---:|---| +| **A — Avalonia.WebView** | HTML/JS bleiben. Unter Linux rendert WebKitGTK, unter Windows weiterhin WebView2. Die Bridge wird auf die Abstraktion der Bibliothek umgeschrieben. | 4–6 PT | Bibliothek ist deutlich weniger reif als WebView2; WebKitGTK-Abhängigkeit muss auf dem Zielserver vorhanden sein; Verhalten unterscheidet sich je Plattform. | +| **B — nativ neu in Avalonia** | Chat als echte Avalonia-Ansicht mit `ItemsControl` und einem Markdown-Renderer. `EmbeddedUI/` entfällt. | 8–12 PT | Kein Fremdrisiko, aber Neuentwicklung. Am Ende deutlich wartbarer als HTML-in-Container. | +| **C — lokaler HTTP-Server + Systembrowser** | Die App liefert `EmbeddedUI/` über `http://localhost:port` aus, der Nutzer öffnet den Browser. | 3–4 PT | Bricht die Ein-Fenster-Anmutung. Passt aber ausgezeichnet zum kopflosen Betrieb — dort **ist** der Browser die Oberfläche. | + +**Empfehlung:** C für den kopflosen Host (fällt dort ohnehin an), B für die +Desktop-Oberfläche. Variante A koppelt uns an eine Bibliothek, die weniger stabil +ist als alles andere im Projekt. + +### 2.2 PropertyGrid → es gibt keinen Ersatz von der Stange + +Vier `PropertyGrid`-Instanzen in [frm_main.Designer.cs](frm_main.Designer.cs) +bilden App-Settings, Instance-Settings, Agent-Settings und Tool-Settings ab. Sie +werden vollständig durch Attribute gesteuert — **246 `[Category]`, +`[DisplayName]`, `[Description]`-Angaben** verteilt auf vier Dateien: + +- [Models/ToolSettingsViewModels.cs](Models/ToolSettingsViewModels.cs) — 108 +- [Models/AgentSettingsViewModel.cs](Models/AgentSettingsViewModel.cs) — 54 +- [Models/AppSettings.cs](Models/AppSettings.cs) — 51 +- [Models/InstanceSettingsViewModel.cs](Models/InstanceSettingsViewModel.cs) — 33 + +Dazu kommen `[TypeConverter(typeof(ExpandableObjectConverter))]` für +verschachtelte Objekte, `[PasswordPropertyText(true)]` für Geheimnisse und ein +eigener [ModelTypeConverter](Models/ModelTypeConverter.cs), der das +Modell-Auswahlfeld dynamisch aus der OpenRouter-Modellliste füllt. + +Avalonia hat kein `PropertyGrid`. Zwei Möglichkeiten: + +1. **`Avalonia.PropertyGrid`** (Community, MIT). Versteht `Category`, + `DisplayName`, `Description`, `Browsable`, `ReadOnly` und + `ExpandableObjectConverter`. Die ViewModels und ihre Attribute könnten + weitgehend unverändert bleiben — das spart am meisten. Zu prüfen ist, ob der + dynamische `ModelTypeConverter` mit `GetStandardValues` unterstützt wird; das + ist der Punkt, an dem so etwas erfahrungsgemäß hakt. **Aufwand 6–8 PT**, plus + dauerhafte Abhängigkeit an ein Ein-Personen-Projekt. +2. **Von Hand gebaute Einstellungsformulare.** Mehr Arbeit, aber wir bekommen + eine Oberfläche, die man Nutzern zumuten kann — das `PropertyGrid` ist + ehrlicherweise eine Entwickleransicht. Passwörter, Verzeichnisauswahl, + Validierung und die Modell-Auswahl werden dabei richtig statt behelfsmäßig. + **Aufwand 10–14 PT.** + +Das ist der größte Einzelposten der GUI-Portierung. Die Entscheidung kann und +sollte man verschieben, bis das Grundgerüst steht. + +### 2.3 DPAPI → Geheimnisse liegen unter Linux im Klartext + +[SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42): + +```csharp +if (!OperatingSystem.IsWindows()) + return plainText; +``` + +Unter Linux verschlüsselt `Protect` **stillschweigend nicht**. OpenRouter-Key, +Datenbank-Verbindungszeichenfolgen mit Passwort, Mail-Zugangsdaten und das +Telegram-2FA-Passwort lägen im Klartext in `InstanceConfig.json` und +`AgentSettings.json` — genau der Zustand, den S7 behoben hat. Auf einem Server, +der per SSH erreichbar ist und gesichert wird, ist das schlechter als auf einem +Einzelplatz-Windows. + +Dasselbe gilt für den Lizenz-Zustandsspeicher: +`LicenseLabrador/client-dotnet/.../StateStore.cs:92` schützt seine Datei ebenfalls +nur unter Windows per DPAPI. + +Zu klären ist also ein plattformübergreifendes Verfahren. Realistisch: + +- **AES-GCM mit Schlüssel aus einer Datei mit `0600`** neben der Konfiguration + (Linux) bzw. weiterhin DPAPI (Windows). Einfach, wirkt gegen versehentliche + Weitergabe und Backups, nicht gegen einen Angreifer mit demselben Benutzer — + dieselbe Schutzstufe wie DPAPI heute. +- Optional zusätzlich `libsecret`/Schlüsselbund, wenn eine Desktop-Sitzung da + ist. Auf einem Server gibt es die nicht, also braucht es den Dateiweg ohnehin. + +Nebenwirkung, die man einplanen muss: **Konfigurationen sind nicht mehr zwischen +Betriebssystemen austauschbar.** Ein `enc:v1:`-Wert von Windows ist unter Linux +nicht lesbar und umgekehrt. `Unprotect` wirft dann korrekterweise eine +`SecretProtectionException` ([SecretProtector.cs:81](src/ClawdDotNet.Core/Security/SecretProtector.cs:81)) — +für den Umzug einer Instanz braucht es einen Migrationsweg (Präfix `enc:v2:`, +Werte neu eintragen oder ein Export/Import-Kommando). + +**Aufwand 3–5 PT** inklusive Tests und Migration. + +### 2.4 Zeitzonen → das ist die stillste Fehlerquelle + +[TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161): + +```csharp +try { return TimeZoneInfo.FindSystemTimeZoneById(id); } +catch { return TimeZoneInfo.Utc; } +``` + +Und [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32) +schreibt `TimeZoneInfo.Local.Id` in die Task-Frontmatter. Auf dem +Entwicklungsrechner ergibt das `"W. Europe Standard Time"`, unter Linux +`"Europe/Berlin"`. + +Task-Dateien sind Markdown im `SharedWorkspace` und wandern zwischen Rechnern. +Trifft eine Windows-ID auf ein System ohne die Umsetzungsdaten, greift das +`catch` — und der Task läuft ab sofort nach **UTC statt Ortszeit**, also im +Sommer zwei Stunden zu früh. Ohne Fehlermeldung, ohne Logeintrag. Ein Task, der +um 08:00 die Marktübersicht holen soll, läuft um 06:00. + +.NET 6+ kann Windows-IDs unter Linux über ICU auflösen, aber nur wenn ICU +vorhanden ist. In einem schlanken Container (Alpine ohne `icu-libs`, distroless) +oder bei `InvariantGlobalization=true` ist es das nicht — dann schlägt jede +Auflösung fehl und alles fällt auf UTC. + +Was zu tun ist: + +- Beim Schreiben auf **IANA normalisieren** + (`TimeZoneInfo.TryConvertWindowsIdToIanaId`), beim Lesen beide Formen + akzeptieren. +- Das `catch` **nicht mehr still schlucken** — eine unbekannte Zeitzone muss + protokolliert werden, besser noch den Task als fehlerhaft markieren. +- Das Zielsystem muss `tzdata` haben. Für Container explizit installieren. + +Verwandt: **82 Vorkommen von `DateTime.Now`/`UtcNow`**. Die meisten sind +unkritisch, zwei fallen auf: +[TaskboardService.cs:80](src/ClawdDotNet.Core/Tasks/TaskboardService.cs:80) +schreibt `DateTime.Now`-Zeitstempel in Task-Dateien, und +[LiveLogViewerService.cs:98](Services/LiveLogViewerService.cs:98) sucht die +Logdatei des Tages über `DateTime.Now`. Server laufen üblicherweise mit `TZ=UTC` +— dort wechselt die Logdatei dann um 02:00 Ortszeit statt um Mitternacht, und +Task-Zeitstempel bekommen eine andere Bedeutung als bisher. Kein Fehler, aber +eine Verhaltensänderung, die man kennen sollte. + +**Aufwand 2–3 PT.** + +### 2.5 Groß-/Kleinschreibung bei Pfaden → sicherheitsrelevant + +Linux-Dateisysteme unterscheiden Groß- und Kleinschreibung, Windows nicht. An +vier Stellen wird das Gegenteil angenommen — und drei davon bewachen eine +Sandbox-Grenze: + +- [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72) — + `normalizedCandidate.StartsWith(normalizedRoot, OrdinalIgnoreCase)`. Das ist + die Prüfung, die Agenten daran hindert, aus ihrem Arbeitsverzeichnis + auszubrechen. +- [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174) — + Abgleich gegen die Liste geschützter Pfade. +- [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155) — dieselbe + Einschließungsprüfung. +- [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384). + +Unter Linux sind `/home/x/Workspace` und `/home/x/workspace` **zwei +verschiedene Verzeichnisse**. Der Vergleich mit `OrdinalIgnoreCase` würde einen +Pfad im zweiten als „innerhalb" des ersten durchwinken. Genauso liefe die +Sperrliste in `FileRWTool` ins Leere, sobald jemand die Schreibweise ändert. + +Nötig ist ein Vergleichsverfahren, das die Plattform berücksichtigt — ein +`PathComparer`, der unter Windows `OrdinalIgnoreCase` und unter Unix `Ordinal` +verwendet, konsequent an allen vier Stellen. + +Ebenfalls betroffen, aber harmlos: +[AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) schlüsselt +seine Sperren mit `fullPath.ToLowerInvariant()`. Unter Linux teilen sich damit +zwei verschiedene Dateien eine Sperre — das serialisiert zu viel, gefährdet aber +nichts. + +**Aufwand 2–3 PT**, davon der größere Teil Tests. + +### 2.6 Prozessaufrufe und `.exe`-Annahmen + +- **`Process.Start("explorer.exe", …)`** — 4 Stellen + ([frm_main.cs:1552](frm_main.cs:1552), [frm_main.cs:1557](frm_main.cs:1557), + [frm_main.cs:1562](frm_main.cs:1562), [BackupPanel.cs:338](UI/BackupPanel.cs:338)). + Ersatz: `Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })` + bzw. `xdg-open`. Die Variante `explorer.exe /select,"…"` hat unter Linux kein + Gegenstück — dort öffnet man nur den Ordner. +- **`Microsoft.VisualBasic.Interaction.InputBox`** — 3 Stellen + ([Program.cs:280](Program.cs:280), [Program.cs:291](Program.cs:291), + [frm_main.cs:658](frm_main.cs:658)), zwei davon für den interaktiven + Telegram-Login (Code und 2FA-Passwort). Braucht einen eigenen Dialog. Für den + kopflosen Betrieb ohnehin problematisch: **ein Login, der ein Eingabefenster + öffnet, blockiert einen Dienst.** Dort muss der Telegram-Login anders gelöst + werden (vorab per CLI, oder über die Weboberfläche). +- **`yt-dlp.exe` / `ffmpeg.exe`** — + [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782) + und `:825`. Die PATH-Suche davor funktioniert unter Linux bereits; nur die + Ausweichpfade sind fest auf `.exe` verdrahtet und laufen dort ins Leere. + Kleine Änderung, aber sie fällt sonst erst zur Laufzeit auf. + +**Aufwand zusammen 1–2 PT.** + +### 2.7 WinForms-Timer in der Dienstschicht + +`Services/` ist logisch kein UI-Code, hängt aber an +`System.Windows.Forms.Timer`: + +- [BackupScheduler.cs:41](Services/BackupScheduler.cs:41) +- [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38) — schreibt + zusätzlich direkt in eine `RichTextBox` +- [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43) +- [frm_main.License.cs:41](frm_main.License.cs:41) + +Der Backup-Zeitplan und die Lizenzprüfung gehören in den kopflosen Host und +müssen dafür auf `System.Threading.PeriodicTimer` umgestellt werden. Der +Log-Betrachter ist echte Oberfläche und wird ohnehin neu gebaut. + +**Aufwand 2–3 PT.** + +### 2.8 Lizenzierung — erledigt (Stand 2026-08-06) + +> **Nachtrag.** LicenseLabrador und WatchDog sind im **Deploymentcenter** +> zusammengefasst, Hardware-ID v2 ist dort umgesetzt. Der Client +> (`Deploymentcenter.Client`, `netstandard2.0;net8.0`) läuft auf beiden +> Plattformen, die HW-ID ist container- und umbenennungsfest, der Ablageort +> löst sich auch ohne `HOME` auf, und der Zustandsspeicher ist mit AES-GCM +> plattformübergreifend verschlüsselt. +> +> **Damit ist die einzige potenziell blockierende Frage dieser Analyse +> beantwortet.** Details und offene Punkte der Anbindung: +> [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md). + +Es bleibt reine Anschlussarbeit in ClawdDotNet: Projektverweis umhängen, +[LicenseGate](Services/LicenseGate.cs) gegen die neue Ergebnisklasse schreiben +(`LicenseState` ist entfallen, `Status` ist jetzt eine Zeichenkette), `MessageBox` +durch das mitgelieferte `ILicensePrompt` ersetzen und die Lizenzprüfung aus +[Program.cs:112](Program.cs:112) fensterfrei machen. + +**Aufwand 2–3 PT** (vorher 3–5). + +--- + +## 3. Kleinere Punkte, die trotzdem beißen + +### 3.1 Kultur- und Zahlenformatierung + +Nur 16 Stellen im gesamten Projekt nennen eine Kultur explizit. Das heißt +umgekehrt: fast alles formatiert mit `CurrentCulture`. Auf dem +Entwicklungsrechner ist das `de-DE`, auf einem Server mit unbesetztem `LANG` +ist es `InvariantCulture`. Aus `1,25` wird `1.25`. + +Wo das folgenlos bleibt: +- **JSON** — `System.Text.Json` schreibt Zahlen immer invariant. Alle + Konfigurationen, Zustandsdateien und API-Aufrufe sind sicher. +- **SQLite** — Werte gehen typisiert über Parameter, nicht als Text. + +Wo hinzuschauen ist: +- Zeichenkettenverkettung in Logeinträgen und Prompts (`$"{cost:F4}"`). Wenn + eine Zahl mit deutschem Dezimalkomma in einen Prompt gerät, muss das Modell + raten. +- Anzeigewerte in der Oberfläche — dort ist Ortsformat gewünscht, aber es sollte + bewusst gesetzt sein, nicht zufällig. + +**Empfehlung:** einmal alle Formatierungen durchgehen und trennen — invariant +für alles Maschinenlesbare, `CurrentCulture` nur für die Anzeige. Am besten mit +einem Analyzer (`CA1305`, `CA1304`, `CA1310`) als Warnung im Build, damit es so +bleibt. + +**Aufwand 2–3 PT.** + +### 3.2 Globalisierungsmodus festlegen + +`InvariantGlobalization=true` macht das Publikat kleiner und ICU überflüssig — +kostet aber `TimeZoneInfo.FindSystemTimeZoneById` (siehe 2.4), kulturabhängige +Vergleiche und korrektes `ToLower()` für Umlaute. Für dieses Projekt mit +zeitzonenabhängiger Planung ist das **keine Option**; die Entscheidung sollte im +Projekt dokumentiert und ICU/tzdata als Voraussetzung festgehalten werden. + +Nebenbemerkung: `COLLATE NOCASE` in +[SqliteMemoryRepository.cs:143](src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs:143) +und [SqliteTaskRepository.cs:105](src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs:105) +ist ASCII-beschränkt — `Ä` und `ä` gelten SQLite als verschieden. Das ist heute +schon so und ändert sich beim Umzug nicht, ist also kein Portierungsthema, +sondern eine bestehende Eigenheit. + +### 3.3 Zeilenenden + +1.230 Stellen verwenden `Environment.NewLine` oder `\r\n`. Für Logdateien ist +das egal. Bei **Task-Dateien** und Agenten-erzeugten Dateien im geteilten +Arbeitsverzeichnis führt es zu Rauschen: Eine Datei, die unter Windows +geschrieben und unter Linux angefasst wird, ändert komplett ihre Zeilenenden. +Wenn der Arbeitsbereich unter Git liegt oder synchronisiert wird, sieht jede +Änderung wie eine Vollumschreibung aus. Der Parser kommt damit klar (siehe 1.1) +— es ist eine Frage der Ordnung, kein Fehler. Empfehlung: für Task- und +Konfigurationsdateien fest `\n` schreiben. + +### 3.4 Dateinamen + +`Path.GetInvalidFileNameChars()` liefert unter Windows 41 Zeichen, unter Linux +genau zwei (`\0` und `/`). [FileLogWriter.cs:95](src/ClawdDotNet.Core/Logging/FileLogWriter.cs:95) +säubert Modulnamen damit — unter Linux entstehen also Dateinamen, die auf +Windows nicht mehr lesbar sind. Betrifft Sicherungen, die zwischen Systemen +wandern. Ebenso die Windows-Sonderfälle `CON`, `PRN`, `AUX` und Namen mit +abschließendem Punkt: unter Linux erlaubt, beim Rückspielen auf Windows nicht. +Für den Sicherungs-/Wiederherstellungsweg über Systemgrenzen hinweg relevant. + +### 3.5 Ablageorte + +[SettingsManager.cs:24](Services/SettingsManager.cs:24) legt `AppSettings.json` +neben die Programmdatei (`AppDomain.CurrentDomain.BaseDirectory`). Unter Windows +in einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung typisch +in `/opt/…` oder `/usr/lib/…` und ist für den Dienstbenutzer **nicht +schreibbar**. Dasselbe gilt für die Zielordner `tools/`, `Logs/` und +`Instances/`, die die Build-Ziele in `ClawdDotNet.csproj` neben der +Programmdatei anlegen. + +Nötig ist eine Trennung von Programm und Daten nach XDG-Konvention: +`$XDG_CONFIG_HOME` bzw. `/etc/clawddotnet` für die Konfiguration, +`$XDG_DATA_HOME` bzw. `/var/lib/clawddotnet` für Instanzen und Datenbanken, +`/var/log/clawddotnet` für Logs. Dazu Dateirechte: Instanzverzeichnisse mit +Geheimnissen gehören auf `0700`, Konfigurationsdateien auf `0600` — unter +Windows regelt das die ACL des Benutzerprofils, unter Linux muss man es setzen. + +**Aufwand 2–3 PT.** + +### 3.6 Tests + +Von 348 Tests sind fast alle portabel. Auffällig ist +[WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49): + +```csharp +[InlineData(@"C:\Windows\System32\config\SAM")] +[InlineData(@"\\server\share\evil.txt")] +[InlineData(@"C:\temp\datei.txt")] +``` + +Unter Linux liefert `Path.IsPathRooted(@"C:\temp\datei.txt")` **`false`** — das +ist ein gewöhnlicher relativer Dateiname mit Doppelpunkt und Backslashes darin. +Der Test prüft dort also etwas anderes als beabsichtigt. Und da er die +Sandbox-Grenze absichert, ist das keine Kleinigkeit: Er muss +betriebssystemabhängig aufgeteilt werden, mit einer eigenen Linux-Fassung +(`/etc/passwd`, `../../etc/passwd`, Symlinks). Symlinks sind überhaupt ein +Prüfpunkt, den es unter Windows so nicht gab — `Path.GetFullPath` löst sie +**nicht** auf, `File.ResolveLinkTarget` schon. Ein Agent könnte im +Arbeitsverzeichnis einen Symlink nach `/etc` anlegen und die Prüfung ginge +durch. + +Ebenso in [YouTubeUrlTests.cs:118](tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs:118) +(harmlos, nur Beispieldaten). + +**Aufwand 2–4 PT**, inklusive Symlink-Absicherung in `WorkspacePath` selbst. + +### 3.7 Bau und Auslieferung + +[Deploy-Build.ps1](Deploy-Build.ps1) setzt PowerShell 5.1 voraus, verwendet +Backslash-Pfade und den festen Ausgabepfad `bin\Release\net10.0-windows`. Für +Linux braucht es entweder eine `pwsh`-taugliche Fassung oder — besser — einen +schlichten `dotnet publish -r linux-x64 --self-contained` mit einer +systemd-Unit-Datei. Dazu: + +- systemd-Unit mit eigenem Dienstbenutzer, `Restart=on-failure` +- Prüfen, ob der bestehende Watchdog-Heartbeat + ([Program.cs:367](Program.cs:367)) mit `systemd-notify` zusammenspielen soll +- optional `.deb` oder AppImage für den Desktop-Fall + +**Aufwand 3–5 PT.** + +--- + +## 4. Der empfohlene Schnitt + +Der entscheidende Befund dieser Analyse: **Die Oberfläche ist nicht der Grund, +warum wir Linux wollen.** Der Grund ist, dass Agenten auf einem Server laufen +sollen. [Program.cs](Program.cs) baut bereits alles — Speicher, Engine, +Taskboard-Scanner, Watchdog, Lizenzprüfung — vollständig auf, **bevor** +`frm_main` überhaupt entsteht (Zeilen 36–385 gegen 388–401). Diese Trennung +existiert faktisch schon; sie muss nur formalisiert werden. + +### Stufe 1 — Kern Linux-fest und kopfloser Host (12–18 PT) + +| Schritt | PT | +|---|---:| +| Geheimnisse plattformübergreifend (2.3) | 3–5 | +| Zeitzonen normalisieren, Fehler nicht mehr schlucken (2.4) | 2–3 | +| Pfadvergleiche plattformabhängig + Symlink-Prüfung (2.5, 3.6) | 3–5 | +| Prozessaufrufe, `.exe`-Pfade (2.6) | 1–2 | +| Ablageorte und Dateirechte nach XDG (3.5) | 2–3 | +| `ClawdDotNet.Host` — Startlogik aus `Program.cs` herauslösen, `PeriodicTimer` statt WinForms-Timer, Telegram-Login ohne Dialog | 4–6 | +| Tests auf Linux grün, CI-Lauf für linux-x64 | 2–3 | + +**Ergebnis:** Die Anwendung läuft als systemd-Dienst auf einem Linux-Server. Die +Windows-GUI bleibt unverändert bestehen und wird weiter benutzt. Das ist der +Punkt, an dem der Nutzen anfällt. + +### Stufe 2 — Avalonia-Oberfläche (32–52 PT) + +| Schritt | PT | +|---|---:| +| Grundgerüst: Avalonia-Projekt, DI, Dispatcher, Shell mit Tabs, MVVM-Schicht | 5–7 | +| Logs-Tab (`RichTextBox` → `SelectingItemsControl` mit Filterung) | 2–3 | +| Agent-Settings: Liste, Werkzeugauswahl, Aktionsschaltflächen | 5–8 | +| Einstellungs-Tabs — PropertyGrid-Ersatz (2.2) | 6–10 | +| Jobs / Services / Job History (4 `DataGridView`) | 4–6 | +| Backup-Panel | 3–4 | +| Instance-Manager und die fünf Dialoge | 4–6 | +| Chat-Ansicht (Variante B, siehe 2.1) | 8–12 | +| Info, Statusleiste, Werkzeugleisten, Menü, Lokalisierung de/en | 3–4 | + +Die Spanne ist breit, weil zwei Entscheidungen noch offen sind (PropertyGrid-Ersatz +und Chat-Variante). Sind die getroffen, lässt sich das auf etwa ±15 % genau +angeben. + +### Stufe 3 — Auslieferung und Härtung (6–10 PT) + +Publish-Pipeline, systemd-Unit, Paketierung, Abnahme auf echter Hardware, +Dokumentation, Umzugsweg für bestehende Instanzen. + +### Gesamt + +| | PT | bei Vollzeit | +|---|---:|---| +| Stufe 1 | 12–18 | 2,5–3,5 Wochen | +| Stufe 2 | 32–52 | 6,5–10,5 Wochen | +| Stufe 3 | 6–10 | 1,5–2 Wochen | +| **Summe** | **50–80** | **10–16 Wochen** | + +--- + +## 5. LiveCharts2 + +Zur Einordnung: **Das Projekt enthält heute keine einzige Diagrammdarstellung.** +Die Suche nach `Chart`, `Series` oder `Plot` findet nur JSON-Feldnamen der +Yahoo-Finance-Abfrage in +[DirectAPITool.cs:126](src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs:126). + +LiveCharts2 ist damit **keine Portierung, sondern neue Funktionalität** — sie +gehört zum Trading-Teil, nicht zum Linux-Umzug, und ist in den 50–80 PT oben +nicht enthalten. Wenn die Kursansichten kommen, sind dafür grob 5–10 PT +zusätzlich zu rechnen. Das passt zu dem, was in +[docs/Roadmap.md](docs/Roadmap.md) und der Notiz „Basis vor Trading härten" +festgehalten ist: Erst die Basis, dann die Handelsansichten. + +Ein Punkt, der jetzt schon zählt: LiveCharts2 setzt auf SkiaSharp, genau wie +Avalonia. Das spricht zusätzlich dafür, die Diagramme erst **nach** der +Avalonia-Portierung zu bauen — sonst entstehen sie zweimal. + +--- + +## 6. Was vor dem ersten Handgriff zu entscheiden ist + +1. **Ist das Ziel Server oder Desktop?** Bei „Server" reicht Stufe 1, und Stufe 2 + kann entfallen oder durch eine Weboberfläche ersetzt werden. Das ändert die + Schätzung um den Faktor drei. +2. **Chat-Ansicht: HTML behalten oder nativ neu bauen?** (2.1) +3. **PropertyGrid: Fremdbibliothek oder eigene Formulare?** (2.2) +4. ~~**Erlaubt LicenseLabrador den Wechsel der Hardware-ID?**~~ — **geklärt**, + siehe 2.8 und + [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md). +5. **Bleibt Windows als Zielplattform bestehen?** Wenn ja, muss alles doppelt + getestet werden, und die Geheimnis-Verschlüsselung braucht beide Wege plus + Umzugspfad. Wenn nein, wird 2.3 deutlich einfacher. + +Frage 1 und 5 beantworten sich vermutlich schnell; 2 und 3 kann man bis zum +Beginn von Stufe 2 offenlassen, ohne Stufe 1 zu blockieren. Damit liegt nichts +mehr außerhalb unserer Hand — **Stufe 1 kann beginnen.** + +--- + +## Anhang — Vollständige Fundstellenliste + +| Thema | Datei:Zeile | +|---|---| +| DPAPI | [SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42), `:66`, `:90`, `:94` | +| DPAPI (Lizenz) | `LicenseLabrador/client-dotnet/.../StateStore.cs:43`, `:92` | +| Zeitzone | [TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161), [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32) | +| Pfad-Groß-/Kleinschreibung | [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72), [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174), [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155), [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384), [AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) | +| `explorer.exe` | [frm_main.cs:1552](frm_main.cs:1552), `:1557`, `:1562`, [BackupPanel.cs:338](UI/BackupPanel.cs:338) | +| `VisualBasic.InputBox` | [Program.cs:280](Program.cs:280), `:291`, [frm_main.cs:658](frm_main.cs:658) | +| `.exe`-Werkzeugpfade | [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782), `:825` | +| WinForms-Timer | [BackupScheduler.cs:41](Services/BackupScheduler.cs:41), [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38), [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43), [frm_main.License.cs:41](frm_main.License.cs:41) | +| WebView2 | [frm_main.cs:235](frm_main.cs:235), [frm_chat.cs:44](frm_chat.cs:44), [WebViewBridge.cs](UI/WebViewBridge.cs), [ClawdDotNet.csproj](ClawdDotNet.csproj) | +| PropertyGrid | [frm_main.Designer.cs](frm_main.Designer.cs) (4×), [Models/](Models/) (246 Attribute) | +| Datenablage | [SettingsManager.cs:24](Services/SettingsManager.cs:24), [ClawdDotNet.csproj](ClawdDotNet.csproj) (Build-Ziele) | +| Testdaten mit Windows-Pfaden | [WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49) | +| Build-Skript | [Deploy-Build.ps1](Deploy-Build.ps1) | diff --git a/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md b/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md new file mode 100644 index 0000000..d08baa2 --- /dev/null +++ b/docs/Lizenz-HardwareId-v2-Implementierungsvorschlag.md @@ -0,0 +1,520 @@ +# Hardware-ID v2 — Implementierungsvorschlag + +Stand: 2026-08-06. Betrifft `LicenseLabrador` (Client + Server) und die +Aufrufseite in ClawdDotNet ([Services/LicenseGate.cs](Services/LicenseGate.cs)). + +Anlass: Für den [Linux-Umzug](Linux-Portierung-Analyse.md) muss die +Hardware-Bindung auf beiden Plattformen funktionieren. Bei der Durchsicht sind +dabei zwei Probleme aufgefallen, die **nichts mit Linux zu tun haben**, aber +denselben Code betreffen — die sollten in einem Zug mit erledigt werden. + +--- + +## 1. Befund + +### 1.1 Der Rechnername steckt im Hash — das ist das eigentliche Problem + +[HardwareId.cs:43](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs): + +```csharp +rawBuilder.Append(Environment.MachineName); // "for system isolation" +``` + +Folge: **Ein umbenannter Rechner ist eine neue Maschine.** Er verbraucht einen +weiteren Aktivierungsplatz, und der alte bleibt für immer belegt +(`max_activations` ist standardmäßig 2 — nach zwei Umbenennungen ist die Lizenz +dicht). Das gilt bereits heute unter Windows. + +Unter Linux wird daraus ein Totalausfall: In einem Container ist der Hostname +standardmäßig die gekürzte Container-ID, also **bei jedem Start ein anderer**. +Die Lizenz wäre nach dem zweiten `docker run` verbraucht. + +Die Absicht („system isolation") ist auch nicht erfüllt: Der Rechnername steht +ohnehin im Feld `hostname`, das der Server bei jeder Prüfung mitschreibt +([LicenseService.php:112](../../LicenseLabrador/server/src/LicenseService.php)). +Diagnostisch verlieren wir nichts, wenn er aus dem Hash verschwindet. + +### 1.2 Die MAC-Ausweichlösung ist unter Linux instabil + +[HardwareId.cs:86](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs) +nimmt die alphabetisch erste physische MAC. Unter Linux: + +- Die Stoppwortliste kennt `docker` und `veth`, aber **nicht** `br-` (Bridges), + `virbr` (libvirt), `cni`, `flannel`, `cali` (Kubernetes), `zt` (ZeroTier). +- `NetworkInterfaceType` meldet unter Linux für die meisten virtuellen Geräte + schlicht `Ethernet` — die Typprüfung greift also nicht. +- Bridge- und veth-MACs werden von systemd **je Boot neu zufällig** vergeben. + +Sortiert man solche Adressen mit, wechselt die Hardware-ID beim Neustart. Die +Ausweichlösung ist damit unter Linux schlimmer als keine. + +### 1.3 Der Zustandsspeicher fällt still auf Klartext zurück + +[StateStore.cs:41](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs) +beim Lesen und `:90` beim Schreiben: + +```csharp +try { decryptedData = ProtectedData.Unprotect(rawData, null, ...); } +catch { decryptedData = rawData; } // ← Klartext wird akzeptiert +``` + +Unter Linux wirft DPAPI immer, also läuft alles über den Klartextzweig. Zwei +Folgen: + +- `SECURITY.md` behauptet, der Cache sei „strikt an die `hardware_id` gebunden". + Das stimmt für die *Hülle* (die Prüfung in + [LicenseClient.cs:198](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)), + nicht für die Cache-Datei selbst. +- Ernster: `max_seen_time` ist die Uhr-Rückdreh-Sperre + ([StateStore.cs:107](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)). + Wer eine `state.dat` von Hand schreiben kann, setzt den Wert auf 0 und stellt + die Systemuhr zurück. Der Klartext-Rückfall beim **Lesen** macht das möglich, + und zwar auf jeder Plattform, auf der DPAPI nicht greift. + +### 1.4 Ablageort bricht bei einem systemd-Dienst weg + +[LicenseConfig.cs:22](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseConfig.cs) +verwendet `Environment.GetFolderPath(SpecialFolder.ApplicationData)`. Läuft der +Dienst unter `User=clawd` ohne Heimatverzeichnis, ist `HOME` nicht gesetzt und +`GetFolderPath` liefert einen **leeren String**. `Path.Combine("", slug, +"license")` ergibt einen relativen Pfad — die Lizenz landet im Arbeitsverzeichnis +oder gar nicht. + +### 1.5 Kein Formatkennzeichen, keine Plattformangabe + +Die Hardware-ID ist heute ein nackter SHA-256-Hex-String. Es gibt keine +Möglichkeit, im Server zu erkennen, aus welcher Quelle oder von welchem +Betriebssystem eine Aktivierung stammt — und keinen Weg, das Format je zu +wechseln, ohne alle bestehenden Aktivierungen zu verlieren. + +**Randnotiz:** `OperatingSystemHelpers.IsWindows()` nutzt +`Environment.OSVersion.Platform == PlatformID.Win32NT`. Das funktioniert +zufällig richtig (Linux liefert `Unix`), ist aber die veraltete API. +`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` ist in netstandard2.0 +verfügbar und der korrekte Weg. + +--- + +## 2. Zielbild: das Format + +``` +2::<64 Hex-Zeichen> + +Beispiele: + 2:win:9f3ab7c1… (Windows, MachineGuid) + 2:lin:41e0d5aa… (Linux, /etc/machine-id) + 2:lin:7c9182ff… (Linux, Vorgabe per Umgebungsvariable) +``` + +68 Zeichen — passt in `activations.hardware_id VARCHAR(128)` ohne +Schemaänderung. Der Doppelpunkt ist unproblematisch, die Spalte ist +`utf8mb4_unicode_ci` und wird nur verglichen. + +Der Hash selbst: + +``` +sha256( "LicenseLabrador-HWID-v2" ‖ "\n" ‖ plattform ‖ "\n" ‖ quelle ‖ "\n" ‖ rohwert ) +``` + +- **Kein `MachineName`.** (1.1) +- Die Domänenzeichenkette verhindert, dass derselbe Rohwert in anderem + Zusammenhang wiederverwendbar ist. +- `quelle` geht mit in den Hash: Findet der Client später eine bessere Quelle, + ändert sich die ID bewusst und nachvollziehbar, statt zufällig. + +Zusätzlich gehen drei neue Felder mit in die Anfrage — **nicht** in den Hash, +nur zur Diagnose und für die Migration: + +| Feld | Beispiel | Zweck | +|---|---|---| +| `hwid_version` | `2` | Formaterkennung serverseitig | +| `hwid_source` | `machine-id` | Admin sieht, wie stabil die Bindung ist | +| `legacy_hardware_id` | `` | Migration ohne Platzverlust (Abschnitt 4) | + +--- + +## 3. Quellen je Plattform + +Reihenfolge = Priorität. Die erste Quelle, die einen nichtleeren, plausiblen Wert +liefert, gewinnt. + +### 3.1 Vorgabe (alle Plattformen, höchste Priorität) + +``` +LicenseConfig.HardwareIdOverride (Code) +LICENSELABRADOR_HWID (Umgebungsvariable) +``` + +Quelle: `override`. Der Rohwert wird trotzdem gehasht, damit das Format +einheitlich bleibt. + +**Das ist der ehrliche Weg für Container und Serverbetrieb.** Heuristik kann dort +nicht gewinnen — in einem Container gibt es keine Hardware, an die man binden +könnte. Der Betreiber setzt einen stabilen Wert, hinterlegt ihn im +Deployment-Geheimnis, und die Bindung ist so verlässlich wie dieser Wert. Eine +Zeile in der systemd-Unit statt eines Ratespiels. + +### 3.2 Windows + +| # | Quelle | `hwid_source` | +|---|---|---| +| 1 | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (Registry64) | `machine-guid` | +| 2 | Stabile physische MAC (Abschnitt 3.4) | `mac` | +| 3 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | + +Unverändert zu heute — nur ohne `MachineName` im Hash. + +### 3.3 Linux + +| # | Quelle | `hwid_source` | Anmerkung | +|---|---|---|---| +| 1 | `/etc/machine-id` | `machine-id` | Von systemd bei der Installation erzeugt, überlebt Neustarts und Kernel-Updates. Die richtige Wahl auf einem echten System. | +| 2 | `/var/lib/dbus/machine-id` | `dbus-machine-id` | Ältere Systeme ohne systemd. | +| 3 | `/sys/class/dmi/id/product_uuid` | `dmi-uuid` | SMBIOS-UUID, echte Hardware-Bindung. **Meist nur für root lesbar** (`0400`) — Versuch in `try` einpacken, kein Fehler wenn nicht lesbar. Bei VMs vom Hypervisor gesetzt und dort stabil. | +| 4 | Stabile physische MAC (Abschnitt 3.4) | `mac` | | +| 5 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | | + +Zwei Fallen bei `/etc/machine-id`, die geprüft werden müssen: + +- **Leer oder nur Zeilenumbruch.** Auf Systemen mit `systemd-firstboot` oder in + manchen Images existiert die Datei, ist aber leer. Muss als „nicht vorhanden" + behandelt werden, nicht als gültiger Wert — sonst haben *alle* diese + Installationen dieselbe ID. +- **Der Wert `uninitialized`.** Genau diese Zeichenkette schreibt systemd, wenn + die ID im laufenden Betrieb noch nicht festgelegt ist. Ebenfalls verwerfen. + +```csharp +private static bool IsPlausibleMachineId(string? v) + => !string.IsNullOrWhiteSpace(v) + && v.Trim().Length >= 16 + && !v.Trim().Equals("uninitialized", StringComparison.OrdinalIgnoreCase) + && v.Trim().Trim('0').Length > 0; // nicht alles Nullen +``` + +### 3.4 MAC-Ausweichlösung, überarbeitet + +Die heutige Fassung nimmt `FirstOrDefault()` der sortierten Liste. Wenn eine +Schnittstelle dazukommt oder wegfällt, kann sich damit die gewählte MAC ändern. +Besser: **alle** gültigen MACs sortiert verketten — dann ändert sich der Wert +nur, wenn sich die Netzwerkausstattung wirklich ändert, und nicht schon, weil +eine Adresse hinzukommt, die vorher sortiert davor lag. + +Stoppwortliste erweitern um: `br-`, `virbr`, `cni`, `flannel`, `cali`, `weave`, +`zt`, `tailscale`, `ipsec`, `sit`, `gre`, `dummy`, `bond`, `macvlan`, `ovs`. + +Zusätzlich hart ausschließen (unabhängig vom Namen): + +- Schnittstellen mit gesetztem **„locally administered"-Bit** (zweites Bit des + ersten Oktetts, `mac[0] & 0x02`). Genau das setzt systemd bei zufällig + erzeugten MACs für veth und Bridges. Ein sauberer, namensunabhängiger Filter — + und der wirksamste von allen. +- Unter Linux zusätzlich prüfen: existiert + `/sys/class/net//device`? Fehlt das Verzeichnis, hat die Schnittstelle + kein physisches Gerät und ist virtuell. Das ist zuverlässiger als jede + Namensliste. + +```csharp +// Namensunabhängig: zufällig erzeugte MACs tragen dieses Bit. +private static bool IsLocallyAdministered(PhysicalAddress addr) +{ + var b = addr.GetAddressBytes(); + return b.Length > 0 && (b[0] & 0x02) != 0; +} +``` + +### 3.5 Erzeugte Datei als letzte Stufe + +`/machine.key` — 32 Zufallsbytes, Base64, Dateirechte `0600`. +Wird nur angelegt, wenn keine Quelle davor greift. + +Das ist eine **Installations-** und keine Hardware-Bindung. Für einen Container +ohne Vorgabe ist das aber die Wahrheit, und mit einem gemounteten Datenverzeichnis +bleibt sie über Container-Neustarts stabil. `hwid_source` = `keyfile` macht dem +Admin sichtbar, dass diese Aktivierung schwächer gebunden ist als die anderen. + +Wichtig: Die Datei gehört ins **Datenverzeichnis**, nicht neben die +Programmdatei. Sonst ist sie bei jedem Deployment weg. + +--- + +## 4. Migration v1 → v2 ohne Platzverlust + +Der Kern: Der Client kennt **beide** IDs und schickt beide mit. Der Server zieht +die alte Aktivierung auf die neue ID um, statt eine zweite anzulegen. + +**Client** — `HardwareId` bekommt neben `GetHardwareId()` (v2) ein +`GetLegacyHardwareId()`, das die heutige v1-Berechnung *unverändert* beibehält +(inklusive `MachineName`, damit sie zu bestehenden Aktivierungen passt). Beides +geht in die Anfrage: + +```csharp +hardware_id = "2:lin:41e0…", +legacy_hardware_id = "8fa2…", // nur solange v1-Aktivierungen existieren +hwid_version = 2, +hwid_source = "machine-id", +``` + +**Server** — in `LicenseService::validate`, an der Stelle der heutigen Suche +([LicenseService.php:100](../../LicenseLabrador/server/src/LicenseService.php)): + +``` +1. Aktivierung mit hardware_id = suchen + → gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren) + +2. nicht gefunden, und legacy_hardware_id ist gesetzt: + Aktivierung mit hardware_id = suchen + → gefunden: UPDATE activations SET hardware_id = , hwid_version = 2, + hwid_source = WHERE id = … + + audit_log-Eintrag 'hwid_migrated' + → weiter wie unter 1. KEIN neuer Platz verbraucht. + +3. weder noch: neue Aktivierung anlegen, max_activations prüfen (wie heute) +``` + +Damit wandern alle bestehenden Windows-Installationen beim ersten Start nach dem +Update lautlos auf v2 — niemand merkt etwas, kein Aktivierungsplatz geht +verloren. Das `legacy_hardware_id`-Feld kann nach einer Übergangszeit (etwa zwei +Veröffentlichungen) aus dem Client fallen. + +### 4.1 Der lokale Cache muss einmal verworfen werden + +Nicht übersehen: Die Hardware-ID geht in zwei weitere Berechnungen ein — +`CalculateHmac(state, licenseKey, _hardwareId)` für die Prüfsumme in +`LicenseResult` +([LicenseClient.cs:272](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)) +und den Seed des Speicherschutzes (`:287`). Nach dem Formatwechsel schlägt +`VerifyChecksum` für jede zwischengespeicherte Hülle fehl. + +Das ist kein Fehler, sondern erwartet — muss aber als **Cache-Fehltreffer** +behandelt werden (einmal online neu prüfen), nicht als +`TamperSuspected`. Sonst sperrt sich jede bestehende Installation beim ersten +Start nach dem Update selbst aus. Der Weg dorthin: Cache-Version im +`LocalCacheData` mitführen (`schema_version: 2`) und einen Datensatz mit +abweichender Version verwerfen, bevor die Prüfsumme überhaupt geprüft wird. + +### 4.2 Schemaerweiterung + +```sql +ALTER TABLE activations + ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id, + ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version, + ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source; +``` + +Alles mit Vorgabewerten, also rückwärtskompatibel — ein alter Client, der die +Felder nicht schickt, funktioniert unverändert weiter. + +--- + +## 5. Umzug Windows → Linux + +Das ist etwas anderes als die Formatmigration: hier wechselt die Maschine +wirklich, die ID muss sich also ändern. Drei Wege, alle drei sinnvoll parallel: + +### 5.1 Der Normalfall braucht gar nichts + +`max_activations` ist standardmäßig **2**. Ein Windows-Entwicklungsrechner und +ein Linux-Server passen also ohne jeden Eingriff hinein. Für den anstehenden +Umzug ist das wahrscheinlich die ganze Antwort — die anderen beiden Punkte sind +für den Fall danach. + +### 5.2 Abmelden vor dem Umzug (existiert, aber nicht erreichbar) + +`LicenseService::deactivate` löscht die Aktivierungszeile und gibt den Platz frei +([LicenseService.php:153](../../LicenseLabrador/server/src/LicenseService.php)), +und `LicenseClient.DeactivateAsync` ruft es auf. In ClawdDotNet ist die Methode +aber nur über [LicenseGate.cs:39](Services/LicenseGate.cs) erreichbar und dort +an die GUI gebunden. + +Nachzuliefern: ein Kommandozeilenschalter am Host, damit das auch ohne +Oberfläche geht. + +```bash +clawddotnet --license-deactivate +``` + +Das braucht der kopflose Betrieb ohnehin (siehe +[Linux-Portierung-Analyse.md](Linux-Portierung-Analyse.md), 2.6 — der +Lizenzdialog ist ein `MessageBox`, der einen Dienst blockieren würde). + +### 5.3 Umbinden aus der Verwaltung (fehlt noch) + +Für den Fall, dass die alte Maschine schon weg ist: In +`public/admin/license_detail.php` je Aktivierungszeile eine Schaltfläche +**„Aktivierung freigeben"** (löscht die Zeile, gibt den Platz frei). Ein echtes +„Umbinden" auf eine bekannte neue ID ist unnötig — Freigeben plus Neuaktivierung +auf dem Zielsystem ist derselbe Vorgang mit weniger Code und einer klareren +Spur im Prüfprotokoll. + +Beides sollte in `audit_log` landen, mit altem und neuem Wert. + +--- + +## 6. Zustandsspeicher härten + +Zusammen mit dem HW-ID-Umbau, weil dieselbe Datei betroffen ist und die +Verschlüsselung den HW-ID als Schlüsselmaterial braucht. + +**Format** — feste Hülle statt „mal so, mal so": + +``` +Magic "LLS2" (4 Byte) │ Nonce (12) │ Ciphertext │ GCM-Tag (16) +``` + +- **AES-GCM**, Schlüssel abgeleitet aus HW-ID + `ProductSlug` per HKDF-SHA256. +- Auf Windows das Ergebnis **zusätzlich** in DPAPI wickeln (Gürtel und + Hosenträger, kostet nichts). +- Dateirechte `0600` auf Unix. + +**Der entscheidende Punkt: den Klartext-Rückfall beim Lesen entfernen.** Eine +Datei, die sich nicht entschlüsseln oder nicht authentifizieren lässt, ist +**kein Cache** — sie wird verworfen und der Client prüft online. Nicht als +Klartext akzeptieren. Genau dieser Rückfall macht heute die +Uhr-Rückdreh-Sperre umgehbar (1.3). + +Einmalig weiterhin lesbar bleiben muss das alte Format (Datei ohne `LLS2`-Magic): +einlesen, in v2 neu schreiben, fertig. Nach einer Veröffentlichung kann der Pfad +weg. + +### 6.1 netstandard2.0 hat kein AesGcm — Empfehlung: mehrfach zielen + +`System.Security.Cryptography.AesGcm` gibt es erst ab .NET Core 3.0, `HKDF` erst +ab .NET 5, `File.SetUnixFileMode` erst ab .NET 7. Das Projekt zielt heute auf +`netstandard2.0`. + +Zwei Wege: + +1. **`netstandard2.0;net8.0`** — + *empfohlen*. ClawdDotNet (net10.0) zieht automatisch das net8.0-Ziel und + bekommt `AesGcm`, `HKDF` und `File.SetUnixFileMode` ohne Umwege. Der + netstandard2.0-Zweig bleibt für andere Abnehmer erhalten und nutzt dort + BouncyCastle. Kosten: ein paar `#if NET8_0_OR_GREATER`-Blöcke an genau drei + Stellen. +2. **Durchgängig BouncyCastle** (`GcmBlockCipher`, `HkdfBytesGenerator`) — die + Bibliothek ist mit `BouncyCastle.Cryptography` bereits als Abhängigkeit da, + also kein neues Paket. Kein Mehrfachziel nötig, aber die Dateirechte bleiben + ein Problem: `chmod` müsste per P/Invoke laufen. + +Weg 1 ist sauberer, weil er nebenbei das Dateirechte-Problem löst. + +--- + +## 7. Ablageort (1.4) + +Auflösungskette in `LicenseConfig.StorageDirectory`, erste nutzbare gewinnt: + +1. Explizit gesetzter Wert (ClawdDotNet setzt ihn künftig — der Host hat ohnehin + eine eigene XDG-Auflösung). +2. `LICENSELABRADOR_STORAGE_DIR`. +3. Unix: `$XDG_CONFIG_HOME//license`, sonst `$HOME/.config//license`. +4. Windows: `SpecialFolder.ApplicationData` wie heute. +5. Letzter Ausweg: `/license`. + +**Und in jedem Fall: nie einen leeren Pfad durchlassen.** Der heutige Code kann +`Path.Combine("", …)` erzeugen, ohne dass es auffällt. Ein `if +(string.IsNullOrEmpty(...)) throw` an dieser Stelle ist besser als eine +Lizenzdatei, die im Arbeitsverzeichnis landet und beim nächsten Start nicht mehr +gefunden wird. + +--- + +## 8. Änderungsliste + +### LicenseLabrador — Client + +| Datei | Was | +|---|---| +| `HardwareId.cs` | Neuschreiben: v2-Format, Quellenkette je Plattform, `GetLegacyHardwareId()`, `HwidSource`/`Platform` als Eigenschaften, MAC-Filter (locally-administered-Bit, `/sys/class/net/*/device`), Plausibilitätsprüfung für machine-id, `machine.key`-Erzeugung | +| `LicenseConfig.cs` | `HardwareIdOverride`, Auflösungskette für `StorageDirectory`, leeren Pfad ausschließen | +| `StateStore.cs` | `LLS2`-Hülle, AES-GCM, `schema_version`, **Klartext-Rückfall beim Lesen entfernen**, v1-Einmalmigration, `0600` | +| `LicenseClient.cs` | Neue Felder in `validate`/`deactivate` senden; Cache mit abweichender `schema_version` als Fehltreffer behandeln, **nicht** als `TamperSuspected` | +| `OperatingSystemHelpers` | `RuntimeInformation.IsOSPlatform`, dazu `IsLinux()`/`IsMacOs()` | +| `LicenseLabrador.Client.csproj` | `netstandard2.0;net8.0` | + +### LicenseLabrador — Server + +| Datei | Was | +|---|---| +| `sql/schema.sql` + Migrationsskript | `hwid_version`, `hwid_source`, `platform` | +| `src/LicenseService.php` | `legacy_hardware_id` entgegennehmen; Migrationssuche (Abschnitt 4); neue Felder speichern | +| `src/Audit.php` | Ereignisart `hwid_migrated`, `activation_released` | +| `public/admin/license_detail.php` | Quelle/Plattform je Aktivierung anzeigen, „Aktivierung freigeben" | +| `docs/SECURITY.md` | Aussage zur Cache-Bindung korrigieren (1.3) | + +### ClawdDotNet + +| Datei | Was | +|---|---| +| [Services/LicenseGate.cs](Services/LicenseGate.cs) | `StorageDirectory` explizit setzen; `MessageBox`/`frm_License` hinter eine Schnittstelle (`ILicensePrompt`) legen, damit der kopflose Host eine Konsolenfassung einsetzen kann | +| Host (neu) | `--license-deactivate`, `--license-set-key`, `--license-status` | +| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Format v2 und Migrationsweg nachtragen | + +--- + +## 9. Testplan + +Das Wichtigste zuerst — die Fälle, die heute schiefgehen würden: + +| Fall | Erwartung | +|---|---| +| Rechner umbenennen | **ID unverändert** (Kern von 1.1) | +| Container zweimal starten, `/etc/machine-id` im Abbild | beide Male dieselbe ID | +| Container ohne `machine-id`, Datenverzeichnis gemountet | ID über Neustarts stabil, `hwid_source = keyfile` | +| Container ohne `machine-id`, **ohne** Mount | ID wechselt — muss so sein, und im Protokoll erkennbar | +| `LICENSELABRADOR_HWID` gesetzt | gewinnt gegen alles, `hwid_source = override` | +| `/etc/machine-id` leer bzw. `uninitialized` | wird verworfen, nächste Quelle greift | +| Docker-Bridge und veth vorhanden, keine machine-id | MAC-Wahl ignoriert sie, ID über Neustart stabil | +| Bestehende v1-Windows-Aktivierung, Client aktualisiert | Zeile wird auf v2 umgeschrieben, `max_activations` unverändert, Prüfprotokolleintrag | +| v1-Cache-Datei nach dem Update | einmal online geprüft, dann v2-Cache — **kein** `TamperSuspected` | +| `state.dat` von Hand mit `max_seen_time = 0` | Datei wird verworfen, Uhr-Rückdreh-Sperre bleibt wirksam | +| systemd-Dienst ohne `HOME` | Ablageort auflösbar, keine Datei im Arbeitsverzeichnis | +| Alter Client gegen neuen Server | funktioniert unverändert (Felder haben Vorgabewerte) | +| Neuer Client gegen alten Server | funktioniert, Zusatzfelder werden ignoriert | + +Die letzten beiden Zeilen sind nicht optional: Client und Server werden nicht +gleichzeitig ausgerollt. + +--- + +## 10. Aufwand + +| Block | PT | +|---|---:| +| `HardwareId` v2 samt Quellenkette, MAC-Filter, `machine.key` | 2–3 | +| Client mehrfach zielen + `StateStore`-Härtung | 2–3 | +| Server: Migrationssuche, Schema, Prüfprotokoll, Verwaltungsansicht | 2–3 | +| ClawdDotNet: `ILicensePrompt`, Lizenz-Kommandozeile | 1–2 | +| Tests (Container-Fälle brauchen echtes Docker) und Abnahme | 1–2 | +| **Summe** | **8–13** | + +Das ist mehr als die 3–5 PT, die in der Linux-Analyse für „Lizenz" standen — +weil dort nur die Plattformverträglichkeit gerechnet war. Die Punkte 1.1 und 1.3 +sind bestehende Fehler, die unabhängig vom Umzug behoben werden sollten; sie +machen den Unterschied aus. + +Der Block ist **unabhängig vom übrigen Linux-Umzug** und kann sofort beginnen — +er hängt an keiner der offenen GUI-Entscheidungen. + +--- + +## 11. Was ich anders machen würde als heute — kurz begründet + +Drei Entscheidungen im Vorschlag verdienen eine Begründung, weil sie vom +bisherigen Ansatz abweichen: + +**Rechnername raus.** Er ist der Grund, warum die heutige Bindung fragiler ist +als nötig, und er trägt nichts bei, was `activations.hostname` nicht schon +festhält. Eine Bindung, die bei einer Umbenennung bricht, bindet nicht an +Hardware, sondern an eine Konfiguration. + +**Vorgabe per Umgebungsvariable statt besserer Heuristik für Container.** Man +kann eine Container-Umgebung nicht sinnvoll erraten — es gibt dort keine +Hardware. Jede zusätzliche Heuristik verschiebt nur, wo es falsch wird. Eine +explizite Vorgabe ist ein bewusster Betreiberentscheid, in der Unit-Datei +sichtbar, im Prüfprotokoll nachvollziehbar. + +**Kein Klartext-Rückfall, auch nicht „zur Sicherheit".** Der heutige Rückfall +sollte Robustheit bringen, kostet aber genau die Eigenschaft, für die der Cache +existiert. Ein verworfener Cache bedeutet: einmal online prüfen. Das ist der +mildere Schaden — und wer keine Verbindung hat, hat immer noch die +Offline-Gnadenfrist aus der signierten Hülle, die von dieser Datei nicht abhängt. diff --git a/docs/Roadmap.md b/docs/Roadmap.md new file mode 100644 index 0000000..347b6f4 --- /dev/null +++ b/docs/Roadmap.md @@ -0,0 +1,301 @@ +# Roadmap + +Zentrale Liste aller offenen Vorhaben. Sie löst die beiden „Vorgeschlagene +Reihenfolge"-Abschnitte in der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md) und im +[Konzepte-Dokument](Konzepte-Backup-Finanz-Analyse.md) ab — die bleiben als Befund bzw. +Konzept bestehen, gepflegt wird nur noch hier. + +Kürzel (S4, K2, T4, F-A1, …) verweisen auf die Bestandsaufnahme. + +--- + +## A — Beschlossen (aus dem OpenAlice-Vergleich, Juli 2026) + +Hintergrund: Konzeptvergleich mit [OpenAlice](https://github.com/TraderAlice/OpenAlice) +(AGPL-3.0 — Konzepte übernehmen ja, Code nein). Übernommen werden Taskboard, +Staging-Freigabe, Audit-Log und das Skill-Modell. Die Inbox-Idee entfällt zugunsten +der geplanten Matrix-Migration (A5). + +### A1 — Taskboard + +Aufgaben als Markdown-Dateien mit YAML-Frontmatter im `SharedWorkspace`: +`title`, `status` (`backlog | todo | in_progress | done | canceled`), `priority`, +`assignee`, optional `when` (`at` | `every` | `cron` **mit Zeitzone**). + +- **Scanner statt Delay-Schleifen**: Ein Takt (~60 s) prüft, was fällig ist. + Persistiert werden nur Last-Fired-Marker — ein fehlgeschlagener Lauf bleibt der + einzige Versuch für diesen Termin, kein automatischer Retry-Sturm. +- **Assignee bestimmt die Ausführung**: `@new` = frischer Lauf ohne Historie, + `@` = bestehender Agent mit seinem Kontext, `@human` = wartet auf uns. + Das ersetzt das implizite `UseChatContext`-Flag (T7) durch eine explizite Angabe + am Auftrag. +- **Agenten-Tool**: `task_create`, `task_list`, `task_update`, `task_comment`. + Agent-zu-Agent-Delegation läuft künftig über Tasks statt über rekursives + `send_message`. +- **Migration**: Die improvisierten `coordination/*.md`-Dateien der Agenten + (task_*, status_*, broadcast) gehen im Taskboard auf. + +**Detailbauplan** (aus dem Fünf-Repo-Vergleich, Juli 2026 beschlossen): + +- Status zusätzlich mit **`in_review`**; im Frontmatter **`require_approval`** + (Task gilt erst nach Review als done) und **`acceptance`** (Abnahmekriterien, + gegen die das Ergebnis geprüft wird). +- **Task-Typen `approval` und `human_input`** — ein Mensch ist einfach ein + Assignee; seine Antwort ist das Task-Ergebnis und Input für Folgetasks. +- **Atomares Claiming**: Die DB verhindert, dass zwei Läufe denselben Task + ziehen. Der Scanner arbeitet mit **Claim-before-run** (at-most-once — ein + doppelter Tick findet den Claim bereits vergeben) und + **Startup-Reconciliation**: Beim Start wird Soll (Frontmatter) gegen Ist + (Marker/Claims) abgeglichen, verpasste Läufe werden erkannt statt still + übersprungen. +- **`blocked_by`-Abhängigkeiten** mit Auto-Dispatch: Wird der letzte Blocker + fertig, wird der wartende Task automatisch angestoßen. Meldet ein Agent einen + Blocker, fällt der Task und der Zuständige (Lead/Benutzer) wird benachrichtigt + (**Blocker-Eskalation**). +- **Reopen-/Feedback-Semantik**: Ergebnis + Kritik gehen per `task_comment` an + denselben Agenten zur Nachbesserung zurück, statt einen neuen Task von vorn + zu beginnen. + +Damit erledigt oder aufgegangen: + +| Punkt | Warum | +|---|---| +| F-A5 Task-Queue | das Taskboard **ist** die Queue | +| F-A4 Run-Historie | Läufe werden am Task verknüpft und persistiert | +| B8 Rekursion `send_message` | Delegation über Tasks ist strukturell zyklenfrei | +| B6 `Task.Delay`-Überlauf | Scanner-Modell kennt keine langen Delays | +| B7 Cron in Lokalzeit | Frontmatter-`when` ist zeitzonen-explizit | +| T7 `RunAsync` vs. `ChatAsync` | Assignee-Semantik beantwortet die Frage | + +Verzahnung: Das Marktkalender-Flag (`onlyWhenMarketOpen`, siehe C1) gehört ins +Frontmatter, nicht in einen eigenen Mechanismus. + +Konzept-Doc: [Taskboard-Konzept](Taskboard-Konzept.md) (Dateiformat, +Wahrheitsaufteilung Datei/DB, Scanner-Verhalten, Invarianten, Migration). + +### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4) + +Konzept-Doc: [Staging-Konzept](Staging-Konzept.md). + +Irreversible Aktionen (Mail senden, X posten, DB-Schreibzugriff, Datei löschen, +perspektivisch Orders) werden **gestaged statt ausgeführt**: Vorschlag → Review im +Hauptfenster → Freigabe/Ablehnung. Pro Tool/Aktion konfigurierbar: +`auto | approve | deny`. + +Das bisher wirkungslose `PermissionGate` (S4) wird dabei zum zentralen +Durchsetzungspunkt ausgebaut: Policy-Prüfung, Staging-Entscheidung und Audit-Hook +(A3) an einer Stelle statt ad-hoc in jedem Tool. S4 wird nicht separat bearbeitet, +sondern geht hier auf. + +Ergänzungen (Juli 2026 beschlossen): + +- **Plan-Freeze**: Freigegeben wird ein eingefrorener, konkreter Aufruf — Tool, + Aktion und exakte Argumente zum Zeitpunkt des Stagings. Ausgeführt wird genau + das Eingefrorene; jede nachträgliche Änderung ist eine neue Freigabe. +- **Approval-Records**: Jede Entscheidung (Freigabe wie Ablehnung) wird als + Datensatz im Audit-Log (A3) verankert — wer, wann, was, mit welchem Ergebnis. + +Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen Vorschlag +erzeugen, keine Ausführung. + +### A3 — Audit-Log (F-A2) + +Konzept-Doc: [Audit-Konzept](Audit-Konzept.md). + +Jeder Tool-Aufruf wird protokolliert: Agent, Lauf/Session, Zeitstempel, Argumente, +Ergebnis-Status. Append-only (JSONL oder SQLite-Tabelle auf dem vorhandenen +`SqliteStorage`). + +Designregeln (aus dem OpenAlice-Provenance-Konzept): + +- Herkunft wird **von der Engine gestempelt**, nie vom Agenten behauptet. +- Einträge sind unveränderlich; Korrekturen sind neue Einträge. +- Unbekannte Herkunft wird als unbekannt markiert, nicht geraten. +- Worker-Typ (Modell/Engine) und verantwortliche Session sind getrennte Begriffe. + +Ergänzung (Juli 2026 beschlossen) — **Receipts**: Jeder abgeschlossene Task/Lauf +erhält einen Abschluss-Beleg mit Ergebnis-Verweis, Schritten, Tokens und Kosten +(Verknüpfung `RunUsage` ↔ Task). Damit fällt C7 („Kosten pro Ergebnis") +weitgehend als Abfallprodukt ab. + +Das Audit-Log ist zugleich das Fundament für das Ergebnisregister (C7) und die +Tool-Fehlerquote aus der Leistungsanalyse. + +### A4 — Skill-/Toolset-Modell (ersetzt T6) + +Dreischichtig statt „alles immer im System-Prompt": + +1. **Dauerhafter Kern** — Identity, Soul, unveränderliche Regeln. Schlank, damit der + Prompt-Cache (T1) stabil bleibt. +2. **Nachladbare Skills/Toolsets** — fachliche Abläufe und selten genutzte Tools + werden erst auf Anforderung geladen (`list_toolsets` → `load_toolset`). +3. **Selbstkorrigierende Tool-Fehler** — Fehlermeldungen nennen die gültigen + Parameter/Aktionen, statt das Modell raten zu lassen. + +Mechanik (Juli 2026 beschlossen, nach GoClaw-Vorbild): Skills liegen als +`SKILL.md` mit Frontmatter (`name`, `description`) im Instanz- bzw. +Agenten-Verzeichnis. Bei wenigen Skills werden die Kurzbeschreibungen inline in +den Prompt eingebettet, bei vielen gibt es stattdessen ein `skill_search`-Tool. +Änderungen an Skill-Dateien werden per Hot-Reload übernommen. + +Dazu Hermes' Selbstverbesserungs-Idee: **Agenten dürfen Skills aus Erfahrung +selbst schreiben** (das AgentEditor-Tool ist die Vorstufe). Wichtig: Ein Skill +ist Prompt-Input — agentengeschriebene Skills werden erst nach Freigabe (A2) +aktiv, sonst wäre das ein Injection-Kanal in künftige Läufe. + +### A5 — Matrix/Element-Migration + +Beschlossene Richtung: Die Kommunikation (Benachrichtigungen, Berichte, Chat mit +Agenten) wird auf Element/Matrix umgestellt. + +- Ersetzt die im OpenAlice-Vergleich erwogene Inbox — Berichte landen in + Matrix-Räumen. +- Der Tool-Kandidat **Notify** entfällt und geht hierin auf. +- Betroffen: Telegram-Tool (Rolle klären), WebView-Chat (bleibt er Haupt-UI?), + Streaming K4 (Matrix streamt nicht — nach dieser Entscheidung neu bewerten). + +Scope ist noch unbestimmt — braucht ein eigenes Konzept-Doc, bevor es in die +Reihenfolge eingeordnet wird. + +### A6 — MySQL-Replikations-Spiegel (optional) + +Beschlossen Juli 2026. **SQLite bleibt die einzige Wahrheit** — gearbeitet wird +ausschließlich auf der Instanz-DB. MySQL ist ein reiner, nachgelagerter Spiegel: +Er empfängt nur INSERT/UPDATE/DELETE vom Replikator; die App liest im Betrieb +**nie** daraus. Einziger Lesezweck: Wiederherstellung, falls die SQLite korrupt +ist — daneben steht der Spiegel externen Auswertungen (Dashboards, Ad-hoc-SQL) +offen, ohne die Agenten-Maschine zu berühren. + +Leitplanken: + +- **Outbox-Muster** an der vorhandenen Schreib-Warteschlange von `SqliteStorage`: + Replikations-Einträge lokal puffern, idempotente Upserts nach MySQL. + Nie blockierend — ist MySQL nicht erreichbar, staut die Outbox und holt auf. +- Mehrere Instanzen replizieren in denselben Spiegel; Zeilen tragen `instance_id`. +- **Restore-Pfad (Spiegel → frische SQLite) muss existieren und getestet sein** — + gleiche Regel wie beim Backup: ein ungeprüftes Restore ist eine Vermutung. +- Kein Koordinationspunkt: Claiming, Locks, Taskboard-Zustand bleiben lokal. + Die Regel „nur der Replikator schreibt, niemand liest im Betrieb" gehört ins + Konzept-Doc. +- Sicherheit: TLS zur Datenbank, Zugangsdaten über `SecretProtector`; + Output-Scrubbing der Tool-Ergebnisse wird wichtiger, weil Kontext-Daten + künftig auch im Spiegel liegen. +- Ergänzt das ZIP-Backup, ersetzt es nicht — Identity/Soul, Settings, Workspace + und Telegram-Session bleiben Sache des Instanz-Backups. + +**Voraussetzung — Historie-Umzug:** `ChatHistory.json` zieht in die Instanz-DB um +(aktive Tabelle + Archiv-Tabelle mit FTS5-Volltextindex und `history_search`-Tool, +siehe K6 in Abschnitt B). Das löst +nebenbei B10 (O(n²)-Schreiblast) und gibt K6 seine Form; erst danach schützt der +Spiegel auch die Historie. Zeitlich passt A6 zu dem Server, der ggf. mit A5 +(Matrix) ohnehin dazukommt. + +--- + +## B — Offen aus der Bestandsaufnahme + +| Punkt | Was | Stand | +|---|---|---| +| T4 | Proaktiv statt reaktiv kompaktieren | offen, unverändert | +| K2-Rest | Untrusted Content als Daten rahmen (``) | A2 nimmt die Schärfe; die Rahmung selbst bleibt nötig | +| K4 | Streaming | **zurückgestellt** bis A5 entschieden ist | +| K6 | Historie **archivieren + durchsuchbar machen** (FTS5-Index, `history_search`-Tool) statt nur rotieren | beschlossen; Teil des Historie-Umzugs (Voraussetzung von A6) | +| Memory-Flush vor Compaction | Bevor der ContextCompactor zusammenfasst, bekommt der Agent ein eng begrenztes Fenster, Dauerhaftes per `memory_store` zu sichern — sonst wirft die Compaction Wissen weg | beschlossen (GoClaw-Muster) | +| Memory-Auto-Injection | Relevante Memory-Abstracts werden automatisch eingeblendet (Relevanzschwelle, Deckel ~200 Tokens), **in die Nutzernachricht, nie in den System-Prompt** (Prompt-Cache T1) | beschlossen; löst den offenen Punkt „Automatische Einblendung" im [Memory-Konzept](Memory-Konzept.md) | +| Output-Scrubbing | Bekannte Secret-Werte (Register des `SecretProtector`) werden zentral aus **allen** Tool-Ergebnissen maskiert, bevor sie in Kontext, Historie oder Spiegel (A6) gelangen | beschlossen; schließt die Lücke, die S3 nur für URLs schloss | +| Hygiene-Paket | B9 (`index_Count`-Race), B11/T8 (`max_tokens` setzen), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B10 geht im Historie-Umzug (A6) auf | + +Erledigt seit der letzten Fortschreibung: B4 vollständig (Preise kommen live vom +`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet). + +### B-DC — Deploymentcenter-Anbindung + +WatchDog und LicenseLabrador sind durch das +[Deploymentcenter](Deploymentcenter-Integration.md) ersetzt: eine Adresse, ein Token, +und dazu Update-Prüfung, Fehler-Stream und Bugtracker. Watchdog läuft wieder pro +Instanz (ein Monitor je Instanz, mit Gesundheitsprüfungen und angekündigtem Ende). + +Offen: + +| Punkt | Was | Bemerkung | +|---|---|---| +| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt | +| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar | +| DC3 | Release-Strecke: `pack-and-deploy` mit `` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props` → `ReleaseInfo`) | +| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI | +| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** | + +--- + +## C — Offen aus dem Finanz-/Analyse-Konzept + +Punkte 1–2 von dort (atomares Schreiben, Backup/Restore inkl. UI) sind umgesetzt. + +| # | Was | Bemerkung | +|---|---|---| +| C1 | Marktkalender (`onlyWhenMarketOpen` + `MarketCalendar`-Tool) | Scheduler-Teil gehört ins Taskboard-Frontmatter (A1) | +| C2 | `Indicators`-Tool — deterministische Berechnung | Qualität hoch, Tokens runter | +| C3 | Datenaktualität erzwingen (`maxAgeSeconds`) | | +| C4 | Termine & Fundamentaldaten (Earnings, EDGAR, Wirtschaftskalender) | | +| C5 | Bestandsregister (Positionen) | Grundlage für C7/C8 | +| C6 | Nachrichten-Entdopplung (`SeenItems`) | | +| C7 | Ergebnisregister (Stufe 2, „Kosten pro Ergebnis") | fällt weitgehend aus den A3-Receipts ab | +| C8 | Falsifizierbare Aussagen + Auflösung, Brier-Score (Stufe 3) | braucht C5, C7 und einen Auflösungs-Task (A1) | + +--- + +## D — Toolkandidaten (unbeschlossen) + +WebSearch, Http (generisch mit Allowlist), Shell (sandboxed), Git, Vision. +Notify ist gestrichen — geht in A5 auf. + +--- + +## Vorgeschlagene Reihenfolge + +| # | Vorhaben | Begründung | +|---|---|---| +| 1 | A1 Taskboard | Fundament; löst sechs bestehende Punkte auf einmal | +| 2 | A3 Audit-Log | klein, sofort nützlich; muss vor A2 da sein, damit Freigaben protokolliert werden | +| 3 | A2 Staging-Freigabe | größter Sicherheitsgewinn; Voraussetzung für unbeaufsichtigten Betrieb | +| 4 | C1 Marktkalender | spart sofort Kosten; nutzt A1-Frontmatter | +| 5 | A4 Skills/Toolsets | Token-Hebel, Cache-stabil | +| 6 | C2 Indicators | Qualität + Kosten | +| 7 | C7 + C8 Ergebnisregister, Aussagen | das eigentliche Leistungsmaß; braucht A3 | +| — | Hygiene-Paket (B) | zwischendurch, unabhängig | +| — | Historie-Umzug in die Instanz-DB | löst B10 + K6; Voraussetzung für A6 | +| — | A6 MySQL-Spiegel | nach dem Historie-Umzug; natürliches Zuhause auf dem A5-Server | +| — | A5 Matrix | eigenes Konzept-Doc zuerst; Scope klären, dann einordnen | + +Leitlinie der Reihung: erst Nachvollziehbarkeit und Kontrolle (Audit, Staging), +dann Fähigkeiten — ein Agent, der unbeaufsichtigt läuft, braucht zuerst Bremsen, +dann PS. + +--- + +## Umsetzung mit Opus 4.6 — Einstufung + +Die Entwicklung erfolgt mit Opus 4.6. Die meisten Vorhaben sind damit gut +machbar, sofern die hier notierten Vorgaben mitgegeben werden. Zwei Stellen +berühren Nebenläufigkeits-Invarianten bzw. Engine-Querschnitte — sie sind für +Opus 5 / Fable markiert oder durch eine Architektur-Vorgabe entschärft. + +| Vorhaben | Einstufung | Vorgabe / Begründung | +|---|---|---| +| A1: Dateiformat, Frontmatter-Parsing, `task_*`-Tool, Migration | 4.6 | klar spezifizierbar, gut testbar | +| A1: **Scanner-Kern** (atomares Claiming, Auto-Dispatch, Reconciliation) | ⚠️ **Opus 5 / Fable** | At-most-once-Semantik, Claim-CAS und das Zusammenspiel mit den seit B2 serialisierten Chat-Läufen sind genau die Fehlerklasse, die hier schon einmal schiefging. Falls doch 4.6: erst Konzept-Doc, Umsetzung strikt dagegen, Property-Tests für die Invarianten („nie zwei Claims auf einen Task", „kein Dispatch bei offenem Blocker", „doppelter Tick = ein Lauf") | +| A2: Gate, Policy, Staging-Queue, Review-UI, Approval-Records | 4.6 | mit der folgenden Architektur-Vorgabe | +| A2: **Fortsetzung nach Freigabe** | 4.6 nur mit Vorgabe | **Kein pausierter, im Speicher gehaltener Lauf.** Vorgabe: Der Lauf endet beim Staging regulär — das Tool liefert „zur Freigabe vorgelegt" als Ergebnis, der Agent schließt ab. Die Freigabe erzeugt einen Folge-Task (A1), der den Agenten mit dem **eingefrorenen** Aufruf weckt. Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser Vereinfachung unnötig | +| A3: Audit-Log + Receipts | 4.6 | append-only, klares Schema, keine Nebenläufigkeitsfallen | +| A4: Skills | 4.6 | `FileSystemWatcher` mit Debounce (~500 ms); agentengeschriebene Skills erst nach Freigabe aktiv (siehe A4) | +| Memory-Flush vor Compaction | 4.6 mit Anleitung | Harte Grenzen: max. 3–5 Schritte, einziges Tool `memory_store`, Timeout, günstiges Modell (wie T3), höchstens einmal je Compaction-Zyklus. Vorsicht: Der ContextCompactor hatte B1/B14 — die bestehenden Paarungs-Tests müssen unverändert grün bleiben | +| Memory-Auto-Injection | 4.6 | in die Nutzernachricht, nie in den System-Prompt (sonst verfällt der Prompt-Cache T1); Deckel ~200 Tokens | +| Output-Scrubbing | 4.6 | ein zentraler Filter an der Stelle, wo Tool-Ergebnisse in den Kontext gelangen (`ExecuteToolCallAsync`); Werte aus dem Secret-Register | +| Historie-Umzug + FTS5 + `history_search` | 4.6 | Migration nur nach frischem Backup; alte JSON-Dateien erst nach verifiziertem Import löschen | +| A6: MySQL-Spiegel | 4.6 mit Anleitung | Outbox mit Wasserzeichen, idempotente Upserts, nie blockieren; der getestete Restore-Pfad ist Teil der Definition of Done | +| C1 Marktkalender, C2 Indicators | 4.6 | reine Fachlogik, deterministisch testbar | + +Generell: Neue Subsysteme (Scanner, Staging, Audit, Replikator) kommen mit Tests +nach der [Teststrategie](Teststrategie.md) — die Invarianten-Tests sind bei den +markierten Punkten kein Nice-to-have, sondern die Absicherung dafür, dass ein +schwächeres Modell sie umsetzen darf. diff --git a/docs/RocketChat-Nextcloud-Konzept.md b/docs/RocketChat-Nextcloud-Konzept.md new file mode 100644 index 0000000..946f971 --- /dev/null +++ b/docs/RocketChat-Nextcloud-Konzept.md @@ -0,0 +1,544 @@ +# Rocket.Chat und Nextcloud — Konzept + +Zwei neue Tools, ein gemeinsamer Zweck: **Rocket.Chat** wird der Ort, an dem wir mit den +Agenten reden; **Nextcloud** wird der Ort, an dem die Agenten uns Ergebnisse hinlegen. +Der typische Ablauf ist die Kombination aus beidem — „schreib mir die Auswertung und leg +sie in die Cloud" im Chat, Datei in Nextcloud, Link zurück in den Chat. + +Dieses Dokument prüft die Machbarkeit, legt den Schnitt fest und benennt die Punkte, die +vor der Umsetzung entschieden werden müssen. **Es ist noch keine Umsetzungsfreigabe.** + +Verwandt: [Taskboard-Konzept](Taskboard-Konzept.md) (Scanner/Wake), [Staging-Konzept](Staging-Konzept.md) +(Freigaben), [Audit-Konzept](Audit-Konzept.md), [Roadmap](Roadmap.md) (A5 — siehe Konflikt unten). + +--- + +## 0 — Kurzfassung des Befunds + +| Frage | Antwort | +|---|---| +| Ist es umsetzbar? | Ja, beides. Ohne neue Architektur — die vorhandenen Bausteine tragen. | +| Braucht es Änderungen am Core? | Für Phase 1: **nein**, nur zwei neue Tool-Projekte + Staging-Defaults. Für den automatischen Rückweg (Antwort landet ohne Zutun des Modells im Raum) und den Notfallkanal: ja, zwei kleine Core-Ergänzungen. | +| Größtes technisches Risiko | Nicht die API — sondern **Antwort-Schleifen zwischen Agenten** und **Kosten durch zu häufiges Wecken**. | +| Größte Konzeptkollision | Roadmap **A5** sieht Matrix/Element für genau diesen Zweck vor. Rocket.Chat ersetzt A5, oder wir haben zwei Chat-Wege. Muss entschieden werden. | +| „Agent erstellt Dokument direkt über die Nextcloud-API" | So nicht. Nextcloud hat keine API, die Inhalte *erzeugt*. Der Weg ist: Datei lokal im Workspace erzeugen → hochladen. Für PDF/XLSX kann **Collabora als Konverter** dienen — das ist der elegante Teil, siehe 5.4. | + +--- + +## 1 — Was schon da ist (und deshalb nicht neu gebaut wird) + +Der Rückkanal von außen nach innen existiert vollständig: + +``` +TaskScanner (60-s-Takt) + └─ Task vom Typ tool_job + └─ EngineTaskDispatcher.DispatchToolJobAsync + └─ IToolJobProvider.ExecuteJobAsync ← kein LLM, kostenlos + └─ ToolJobResult.Wake(text) ← nur wenn wirklich etwas da ist + └─ AgentEngine.ChatAsync ← hier erst kostet es Tokens +``` + +Das Telegram-Tool nutzt genau das (`telegram_poll`). **Rocket.Chat bekommt dieselbe +Bauform** — `rocketchat_poll`. Damit gilt automatisch: + +- Zustand (letzter gesehener Zeitpunkt) über `IStateStore`, überlebt Neustarts. +- Ein Takt ohne neue Nachricht kostet nichts. +- Kein eigener Thread, kein eigener Scheduler, keine Sonderbehandlung beim Start. +- Jeder Tool-Aufruf läuft ohnehin durch `StagingGate` (A2) und Audit (A3). + +Ebenso vorhanden und wiederverwendbar: + +- **Pro-Agent-Konfiguration** (`AgentConfig.Tools["RocketChat"]`) — jeder Agent bekommt + seine eigenen Zugangsdaten, ohne dass ein Agent die eines anderen sehen kann. +- **`ConfigSecrets`** verschlüsselt Felder nach Namen (`token`, `password`, `apikey` …) — + ein Feld namens `authToken` bzw. `appPassword` ist automatisch geschützt. +- **Workspace-Prefixe** `personal:` / `shared:` samt Path-Traversal-Prüfung — aus dem + FTP-Tool wortgleich übernehmbar für Nextcloud-Uploads. + +--- + +## 2 — Rocket.Chat: Machbarkeit + +Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist +Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature. + +### 2.1 Identität — ein echter Benutzer je Agent + +Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist +der richtige Ansatz und wird von Rocket.Chat direkt unterstützt. + +- Admin legt je Agent einen Benutzer an: `POST /api/v1/users.create` + (`{ name, username, email, password, roles: ["bot"] }`). +- Die Rolle **`bot`** ist wichtig: Sie markiert den Benutzer als Maschine (relevant für + Schleifenschutz, siehe 2.5) und wird in neueren Versionen bei der Sitzplatzzählung + nicht als normaler Nutzer gewertet. *Gegen die eigene Version zu prüfen.* +- Für jeden Agenten wird ein **Personal Access Token** erzeugt + (`POST /api/v1/users.generatePersonalAccessToken`, oder im Konto des Benutzers). + Dauerhaft gültig, einzeln widerrufbar — deutlich besser als Login mit Passwort, weil + kein Session-Ablauf und keine gespeicherten Passwörter im Spiel sind. +- Authentifiziert wird jeder Aufruf über zwei Header: `X-Auth-Token` und `X-User-Id`. + +**Entscheidung, die ich empfehle:** Das Anlegen der Benutzer ist **kein Agenten-Tool**. +Es ist eine einmalige Einrichtungsfunktion in der WinForms-Oberfläche +(Instanz-Einstellungen → Rocket.Chat → „Agenten-Benutzer anlegen"). Sonst müsste ein +Agent ein Admin-Token halten — und ein Admin-Token in Reichweite einer Prompt-Injection +ist genau das, was A2 verhindern soll. Der Admin-Token liegt in der **Instanz**-Konfiguration, +nicht in einer Agenten-Tool-Konfiguration. + +### 2.2 Ausgang — Nachrichten senden + +| Zweck | Endpunkt | +|---|---| +| In Kanal/Gruppe/DM schreiben | `POST /api/v1/chat.postMessage` (`roomId` oder `channel`) | +| Auf eine Nachricht antworten (Thread) | dasselbe, mit `tmid` | +| Datei anhängen | `POST /api/v1/rooms.upload/{roomId}` (multipart) | +| Reaktion setzen | `POST /api/v1/chat.react` | + +Gesendet wird als der Agenten-Benutzer — Direktnachrichten funktionieren dadurch echt und +nicht als „Bot mit Alias". + +### 2.3 Eingang — der Poll-Weg (Phase 1) + +Der sparsame Weg, ohne jede neue Infrastruktur: + +1. `GET /api/v1/subscriptions.get?updatedSince=` — **ein einziger Aufruf** + liefert für diesen Agenten alle Räume mit Ungelesen-Zähler, Erwähnungs-Zähler und + „zuletzt gesehen"-Marke. Auch bei 50 Räumen bleibt es ein Aufruf. +2. Nur für Räume mit relevanten Neuigkeiten wird die Historie geholt: + `channels.history` (öffentlich) / `groups.history` (privat) / `im.history` (DM), + jeweils mit `oldest=`. +3. `POST /api/v1/subscriptions.read` markiert gelesen — der Zähler geht zurück auf null. + +Zustand im `IStateStore`: `rocketchat:{agentId}:lastCheck` sowie je Raum die zuletzt +verarbeitete Nachrichtenzeit. + +**Rate-Limits:** Rocket.Chat begrenzt REST-Aufrufe (Standard in der Größenordnung von +10 Aufrufen je Minute und Endpunkt). Bei einem Takt von 30–60 Sekunden und einem +Sammelaufruf pro Takt ist das unkritisch — es ist aber der Grund, warum der Entwurf über +`subscriptions.get` sammelt statt jeden Raum einzeln zu pollen. + +**Latenz:** Bei 60-Sekunden-Takt antwortet ein Agent im Mittel nach ~30 s plus Laufzeit. +Für Gespräche mit Agenten ist das spürbar, aber tragbar. Der Takt lässt sich pro Job +setzen (`*/1 * * * *` ist das Minimum des Cron-Modells; feiner ginge nur über die +Realtime-API). + +### 2.4 Eingang — die Realtime-Variante (Phase 3, optional) + +Rocket.Chat bietet eine WebSocket-/DDP-Schnittstelle (`wss://host/websocket`): nach +`login` mit dem Token abonniert man `stream-notify-user/{userId}/notification` und +bekommt DMs und Erwähnungen **sofort** gepusht, ohne Polling. + +Das ist die richtige Endstufe (Antwortzeit ~1 s statt ~30 s), aber es ist eine dauerhafte +Verbindung je Agent mit Wiederverbindungs-Logik — also eine echte Komponente, keine +Ergänzung eines Tools. Vorschlag: **erst nachrüsten, wenn Phase 1 im Alltag steht** und +sich die Verzögerung tatsächlich stört. + +Eine dritte Möglichkeit — Rocket.Chats *Outgoing Webhook* auf unsere vorhandene +`ClawdDotNetApi` (Port 5082) — wäre die einfachste Push-Lösung, setzt aber voraus, dass +der Rocket.Chat-Server den Windows-Rechner über das Netz erreicht. Das ist eine Frage +deiner Netztopologie und keine der Software. Falls erreichbar: der kürzeste Weg zu +niedriger Latenz. + +### 2.5 Die zwei echten Fallen + +Diese beiden Punkte sind wichtiger als jede API-Frage. + +**(a) Mehrere Agenten im selben Raum.** Wenn drei Agenten denselben Gruppenchat pollen, +antworten drei Agenten auf jede Nachricht. Regel im Entwurf: + +> Ein Agent wird nur geweckt bei (1) Direktnachrichten an ihn oder (2) Nachrichten, die +> ihn per `@name` erwähnen. Alles andere liest er nicht einmal. + +Ein Raum kann per Konfiguration auf `respondToAll: true` gestellt werden — das ist die +bewusste Ausnahme für einen Raum mit genau einem Agenten. + +**(b) Agenten-Schleifen.** Agent A schreibt, Agent B wird geweckt, antwortet, weckt A — +und das läuft, bis das Tagesbudget greift. Der `LoopGuard` schützt nur *innerhalb* eines +Laufs, nicht über Agenten hinweg. Regel im Entwurf: + +> Nachrichten von Benutzern mit der Rolle `bot` werden **ignoriert**, außer der Agent ist +> namentlich erwähnt. Zusätzlich eine Drossel: höchstens N Weckvorgänge je Raum und +> Stunde (Zähler im `IStateStore`), danach schweigt der Agent in diesem Raum bis zur +> nächsten Stunde und protokolliert das. + +Das Tagesbudget (K5) ist das letzte Netz, nicht das erste. + +### 2.6 Der Rückweg der Antwort + +Der Wake-Mechanismus liefert die Nachricht *in* den Agenten. Seine Antwort geht heute in +den Chat-Verlauf, nicht zurück nach Rocket.Chat. Zwei Wege: + +- **(a) Der Agent antwortet selbst** — die Weck-Nachricht enthält die `roomId` und die + Anweisung, mit `RocketChat.send_message` zu antworten. Kein Core-Eingriff, funktioniert + sofort. Schwäche: Es hängt daran, dass das Modell es tut. Erfahrungsgemäß klappt das + gut, aber nicht in 100 % der Fälle. +- **(b) Automatischer Rückweg** — der Tool-Job merkt sich „Antwort gehört nach Raum X", + und der Dispatcher schickt die Abschlussnachricht des Laufs dorthin. Zuverlässig, aber + es braucht einen kleinen Haken in `ToolJobResult`/`EngineTaskDispatcher` + (etwa ein `ReplyTo`-Feld, das der Dispatcher nach dem Lauf an dasselbe Tool zurückgibt). + +**Empfehlung:** (a) in Phase 1, (b) in Phase 2 nachziehen — denn (b) ist der Unterschied +zwischen „meistens antwortet er" und „er antwortet". Für die Hauptkommunikationsschiene +ist das am Ende nicht optional. + +### 2.7 Sicherheit + +- **Nachrichten aus Rocket.Chat sind fremder Text.** Sie müssen als + `` gerahmt in den Kontext (Roadmap K2-Rest). Bei Telegram fehlt das + bis heute; hier sollte es von Anfang an drin sein, weil Gruppenchats mehrere Absender + haben. +- **Raum-Allowlist** je Agent (`allowedRooms`), analog `allowedChatIds` beim Telegram-Tool. +- **Staging-Vorschlag** (siehe 6): Senden in erlaubte Räume `auto`, alles darüber hinaus + `approve`. +- Zugangsdaten heißen im Konfigurationsfeld `authToken` → `ConfigSecrets` verschlüsselt sie + automatisch. Der Admin-Token der Instanz muss in `ConfigSecrets.Apply(InstanceConfig)` + ergänzt werden. +- **TLS** ist Pflicht; selbstsignierte Zertifikate ausdrücklich konfigurieren müssen statt + Validierung generell abschalten. + +### 2.8 Tool-Zuschnitt + +``` +Tool: RocketChat +Aktionen: send_message | reply | send_file | list_rooms | read_room + | mark_read | search +Job: rocketchat_poll +``` + +Konfiguration je Agent: + +```json +"RocketChat": { + "baseUrl": "https://chat.example.org", + "userId": "aBcD…", + "authToken": "…", // von ConfigSecrets geschützt + "allowedRooms": ["GENERAL", "finanz-team"], + "defaultRoom": "finanz-team", + "mentionOnly": true, + "maxWakesPerRoomPerHour": 12 +} +``` + +--- + +## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt + +Das ist die Anforderung, die die Architektur bestimmt — nicht der Chat selbst. Der Kern: +**Rocket.Chat darf ein Kanal sein, nicht der Kanal.** + +### 3.1 Was heute schon unabhängig funktioniert + +| Kanal | Unabhängig von Rocket.Chat? | Richtung | +|---|---|---| +| WinForms-Chat (`frm_chat`) | vollständig — läuft in der App selbst | beide | +| Telegram-Bot-Tool | ja — fremde Infrastruktur | beide | +| Mail-Tool | ja, sofern der Mailserver anderswo läuft | beide | +| Web-Chat / `ClawdDotNetApi` | ja, aber nur im lokalen Netz | beide | + +Wir sind also nicht bei null. Was fehlt, ist die **Umschaltung** — heute muss ein Mensch +merken, dass nichts mehr ankommt. + +### 3.2 Vorschlag: `ChannelRouter` im Core + +Eine kleine Komponente im Core (kein neues Tool, keine Tool-zu-Tool-Abhängigkeit — +sie löst Tools über die vorhandene `ToolRegistry` nach Namen auf, wie es der Dispatcher +schon tut): + +- Je Agent eine **geordnete Kanalliste**, z. B. `["RocketChat", "Telegram", "Mail"]`. +- Eine Methode „stelle dem Menschen diese Nachricht zu": versucht der Reihe nach, bis + einer erfolgreich ist, und protokolliert im Audit-Log, **über welchen Kanal** zugestellt + wurde — inklusive des Hinweises „Primärkanal war nicht erreichbar". +- Genutzt von: Agenten (`notify_user`), aber vor allem von **systemseitigen** Meldungen, + die heute keinen Weg nach außen haben: Staging-Vorschlag wartet auf Freigabe, Budget + überschritten, Watchdog-Alarm, Task blockiert. + +Der zweite Teil ist der wichtigere: Gerade wenn etwas kaputt ist, ist die Meldung darüber +diejenige, die ankommen muss. + +### 3.3 Gesundheitsprüfung und Eskalation + +Ein Tool-Job `rocketchat_health` (Takt ~5 Minuten, `GET /api/info`): + +- Nach **drei** aufeinanderfolgenden Fehlschlägen: einmalige Meldung über den nächsten + Kanal der Liste — „Rocket.Chat ist seit HH:MM nicht erreichbar, ich melde mich hier." + Einmalig, nicht je Takt. +- Bei Rückkehr: „Rocket.Chat ist wieder da", und der Zustand wird zurückgesetzt. +- Nachrichten, die während des Ausfalls nicht gesendet werden konnten, werden **nicht** + in einer eigenen Warteschlange gehalten — sie gehen über den Ersatzkanal raus. Eine + zweite Zustellwarteschlange wäre eine zweite Fehlerquelle. + +**Eingehend während des Ausfalls:** Der Telegram-Poll-Job bleibt dauerhaft aktiv, nur mit +langsamem Takt (z. B. alle 5 Minuten). Er kostet nichts, wenn nichts kommt — und ist im +Ernstfall der Weg, auf dem *du* die Agenten erreichst. Der WinForms-Chat ist ohnehin immer +da, solange die App läuft. + +**Entschieden (August 2026): Telegram ist der Notfallkanal.** Die Kanalliste lautet damit +`["RocketChat", "Telegram"]`. Konsequenzen: + +- Das Telegram-Tool wird **nicht** abgebaut und geht nicht in Rocket.Chat auf. Es behält + seine Rolle, verliert aber die Rolle als Alltagskanal. +- `Telegram.send_message` bleibt in der Staging-Policy auf `approve` — mit einer Ausnahme: + Meldungen, die der `ChannelRouter` selbst erzeugt (Ausfall, Budget, Watchdog, offene + Freigabe), laufen **ohne** Freigabe. Sonst bliebe die Warnung, dass eine Freigabe + aussteht, selbst in der Freigabewarteschlange hängen — ein Ringschluss, der genau im + Ernstfall zuschlägt. +- Der Telegram-Poll bleibt dauerhaft eingerichtet, aber mit langsamem Takt. Ein Kanal, der + erst im Notfall eingeschaltet wird, ist im Notfall ungetestet. +- Mail bleibt außen vor. Zwei Ersatzkanäle zu pflegen lohnt nicht; das Mail-Tool behält + seinen fachlichen Zweck. + +### 3.4 Was das für die Prompts heißt + +Ein Agent soll seinen Kanal nicht selbst wählen. Er sagt „ich möchte dem Nutzer das hier +mitteilen", der Router entscheidet. Sonst muss das Modell im Fehlerfall improvisieren — +und genau dann ist Improvisation das Letzte, was man will. + +--- + +## 4 — Konflikt mit Roadmap A5 (Matrix) + +Roadmap-Punkt **A5** legt fest: „Die Kommunikation (Benachrichtigungen, Berichte, Chat mit +Agenten) wird auf Element/Matrix umgestellt", und der gestrichene Tool-Kandidat *Notify* +geht darin auf. + +Rocket.Chat besetzt exakt dieselbe Rolle. Drei mögliche Auflösungen: + +1. **Rocket.Chat ersetzt A5.** A5 wird umgeschrieben, Matrix entfällt. Vorteil: eine + Schiene, ein Betriebsaufwand, die Instanz läuft bereits. +2. **A5 bleibt, Rocket.Chat ist nur ein weiteres Tool.** Dann bauen wir zweimal dasselbe. + Schwer zu begründen. +3. **Rocket.Chat primär, Matrix als späterer Zweitkanal.** Passt formal zur + Redundanz-Anforderung, verdoppelt aber den Wartungsaufwand für einen Fall, den + Telegram schon abdeckt. + +**Meine Empfehlung: (1).** Der `ChannelRouter` aus 3.2 ist ohnehin die Verallgemeinerung, +die A5 gebraucht hätte — mit ihm ist ein späterer Matrix-Kanal ein zusätzlicher Eintrag in +der Liste, keine Migration. Das ist eine Entscheidung für dich, keine technische Sachfrage. + +--- + +## 5 — Nextcloud: Machbarkeit + +### 5.1 Der Zugriffsweg + +Nextcloud hat zwei Schnittstellen, beide brauchen wir: + +| Zweck | Schnittstelle | +|---|---| +| Dateien lesen/schreiben/auflisten/verschieben | **WebDAV**: `/remote.php/dav/files/{benutzer}/{pfad}` | +| Öffentlichen Link erzeugen | **OCS**: `/ocs/v2.php/apps/files_sharing/api/v1/shares` | + +Authentifiziert wird mit **App-Passwörtern** (Nextcloud → Einstellungen → Sicherheit → +„Neues App-Passwort erstellen") per Basic-Auth. Ein App-Passwort ist einzeln widerrufbar +und lässt das eigentliche Kontopasswort unangetastet — dieselbe Logik wie das Personal +Access Token bei Rocket.Chat. + +WebDAV braucht keine Bibliothek: `HttpClient` mit den Methoden `PUT`, `GET`, `MKCOL`, +`PROPFIND`, `MOVE`, `DELETE`. Nur `PROPFIND` liefert XML (Multistatus), das geparst werden +muss — überschaubar, und es erspart uns eine weitere Abhängigkeit. + +### 5.2 Ein Benutzer je Agent — oder ein Sammelkonto? + +Zwei Modelle: + +- **Je Agent ein Nextcloud-Benutzer.** Sauber nachvollziehbar („wer hat das abgelegt"), + passt zum Rocket.Chat-Modell, kostet je nach Lizenzmodell Nutzer. +- **Ein Dienstkonto `clawd-agents` mit Unterordnern je Agent.** Einfacher zu verwalten, + Herkunft steht dann im Pfad statt im Konto. + +**Empfehlung:** Ein Dienstkonto mit Ordnerstruktur `/ClawdDotNet/{Agent}/…`, **plus** einen +mit dir geteilten Ordner `/ClawdDotNet/Berichte/`. Begründung: Bei Rocket.Chat ist die +eigene Identität funktional zwingend (DMs, Erwähnungen), bei Dateien ist sie es nicht — +und ein Ordnerbaum ist leichter aufzuräumen als zehn Konten. Falls du die Trennung dennoch +willst, ändert das am Tool nichts, nur an der Konfiguration. + +Die Ordnerdurchsetzung gehört ins Tool: eine konfigurierte `rootPath`, aus der der Agent +nicht ausbrechen kann — dieselbe Prüfung wie in `FTPTool.ResolveLocalPath`. + +### 5.3 Die ehrliche Antwort zu „direkt über die API erstellen" + +Nextcloud hat **keine** API, die Dokumenteninhalte erzeugt. Es ist ein Dateiablage- und +Freigabesystem; Collabora ist ein *Editor* im Browser (über WOPI angebunden), kein +Generator, den man von außen mit „erstelle eine Tabelle mit diesen Zahlen" beauftragen +kann. + +Der tatsächliche Weg ist deshalb der, den du selbst schon beschrieben hast: + +``` +Agent erzeugt die Datei im eigenen Workspace (FileRW-Tool, schon vorhanden) + → Nextcloud.upload (WebDAV PUT) + → Nextcloud.share (optional) (OCS, liefert Link) + → RocketChat.send_message mit dem Link +``` + +Das ist kein Umweg, sondern die richtige Aufteilung: Der Agent kann seine Datei lokal +prüfen und korrigieren, bevor sie irgendwo landet. + +### 5.4 Formate — und wo Collabora doch nützlich wird + +Was ein Agent von sich aus gut schreiben kann: **Markdown** (Nextcloud rendert `.md` +direkt in der Weboberfläche — für Berichte oft die beste Wahl), **CSV**, **HTML**, JSON. + +Was er nicht von sich aus schreiben kann: `.xlsx`, `.docx`, `.pdf`. + +Hier gibt es einen eleganten Weg, weil du Collabora ohnehin betreibst: Collabora Online +bringt einen **Konvertierungs-Endpunkt** mit (`POST /cool/convert-to/{format}`, multipart). +Damit gilt: + +| Ziel | Weg | +|---|---| +| PDF | Agent schreibt HTML oder ODT → Collabora → PDF | +| XLSX | Agent schreibt CSV → Collabora → XLSX | +| DOCX | Agent schreibt HTML/ODT → Collabora → DOCX | + +Vorteil: **keine zusätzliche PDF- oder Excel-Bibliothek** im Projekt (und keine +Lizenzfrage, die wir uns damit einhandeln — mehrere verbreitete .NET-Bibliotheken für +XLSX und PDF sind für kommerzielle Nutzung nicht frei). + +Zu prüfen, bevor wir darauf bauen: +- Ist der Endpunkt in deiner Collabora-Installation erreichbar? Er muss in `coolwsd.xml` + für die IP des ClawdDotNet-Rechners freigegeben sein (`net`/`post_allow`-Allowlist). + Standardmäßig ist das eng gefasst. +- Der Pfad heißt je nach Version `/cool/convert-to/…` (neu) oder `/lool/convert-to/…` (alt). + +Falls der Endpunkt nicht freigegeben werden soll: Rückfallebene ist Markdown/CSV — für +den Alltag völlig ausreichend, PDF wäre dann ein späterer eigener Punkt. + +### 5.5 Freigabe-Links + +`POST /ocs/v2.php/apps/files_sharing/api/v1/shares` (Header `OCS-APIRequest: true`), +`shareType=3` = öffentlicher Link. Optional `password`, `expireDate`, `permissions=1` +(nur lesen). Die Antwort enthält die fertige URL. + +Zwei Hinweise: +- Manche Instanzen erzwingen Passwortschutz für öffentliche Links — dann muss das Tool ein + Passwort mitgeben und zurückliefern. +- Ein öffentlicher Link ist **irreversibel im Sinne von A2**: Einmal geteilt, kann er + weitergegeben worden sein, auch wenn man ihn danach löscht. Deshalb steht er unten in + der Staging-Tabelle auf `approve`. + +Innerhalb der eigenen Instanz ist die freundlichere Variante `shareType=0` (an einen +konkreten Nextcloud-Benutzer) — kein öffentlicher Link nötig, wenn du ohnehin ein Konto +hast. Das sollte der **Standard** sein, öffentlich die Ausnahme. + +### 5.6 Fallstricke + +- **Dateisperren (HTTP 423).** Wenn du eine Datei gerade in Collabora offen hast, kann ein + Upload auf dieselbe Datei scheitern. Das Tool muss 423 sauber melden statt kryptisch zu + scheitern — und beim Überschreiben eines Berichts lieber einen neuen Dateinamen mit + Zeitstempel vergeben. +- **Überschreiben ist nicht destruktiv**, solange die Versionierung aktiv ist (Nextcloud + legt automatisch eine Vorversion an). Das ist der Grund, warum `Nextcloud.upload` unten + auf `auto` steht, `FTP.upload` aber auf `approve`. +- **Größenbegrenzung.** Ein einfaches `PUT` reicht für Berichte problemlos; erst bei sehr + großen Dateien bräuchte es den Chunked-Upload (`/remote.php/dav/uploads/…`). Für den + angedachten Zweck (Berichte, Tabellen, PDFs) nicht nötig — und wenn doch, meldet der + Server einen klaren Fehler. +- **Quota.** Ein Agent, der stündlich Berichte ablegt, füllt das Konto. Ein Aufräum-Task + („Berichte älter als 90 Tage") gehört mittelfristig ins Taskboard. + +### 5.7 Tool-Zuschnitt + +``` +Tool: Nextcloud +Aktionen: upload | download | list | mkdir | move | delete + | share | unshare | convert (convert nur falls Collabora freigegeben) +``` + +Konfiguration je Agent: + +```json +"Nextcloud": { + "baseUrl": "https://cloud.example.org", + "username": "clawd-agents", + "appPassword": "…", // von ConfigSecrets geschützt + "rootPath": "/ClawdDotNet/Hermes", + "allowPublicShares": false, + "collaboraUrl": "https://collabora.example.org" +} +``` + +`appPassword` muss der Schlüsselliste in `ConfigSecrets` hinzugefügt werden — `password` +allein greift nicht, weil dort auf ganze Feldnamen verglichen wird. + +--- + +## 6 — Verzahnung mit Staging (A2) und Audit (A3) + +Vorschlag für die `StagingPolicy.DefaultRules`: + +| Aktion | Standard | Begründung | +|---|---|---| +| `RocketChat.send_message` (erlaubter Raum) | **auto** | Sonst ist Chat unbenutzbar — jede Antwort bräuchte einen Klick | +| `RocketChat.send_message` (Raum nicht in `allowedRooms`) | **deny** | Wird vom Tool selbst abgewiesen, gar nicht erst vorgelegt | +| `RocketChat.send_file` | **approve** | Dateiabfluss in einen Chatraum | +| `Nextcloud.upload`, `mkdir`, `move` | **auto** | Versioniert, im eigenen Ordner, umkehrbar | +| `Nextcloud.delete` | **approve** | wie `FileRW.delete` | +| `Nextcloud.share` (an Benutzer) | **auto** | bleibt innerhalb der Instanz | +| `Nextcloud.share` (öffentlicher Link) | **approve** | nicht zurückholbar | + +Der Unterschied zu `Telegram.send_message` (heute `approve`) ist Absicht: Telegram ist ein +Benachrichtigungskanal nach außen, Rocket.Chat ist der Arbeitsraum. Ein Arbeitsraum, in +dem jede Antwort eine Freigabe braucht, ist kein Arbeitsraum. Der Schutz sitzt hier an der +Raum-Allowlist statt an der Einzelfreigabe. + +Für das Audit-Log entstehen keine Sonderfälle — die Tool-Aufrufe laufen ohnehin durch. + +--- + +## 7 — Was dieses Konzept **nicht** vorsieht + +Damit der Zuschnitt klar ist: + +- Keine Rocket.Chat-**App** (Apps-Engine, TypeScript im Server) — wir bleiben Client. +- Keine Verwaltung von Rocket.Chat durch Agenten (Benutzer anlegen, Räume erstellen, + Rechte vergeben). Das ist Admin-Arbeit in der WinForms-Oberfläche. +- Keine Sprach-/Videofunktionen, keine Nextcloud Talk-Anbindung. +- Kein Ersatz für den WinForms-Chat — der bleibt und ist die unterste Rückfallebene. +- Keine Ende-zu-Ende-Verschlüsselung. Rocket.Chat kann das, aber verschlüsselte Räume sind + über die REST-API nicht lesbar. Agenten arbeiten in unverschlüsselten Räumen — das ist + eine bewusste Einschränkung, die du kennen solltest. + +--- + +## 8 — Vorschlag für den Schnitt + +| Phase | Inhalt | Ergebnis | +|---|---|---| +| **1** | `Nextcloud`-Tool: upload/download/list/mkdir/move/delete/share | Agent kann Berichte ablegen und einen Link liefern | +| **2** | `RocketChat`-Tool: senden, lesen, `rocketchat_poll`-Job, Raum-Allowlist, Erwähnungsfilter, Schleifendrossel | Gespräch mit Agenten über Rocket.Chat, Antwort per Prompt | +| **3** | Automatischer Rückweg (`ReplyTo` in `ToolJobResult`) | Antwort landet zuverlässig im richtigen Raum/Thread | +| **4** | `ChannelRouter` + `rocketchat_health` + Eskalation | Der Notfallkanal — Ausfall wird erkannt und umschifft | +| **5** | Collabora-Konvertierung (PDF/XLSX) | Berichte in Büroformaten | +| **6** *(optional)* | Realtime/DDP statt Polling | Antwortzeit ~1 s statt ~30 s | + +Nextcloud zuerst, weil es das kleinere, in sich abgeschlossene Stück ist und sofort Nutzen +bringt — und weil es sich unabhängig vom Ausgang der A5-Entscheidung lohnt. + +Phase 4 ist **kein Nice-to-have**: Ohne sie ist Rocket.Chat ein Einzelpunkt, dessen Ausfall +niemand meldet. Sie sollte nicht hinter Phase 5 rutschen. + +Zur Modell-Einstufung im Sinne der Roadmap: Phasen 1, 2 und 5 sind klar spezifizierbare +Tool-Arbeit (4.6-tauglich). Phase 3 und 4 fassen Engine bzw. Zustellwege an und sollten +mit vorheriger Festlegung der Invarianten und mit Tests gebaut werden. + +--- + +## 9 — Offene Punkte für die Diskussion + +1. **A5/Matrix** — ersetzt Rocket.Chat den Punkt, oder bleibt Matrix als Ziel bestehen? + (Abschnitt 4; das entscheidet, ob der `ChannelRouter` Pflicht oder Kür ist.) +2. **Nextcloud-Identität** — ein Dienstkonto mit Ordnern je Agent (mein Vorschlag) oder + je Agent ein eigener Nextcloud-Benutzer? +3. **Rückweg der Antwort** — reicht Phase 2 (Agent antwortet selbst) für den Anfang, oder + soll Phase 3 direkt mitgebaut werden? +4. ~~**Notfallkanal** — Telegram oder Mail?~~ **Entschieden: Telegram** (siehe 3.3). + Offen bleibt nur die Kleinigkeit, ob die Reihenfolge instanzweit gilt (mein Vorschlag) + oder pro Agent einstellbar sein soll. +5. **Collabora-Konvertierung** — ist der `convert-to`-Endpunkt für den ClawdDotNet-Rechner + freigebbar? Falls nein, bleibt es bei Markdown/CSV. +6. **Versionen** — welche Rocket.Chat- und welche Nextcloud-Version läuft bei dir? + Einzelne Endpunkte und Rollennamen sind versionsabhängig; das prüfe ich vor der + Umsetzung gegen deine Instanz statt gegen die Dokumentation. +7. **Öffentliche Links** — grundsätzlich erlauben (mit Freigabe) oder ganz sperren + (`allowPublicShares: false` als harte Voreinstellung)? diff --git a/docs/Staging-Konzept.md b/docs/Staging-Konzept.md new file mode 100644 index 0000000..26b36e8 --- /dev/null +++ b/docs/Staging-Konzept.md @@ -0,0 +1,108 @@ +# Staging-Freigabe für irreversible Aktionen + +Setzt A2 aus der [Roadmap](Roadmap.md) um (F-A1 + S4). Irreversible Aktionen werden +**gestaged statt ausgeführt**: Vorschlag → Review im Hauptfenster → Freigabe/Ablehnung. +Aufbauend auf dem Taskboard (A1, Fortsetzung nach Freigabe) und dem Audit-Log (A3, +Approval-Records). + +Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen **Vorschlag** +erzeugen, keine Ausführung. + +## Das Gate wird zum Durchsetzungspunkt (S4) + +Das bisher wirkungslose `PermissionGate` (es prüfte nur, ob ein Tool zugewiesen ist) +wird zur zentralen Stelle: Policy-Prüfung, Staging-Entscheidung und Audit-Hook an +**einer** Stelle (`AgentEngine.ExecuteToolCallAsync`), statt ad-hoc in jedem Tool. S4 +geht hier auf. + +## Policy: `auto | approve | deny` pro Tool/Aktion + +Je (Tool, Aktion) eine Entscheidung: + +- **`auto`** — läuft wie bisher. +- **`approve`** — wird gestaged; die Ausführung wartet auf eine menschliche Freigabe. +- **`deny`** — wird gar nicht erst vorgeschlagen, sondern abgelehnt. + +Auflösung vom Speziellen zum Allgemeinen: `Tool.Aktion` → `Tool` → Standard (`auto`). +Die „Aktion" ist das `action`-Argument des Aufrufs (die meisten Tools haben es). + +**Eingebaute Standardregeln** (nach Sichtung der Tools, überschreibbar per Konfiguration) +— genau die in der Roadmap genannten irreversiblen Aktionen: + +| Tool.Aktion | Standard | +|---|---| +| `Mail.send` | approve | +| `Telegram.send_message` | approve | +| `Database.insert`, `Database.upsert` | approve | +| `FileRW.delete` | approve | +| `FTP.upload`, `FTP.delete` | approve | +| alles andere | auto | + +Lesende Aktionen (`Mail.read_inbox`, `FileRW.read`, `Database.query`, …) bleiben `auto` — +Staging soll schützen, nicht lähmen. + +## Plan-Freeze + +Freigegeben wird ein **eingefrorener, konkreter Aufruf**: Tool, Aktion und die **exakten +Argumente** zum Zeitpunkt des Stagings. Ausgeführt wird genau das Eingefrorene (der +gespeicherte Argument-JSON), nie eine nachträglich veränderte Fassung. Jede Änderung +wäre eine neue Freigabe. + +Das ist auch der Grund, warum der Agent den Aufruf **nicht** nach der Freigabe erneut +formuliert (er könnte etwas anderes bauen) — der eingefrorene JSON wird direkt an das +Tool gegeben. + +## Fortsetzung nach Freigabe — kein pausierter Lauf + +Die tragende Architektur-Vorgabe (aus der Roadmap-Einstufung): **Kein pausierter, im +Speicher gehaltener Lauf.** Der Ablauf: + +1. **Vorschlag.** Der Agent ruft eine `approve`-Aktion auf. Das Gate führt sie nicht aus, + sondern legt einen **Pending**-Datensatz an (eingefrorener Aufruf) und gibt dem Agenten + „Zur Freigabe vorgelegt (#id)" als Tool-Ergebnis zurück. Der Lauf endet **regulär** — + der Agent schließt ab, nichts hängt im Speicher. +2. **Review.** Ein Mensch sieht die offenen Vorschläge im Hauptfenster und entscheidet. +3a. **Freigabe.** Der eingefrorene Aufruf wird **direkt** ausgeführt (standalone, nicht + über einen Chat-Lauf). Das Ergebnis wird festgehalten, und ein **Folge-Task** (A1) + weckt den Agenten: „Deine Aktion X wurde freigegeben und ausgeführt, Ergebnis: Y — + mach weiter." Der Scanner stellt ihn zu (`ChatAsync`, bestehender Kontext). +3b. **Ablehnung.** Ein Folge-Task weckt den Agenten mit der Ablehnung (samt Grund). + +Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser +Vereinfachung unnötig. + +## Approval-Records (A3) + +Jede Entscheidung — Freigabe wie Ablehnung — wird an zwei Stellen verankert: + +- Im **Staging-Datensatz** selbst: Status, `DecidedBy`, `DecidedAt`, Ergebnis-Verweis + bzw. Ablehnungsgrund. +- Als **Audit-Eintrag** (A3): Tool, Ausgang, `source: approval`, „Freigegeben von …" bzw. + „Abgelehnt von …". Die Herkunft stempelt auch hier das System, nicht der Agent. + +Der Vorschlag selbst wird beim Anlegen als Audit-Eintrag mit Status **`Staged`** notiert — +so ist die ganze Kette (Vorschlag → Entscheidung → Ausführung) im Log nachvollziehbar. + +## Nebenläufigkeit + +Zwei Reviewer dürfen nicht denselben Vorschlag doppelt freigeben. Der Übergang +`pending → approved/rejected` ist ein **atomares, bedingtes `UPDATE`** (dieselbe +Claim-Technik wie beim Taskboard): Genau einer gewinnt, der zweite Klick läuft ins Leere. +Erst nach gewonnenem Übergang wird der eingefrorene Aufruf ausgeführt. + +## Verdrahtung + +- `StagingGate` (Policy + Anlegen des Vorschlags) hängt optional an der Engine — ohne es + läuft alles wie bisher (`auto`). +- Der eingefrorene Aufruf wird über `IFrozenCallExecutor` (von der Engine implementiert) + ausgeführt: gültiger Tool-Kontext, aber ohne LLM-Schleife. +- `StagingService` (Freigabe/Ablehnung) nutzt Executor, Audit und das Taskboard für den + Folge-Task. Es ist die API, die die Review-Oberfläche aufruft. + +## Offen + +- **Review-Oberfläche** im Hauptfenster (Liste der offenen Vorschläge, Freigeben/Ablehnen) + — die Dienst-API steht bereit; die WinForms-Ansicht ist die verbleibende Integration. +- **Output-Scrubbing** greift auch hier auf den gespeicherten Argument-JSON, sobald es + steht (eigener Roadmap-Punkt). +- **Orders** (Handelsaufträge) reihen sich später als weitere `approve`-Aktionen ein. diff --git a/docs/Taskboard-Konzept.md b/docs/Taskboard-Konzept.md new file mode 100644 index 0000000..a8c8776 --- /dev/null +++ b/docs/Taskboard-Konzept.md @@ -0,0 +1,334 @@ +# Taskboard — Aufgaben statt Delay-Schleifen + +Setzt A1 aus der [Roadmap](Roadmap.md) um. Das Taskboard ist das Fundament, auf dem +Audit (A3), Staging (A2), Marktkalender (C1) und das Ergebnisregister (C7/C8) +aufsetzen. Es löst zugleich sechs Altpunkte auf einmal (F-A5 Queue, F-A4 Run-Historie, +B8 Rekursion, B6 `Task.Delay`-Überlauf, B7 Cron in Lokalzeit, T7 `RunAsync` vs. +`ChatAsync`). + +Aufbau analog zum [Memory-Konzept](Memory-Konzept.md): erst warum die vorhandenen +Mechanismen nicht reichen, dann Dateiformat, Wahrheitsaufteilung, Scanner-Verhalten, +Invarianten, Migration. + +## Das Problem + +Heute gibt es zwei getrennte, je für sich unzureichende Wege, einen Agenten Arbeit +tun zu lassen: + +- **Der Cron-Scheduler** ([`AgentScheduler`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs), + [`ToolJobScheduler`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs)) hängt starr + am Agenten: eine Cron-Zeile je Agent, ausgeführt über ein `Task.Delay` bis zum + nächsten Termin. Ein jährlicher Termin bedeutet ein `Task.Delay` über Monate (B6). + Cron läuft in Lokalzeit ohne explizite Zone (B7). Ob mit oder ohne Kontext gelaufen + wird, entscheidet ein implizites Flag (`UseChatContext`, T7). +- **Die `coordination/*.md`-Dateien** im SharedWorkspace sind die improvisierte + Antwort der Agenten darauf, dass es kein Aufgabenmodell gibt: `task_*`-, `status_*`- + und `broadcast`-Dateien, per Konvention beschrieben, ohne Schema, ohne Claiming, + ohne Zustandsübergänge. Zwei Agenten, die dieselbe Datei „übernehmen", tun das ohne + jede Absicherung. + +Delegation läuft heute über rekursives `send_message`/`spawn` — ein Agent ruft +synchron einen anderen, der wieder einen dritten (B8: Zyklengefahr, deshalb ein +`LoopGuard` als Notbremse). Es gibt keine Run-Historie am Auftrag und keine Queue. + +## Warum ein neues Subsystem, nicht der vorhandene State-Store + +Dieselbe Überlegung wie beim Gedächtnis: `IStateStore` ist eine Schlüssel-Wert-Tabelle +für kleine Marker. Ein Aufgabenmodell mit Status, Zuweisung, Abhängigkeiten und +Terminen darin abzulegen hieße, JSON in eine `Value`-Spalte zu schreiben — nicht +filterbar, nicht atomar claimbar, nicht auswertbar. + +Der Kern ist eine **atomare Anspruchsnahme** (Claim). Genau das kann ein Dateisystem +nicht verlässlich und der Schlüssel-Wert-Store nicht ausdrücken, eine SQL-Zeile mit +einem bedingten `UPDATE` aber sehr wohl. Deshalb eine eigene Tabelle auf dem +vorhandenen [`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs) (WAL, +`busy_timeout`, prozessweite Schreib-Warteschlange) — dieselbe Grundlage, die schon +Gedächtnis, Zustand und Verbrauch teilen. + +## Wahrheitsaufteilung — Datei ist Definition, DB ist Koordination + +Die eine Entscheidung, an der alles hängt: + +| Ebene | Wahrheit über | Wer schreibt | +|---|---|---| +| **Markdown-Datei** (Frontmatter + Rumpf) | die *Definition* der Aufgabe: Titel, Priorität, Assignee, Termin, Abnahme, Abhängigkeiten. Menschen- und agentenlesbar. | Mensch (Editor), Agent (`task_*`-Tool) | +| **SQLite-Tabelle `Tasks`** | den *Ausführungszustand*: Status, Claim, Lease, Last-Fired-Marker je Termin, Blocker-Auflösung. | ausschließlich das Taskboard selbst (Importer, Scanner, Tool) | + +**Regel:** Für die Definition ist die Datei die Wahrheit. Für jede +Ausführungsentscheidung ist die DB die Wahrheit. Weichen beide ab (Absturz zwischen +DB-Claim und Datei-Schreiben), gewinnt die DB, und die Datei wird bei der +Reconciliation nachgezogen. + +Warum nicht alles nur in die DB und die Datei als reine Projektion? Weil die Datei der +Bedienpunkt ist: Ein Mensch soll eine Aufgabe im Editor anlegen und ändern können, ein +Agent über sein Tool, und beides soll im SharedWorkspace sichtbar und versionierbar +bleiben. Warum nicht alles nur in Dateien? Weil das Claiming dort nicht atomar geht — +siehe oben. Die Aufteilung nimmt von beidem das Belastbare. + +Der **Importer** ist die Brücke: Er liest die Frontmatter-Definition und spiegelt sie +idempotent in die DB-Zeile (`UPSERT` auf `task_id`). Er läuft beim Start (alle Dateien), +nach jeder `task_*`-Änderung (die betroffene Datei) und optional per +`FileSystemWatcher` mit Debounce (~500 ms, wie bei A4), damit von Hand editierte +Dateien zeitnah einfließen. + +## Dateiformat + +Aufgaben liegen als eine Datei je Aufgabe unter `SharedWorkspace/tasks/`. Der +Dateiname ist beschreibend (`recherche-nvda-earnings.md`); die stabile Identität ist +die `id` im Frontmatter, nicht der Name — so überlebt eine Aufgabe das Umbenennen. + +```markdown +--- +id: t-8f3a2c # stabil, beim Anlegen vergeben; Wahrheit der Identität +title: NVDA Earnings recherchieren +status: todo # backlog | todo | in_progress | in_review | done | canceled | blocked +type: work # work | approval | human_input +priority: 3 # 1 (niedrig) .. 5 (hoch) +assignee: "@crawler" # @new | @ | @human +when: # optional; fehlt = einmalige Aufgabe, sofort fällig + kind: cron # at | every | cron + value: "0 7 * * 1-5" + tz: Europe/Berlin # PFLICHT, wenn when gesetzt ist — kein Termin ohne Zone +require_approval: false # true = gilt erst nach Review (in_review) als done +acceptance: | # Abnahmekriterien, gegen die das Ergebnis geprüft wird + Aktuelle Zahlen mit Datum und Quelle, in SharedWorkspace/data/nvda.json abgelegt. +blocked_by: [t-4b1e] # diese Aufgabe startet erst, wenn alle Blocker done sind +onlyWhenMarketOpen: false # C1: Termin nur auslösen, wenn der Markt offen ist +--- + +Freitext-Rumpf: Auftragsbeschreibung, Kontext, Verweise. Geht als Aufgabenstellung +an den Agenten. Kommentare (Ergebnisse, Kritik, Reopen) werden unten angehängt. +``` + +Feldregeln: + +- **`when.kind`**: `at` (einmaliger Zeitpunkt, ISO 8601), `every` (Intervall, z. B. + `30m`), `cron` (5-Felder-Ausdruck wie bisher). `tz` ist bei allen dreien Pflicht — + das ist die Antwort auf B7. Die vorhandene + [`CronExpression`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs) rechnet + heute zonen-blind in `DateTime.Now`; sie wird in einen zonen-bewussten Aufruf + gekapselt (nächsten Termin in `tz` bestimmen, dann in UTC vergleichen). Das + verzahnt sich mit R2 (`TimeProvider`) aus der [Teststrategie](Teststrategie.md). +- **`assignee`** ersetzt das `UseChatContext`-Flag durch eine explizite Angabe (T7): + - `@new` bzw. `@new:` → frischer Lauf ohne Historie (`AgentEngine.RunAsync`). + Bloßes `@new` ist nur eindeutig, wenn die Instanz genau **einen** Agenten hat; sonst + benennt `@new:` den Ziel-Agenten. Ist er nicht auflösbar, scheitert der + Dispatch mit klarer Meldung, statt einen falschen Agenten zu raten. + - `@` → bestehender Agent mit seinem Kontext (`AgentEngine.ChatAsync`) + - `@human` → wartet auf einen Menschen; kein Modell-Lauf +- **`type`**: `work` (Standard), `approval` und `human_input`. Bei den letzten beiden + ist ein Mensch der Assignee; seine Antwort **ist** das Ergebnis und Input für + Folgeaufgaben. Kein Sonderpfad — nur ein Assignee, der kein Modell ist. + +## Scanner-Verhalten + +Ein einziger Takt (~60 s) statt vieler langer `Task.Delay`. Damit kennt das System +keine Monats-Delays mehr (B6), und ein verpasster Takt ist ein verpasster Termin, kein +Zeitbombe. + +Je Takt: + +1. **Fällige Termine bestimmen.** Aus jeder aktiven Task-Zeile den nächsten Termin in + ihrer `tz` berechnen und gegen „jetzt" prüfen. Ein Termin ist durch einen + **Occurrence-Key** eindeutig: `task_id` + geplante Feuerzeit (bei `at`/`cron`/`every`). +2. **Claim vor Lauf (at-most-once).** Bevor gelaufen wird, wird der Occurrence-Key + atomar beansprucht: + + ```sql + UPDATE Tasks + SET claim_token = @token, claimed_at = @now, last_occurrence = @occ, status = 'in_progress' + WHERE id = @id + AND status IN ('todo','backlog') + AND (last_occurrence IS NULL OR last_occurrence < @occ) + AND (claim_token IS NULL OR claimed_at < @leaseCutoff); + ``` + + Genau eine Zeile betroffen = Anspruch gewonnen. Ein zweiter, gleichzeitiger Takt + findet die Bedingung nicht mehr erfüllt (Rowcount 0) und läuft **nicht** — so wird + ein doppelter Tick zu einem Lauf. +3. **Dispatch nach Assignee.** `@new` → `RunAsync`; `@` → `ChatAsync`; `@human` + → kein Lauf, Status bleibt/wird `in_review` bzw. `todo`, die UI zeigt die Aufgabe + als wartend. Der Rumpf (+ Abnahmekriterien) ist die Nachricht an den Agenten. +4. **Abschluss verbuchen.** Last-Fired-Marker setzen (`last_occurrence = @occ`), Status + fortschreiben, Claim lösen. Bei `require_approval` → `in_review` statt `done`. Der + Lauf wird am Task verknüpft (Grundlage für A3-Receipts und die Run-Historie F-A4). + +**Kein Retry-Sturm.** Der Marker wird auch bei einem **fehlgeschlagenen** Lauf gesetzt: +ein fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin. Wiederholung +ist eine bewusste Entscheidung (neuer Termin oder Reopen), kein Automatismus. Das +schützt vor einem Agenten, der bei jedem Takt erneut in denselben Fehler läuft und +Budget verbrennt. + +**Serialisierung mit den Chat-Läufen (B2).** Der Scanner ruft die Engine über +dieselben Einstiegspunkte wie WebView, ToolJob und AgentComm. `ChatAsync` ist bereits +je Agent über ein `SemaphoreSlim`-Gate serialisiert +([`AgentEngine`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs)) — der Scanner fügt +sich dort ein, statt einen zweiten, konkurrierenden Pfad in den geteilten +Konversationskontext aufzumachen. Der Claim ist eine **DB**-Grenze (welcher Termin wird +behandelt), das Agent-Gate eine **Kontext**-Grenze (kein verschränkter Nachrichtenstrom). +Beide werden gebraucht; keine ersetzt die andere. + +### Startup-Reconciliation + +Beim Start wird Soll (Frontmatter) gegen Ist (DB-Marker/Claims) abgeglichen, statt +verpasste Läufe still zu überspringen: + +- Alle Task-Dateien importieren (UPSERT), gelöschte Dateien in der DB als `archived` + markieren. +- **Stale Claims freigeben:** Ein Claim, dessen `claimed_at` älter ist als die + Lease-Dauer (ein während des Laufs abgestürzter Prozess), wird verworfen — die + Bedingung `claimed_at < @leaseCutoff` im Claim-`UPDATE` erledigt das ohnehin, die + Reconciliation stellt den Status zusätzlich von `in_progress` auf `todo` zurück. +- **Verpasste Termine erkennen:** Liegt der letzte planmäßige Termin nach dem + Last-Fired-Marker, ist ein Lauf ausgefallen. Er wird als solcher gemeldet (Log, später + A5), nicht heimlich verschluckt. Ob nachgeholt wird, ist Politik — Standard: einmal + nachholen, sonst würde ein über Nacht ausgeschalteter Rechner beim Start eine Welle + auslösen. + +## Die drei Invarianten + +Der Scanner-Kern ist in der Roadmap als der heikle Teil markiert (die Fehlerklasse, die +hier schon einmal schiefging). Er wird strikt gegen diese Invarianten gebaut und mit +Property-Tests (FsCheck, siehe Teststrategie) abgesichert: + +1. **Nie zwei Claims auf einen Task-Termin.** Garantiert durch das bedingte `UPDATE`: + Rowcount ≤ 1 pro Occurrence-Key. Test: N parallele Claims auf denselben Key → genau + einer gewinnt. +2. **Kein Dispatch bei offenem Blocker.** Eine Aufgabe mit unerfüllten `blocked_by` + ist nicht `todo`, sondern `blocked`, und die Claim-Bedingung (`status IN + ('todo','backlog')`) greift nicht. Test: Blocker offen → kein Lauf; letzter Blocker + `done` → genau ein Auto-Dispatch. +3. **Doppelter Tick = ein Lauf.** Zwei Takte im selben Fenster konkurrieren um denselben + Occurrence-Key; der Claim lässt nur einen durch. Test: zwei gleichzeitige + `ScanOnce` → ein Lauf, ein Marker. + +Diese drei sind keine Kür, sondern die Bedingung dafür, dass der Kern mit einem +schwächeren Modell umgesetzt werden darf. + +## Abhängigkeiten und Eskalation + +- **`blocked_by` mit Auto-Dispatch:** Wird eine Aufgabe `done`, sucht das Board alle + Aufgaben, deren `blocked_by` sie enthält. Sind für eine davon **alle** Blocker `done`, + wechselt sie `blocked → todo` und der Scanner nimmt sie beim nächsten Takt auf. +- **Blocker-Eskalation:** Meldet ein Agent einen Blocker (`task_update status=blocked` + mit Begründung), fällt die Aufgabe und der Zuständige (Lead/Benutzer) wird + benachrichtigt. Bis A5 (Matrix) steht, geht das über den vorhandenen Log-/UI-Weg. + +## Zusammenspiel mit Staging (A2) und Reopen + +- **`require_approval` / `in_review`:** Eine Aufgabe mit `require_approval: true` gilt + nach dem Lauf nicht als `done`, sondern als `in_review`. Das ist der natürliche + Andockpunkt für A2: Die Freigabe erzeugt einen Folge-Task, der den Agenten mit dem + eingefrorenen Aufruf weckt (so bleibt der Lauf regulär beendet, kein pausierter + In-Memory-Zustand — genau die Architektur-Vorgabe aus der Roadmap-Einstufung für A2). +- **Reopen/Feedback:** Ergebnis + Kritik gehen per `task_comment` an **denselben** + Agenten zurück (`ChatAsync` in dessen Kontext), statt eine neue Aufgabe von vorn zu + beginnen. Die Aufgabe kehrt nach `todo`/`in_progress` zurück, der Verlauf am Task + bleibt erhalten. + +## Agenten-Tool + +Ein Tool `Taskboard` im Muster von `MemoryTool` (eine Aktion je Aufruf), das über einen +neuen `ITaskRepository` auf dem `AgentToolContext` arbeitet (analog `IMemoryRepository? +Memory`): + +| Aktion | Zweck | +|---|---| +| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. | +| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. | +| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. | +| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. | + +Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt +ein Agent eine Aufgabe mit `assignee: @` an. Das ist strukturell zyklenfrei (B8) — +eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack. + +**Sicherheit:** Ein Task-Rumpf ist Prompt-Input für den Assignee. Fremdbestimmte Inhalte +(Ergebnisse anderer Tools, die in einen Task fließen) werden als Daten gerahmt, nicht als +Anweisung — dieselbe Linie wie K2. Irreversibles, das ein Task auslöst, läuft über A2. + +## Migration + +Die `coordination/*.md`-Dateien gehen im Taskboard auf. Die Altdateien haben kein Schema +(freies Markdown wie `# Task: …`, `## Status: ASSIGNED`), deshalb bewusst konservativ +(`CoordinationMigration`, beim Start ausgeführt): + +- **`task_*`-Dateien** → einmalig als `backlog`-Aufgaben übernommen: Titel aus der + `# Task:`-Überschrift (sonst erste Überschrift, sonst Dateiname), das ganze Markdown + als Rumpf, `assignee: @human` als sicherer Default, bis ein Mensch sie zuordnet. + `backlog` (nicht `todo`), damit der Scanner nichts unbesehen ausführt. +- **`status_*`, `broadcast`, Incident-Berichte, `*.json`-Artefakte** → keine Aufgaben; + bleiben unangetastet (später nach A5/Matrix bzw. verfallen als Altbestand). + +Idempotent durch **Verschieben statt Löschen**: eine übernommene `task_*`-Datei wandert +nach `coordination/migrated/` — die Historie bleibt, ein zweiter Start findet sie nicht +mehr. Vor der Migration greift die übliche Regel: frisches Backup. + +## Ein Takt für alles — Ablösung der Alt-Scheduler + +Der Scanner ist der **einzige** periodische Treiber. Die früheren `AgentScheduler` und +`ToolJobScheduler` (zwei `Task.Delay`-Schleifen mit B6/B7, ungetestet) sind **gelöscht** — +alles Periodische ist jetzt ein Task: + +- **Geplanter Agent-Lauf** (früher `scheduler`-Config) → ein normaler Task mit + `when: cron` und Assignee `@new:`. +- **Tool-Job-Poll** (früher `toolJobs`-Config, z. B. `telegram_poll`) → ein Task vom Typ + **`tool_job`** mit `tool_name`/`job_type`. Beim fälligen Termin tickt der Dispatcher den + `IToolJobProvider` und weckt den Zielagenten (Assignee) nur, wenn der Tick etwas meldet — + mit oder ohne Kontext, je nach `ToolJobResult`. + +Zwei Feinheiten, die dabei geradegezogen wurden: + +- **Wiederkehrende Tasks** (`cron`/`every`/`tool_job`) kehren nach dem Feuern auf `todo` + zurück statt auf `done` — sonst liefe ein Cron-Task nur ein einziges Mal. Der + Last-Fired-Marker verhindert weiterhin, dass **derselbe** Termin doppelt feuert. +- **`backlog` ist ein Halte-Status**: Der Scanner claimt nur `todo`. Eine Aufgabe in + `backlog` (frisch importiert, migriert, oder ein deaktivierter Poll) ruht, bis ein + Mensch sie auf `todo` setzt. + +Die Alt-Konfiguration (`scheduler`, `toolJobs`) wird beim Start einmalig und +nicht-destruktiv in Tasks migriert (`SchedulerTaskMigration`, stabile Ids +`sched-` / `tj--`). Ein manuelles „Jetzt ausführen" im Host läuft über +`TaskScanner.RunTaskNowAsync`. + +## Verzahnung + +- **C1 Marktkalender:** `onlyWhenMarketOpen` gehört ins Frontmatter, nicht in einen + eigenen Mechanismus — der Scanner überspringt einen Termin, wenn der Markt zu ist. +- **A3 Audit/Receipts:** Jeder Lauf wird am Task verknüpft; der Abschluss-Beleg (Schritte, + Tokens, Kosten) fällt daraus ab und macht C7 weitgehend zum Abfallprodukt. +- **F-A5/F-A4:** Das Board **ist** die Queue; die verknüpften Läufe **sind** die Historie. + +## Umsetzungsreihenfolge + +Bewusst so geschnitten, dass der heikle Kern zuletzt und gegen grüne Invarianten kommt: + +1. **Modelle + `ITaskRepository` + Schema + Frontmatter-Parser.** Reine, testbare + Bausteine. Der Parser wird eng gebaut (die Frontmatter ist ein kleiner, flacher + Satz aus Skalaren und kurzen Listen) — keine YAML-Bibliothek, passend zum + dependency-armen Stil des Projekts. 4.6-tauglich. +2. **`Taskboard`-Tool + Importer.** Datei ↔ DB, `task_*`-Aktionen. 4.6-tauglich. +3. **Scanner-Kern** (Claim, Dispatch, Reconciliation, Auto-Dispatch) — strikt gegen die + drei Invarianten, mit Property-Tests. Der in der Roadmap für Opus 5/Fable markierte + Teil; mit Opus 4.8 nur streng nach diesem Dokument und mit den Invarianten-Tests als + Netz. +4. **Migration** der `coordination/*.md`. + +Neue Subsysteme kommen mit Tests nach der [Teststrategie](Teststrategie.md); die +Invarianten-Tests sind bei Punkt 3 die Absicherung, kein Nice-to-have. + +## Offen + +- **Nachhol-Politik verpasster Termine** — umgesetzt als „höchstens einmal nachholen": + der Scanner nimmt den jüngsten verpassten Termin, nicht jeden einzelnen. Eine frische + Aufgabe holt zudem keinen Termin von **vor** ihrer Anlage nach. Ob das je Task + abschaltbar sein soll (`catchUp: true|false`), ist offen. +- **Marktkalender (C1)** — der Scanner fragt eine `IMarketCalendar` (derzeit Platzhalter + „immer offen"). C1 liefert später den echten Kalender; die Verzahnung steht. +- **DST-Randfall** — eine bei der Zeitumstellung nicht existierende Ortszeit + (Frühjahr, „02:30") wird derzeit übersprungen statt verschoben. Für die geplanten + Termine unkritisch; die Härtung gehört zur Scheduler-Nacharbeit (Teststrategie R4/R5). +- **Priorität als Reihenfolge** — bei mehreren fälligen Aufgaben desselben Agenten + bestimmt `priority` die Reihenfolge; ob strikt oder gewichtet, ist noch offen. +- **Aufräumen** — `done`/`canceled`-Aufgaben nach einer Frist archivieren, damit + `tasks/` nicht zuwächst (dieselbe Überlegung wie „Verfall" beim Gedächtnis). diff --git a/frm_main.cs b/frm_main.cs index 20d5980..ce210d8 100644 --- a/frm_main.cs +++ b/frm_main.cs @@ -4,6 +4,7 @@ using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Scheduling; +using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using ClawdDotNet.Models; using ClawdDotNet.Services; @@ -25,8 +26,24 @@ public partial class frm_main : Form private readonly ToolRegistry _toolRegistry; private readonly InstanceDirectoryManager _dirManager; private readonly AgentEngine? _agentEngine; - private readonly AgentScheduler? _agentScheduler; - private readonly ToolJobScheduler? _toolJobScheduler; + + /// + /// Der einzige periodische Treiber (A1). Ersetzt die früheren AgentScheduler/ + /// ToolJobScheduler — geplante Läufe und Tool-Job-Polls sind jetzt Tasks. + /// + private readonly ClawdDotNet.Core.Tasks.TaskScanner? _taskScanner; + + /// + /// A2: Freigabe-/Ablehnungs-Dienst. Die Review-Oberfläche (Liste offener Vorschläge, + /// Freigeben/Ablehnen) ruft auf — + /// die Anbindung ist die verbleibende Integration; die Dienst-API steht bereit. + /// + private readonly ClawdDotNet.Core.Staging.StagingService? _stagingService; + + // Der Lizenz-Teil ist mit der Deploymentcenter-Umstellung aus dieser Fassung + // verschwunden: Startprüfung, laufende Nachprüfung und Notausschalter sitzen jetzt + // in ClawdDotNet.App (LicenseGate, LicenseWatch) und in der Avalonia-Oberfläche. + // Diese Datei ist nur noch Vorlage für die Portierung — siehe ClawdDotNet.slnx. // ─── Services ─── private LiveLogViewerService? _logViewer; @@ -51,6 +68,9 @@ public partial class frm_main : Form private readonly Dictionary _jobLastRunTimes = new(); private bool _suppressAgentSelectionChanged; + /// Setzt die Beenden-Rückfrage außer Kraft (Lizenz-Sperre/-Deaktivierung). + private bool _forceClose; + public frm_main( SettingsManager settingsManager, InstanceConfig instanceConfig, @@ -60,8 +80,8 @@ public partial class frm_main : Form ToolRegistry toolRegistry, InstanceDirectoryManager dirManager, AgentEngine? agentEngine, - AgentScheduler? agentScheduler, - ToolJobScheduler? toolJobScheduler = null) + ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null, + ClawdDotNet.Core.Staging.StagingService? stagingService = null) { _settingsManager = settingsManager; _instanceConfig = instanceConfig; @@ -72,8 +92,8 @@ public partial class frm_main : Form _toolRegistry = toolRegistry; _dirManager = dirManager; _agentEngine = agentEngine; - _agentScheduler = agentScheduler; - _toolJobScheduler = toolJobScheduler; + _taskScanner = taskScanner; + _stagingService = stagingService; _instanceSettingsVm = new InstanceSettingsViewModel(_instanceConfig); InitializeComponent(); @@ -580,8 +600,6 @@ public partial class frm_main : Form foreach (var agent in _instanceConfig.Agents) { - var lastResult = _agentScheduler?.GetLastResult(agent.AgentId); - _agentListEntries.Add(new AgentListDisplayEntry { AgentId = agent.AgentId, @@ -590,7 +608,7 @@ public partial class frm_main : Form ToolCount = agent.Tools.Count, HasIdentity = !string.IsNullOrWhiteSpace(agent.Identity) ? "Ja" : "—", HasSoul = !string.IsNullOrWhiteSpace(agent.Soul) ? "Ja" : "—", - Status = lastResult?.Status.ToString() ?? "Bereit" + Status = _agentEngine?.IsRunning(agent.AgentId) == true ? "Läuft" : "Bereit" }); } } @@ -752,7 +770,7 @@ public partial class frm_main : Form private async void OnRunAgentNowClick(object? sender, EventArgs e) { - if (_agentScheduler is null || _agentEngine is null) + if (_agentEngine is null) { MessageBox.Show("Kein API-Key konfiguriert – Agenten können nicht ausgeführt werden.", "Hinweis", MessageBoxButtons.OK, MessageBoxIcon.Warning); @@ -778,8 +796,8 @@ public partial class frm_main : Form try { using var cts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout); - var result = await _agentScheduler.RunNowAsync( - agentConfig, "Manual execution triggered by user.", cts.Token); + var result = await _agentEngine.RunAsync( + agentConfig, "Manual execution triggered by user.", _instanceConfig.InstanceId, cts.Token); RefreshAgentList(); @@ -925,52 +943,14 @@ public partial class frm_main : Form dgv_jobhistory.DataSource = _jobHistoryEntries; RefreshJobHistoryGrid(); - // ─── ToolJobScheduler: OnJobTick → History + LastRun ─── - if (_toolJobScheduler is not null) - { - _toolJobScheduler.OnJobTick += OnToolJobTick; - } + // Tool-Job-Ticks laufen jetzt über den Scanner (Tasks); ihre Historie ergibt sich + // aus dem Audit-Log/den Receipts (A3), nicht mehr aus einem Scheduler-Event. EnsureBuiltInServices(); RefreshJobList(); RefreshServiceList(); } - private void OnToolJobTick(string agentId, string jobId, ToolJobResult result) - { - if (IsDisposed || !IsHandleCreated) return; - - BeginInvoke(() => - { - // Track LastRun timestamp - _jobLastRunTimes[jobId] = DateTime.Now; - - // Add to history - var agent = _instanceConfig.Agents.FirstOrDefault(a => a.AgentId == agentId); - var jobConfig = agent?.ToolJobs.FirstOrDefault(j => j.JobId == jobId); - - var historyEntry = new JobHistoryEntry - { - JobName = jobConfig?.JobTypeId ?? jobId, - Agent = agent?.DisplayName ?? agentId, - Time = DateTime.Now, - JobDescription = $"Tool Job: {jobConfig?.ToolName ?? "?"}", - Info = result.LogSummary ?? (result.ShouldWakeAgent ? "Wake Agent" : "No Action"), - Status = result.ShouldWakeAgent ? "Success" : (result.LogSummary?.Contains("Fehler") == true ? "Error" : "Success") - }; - - _jobHistoryService?.Add(historyEntry); - _jobHistoryEntries.Insert(0, historyEntry); - - // Trim UI list - while (_jobHistoryEntries.Count > 200) - _jobHistoryEntries.RemoveAt(_jobHistoryEntries.Count - 1); - - // Refresh job list to update LastRun column - RefreshJobList(); - }); - } - private void RefreshJobHistoryGrid() { _jobHistoryEntries.Clear(); @@ -1123,9 +1103,9 @@ public partial class frm_main : Form if (entry.JobType == "Tool Job") { - if (_toolJobScheduler is null) + if (_taskScanner is null) { - MessageBox.Show("Tool-Job-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBox.Show("Scanner ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } @@ -1136,11 +1116,13 @@ public partial class frm_main : Form btn_runJob.Text = "⏳ Läuft..."; try { - var result = await _toolJobScheduler.TriggerJobAsync(agent, jobConfig, CancellationToken.None); + // Poll-Jobs sind jetzt Tasks (Id: tj--); der Scanner führt sie aus. + var ran = await _taskScanner.RunTaskNowAsync( + $"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None); _jobLastRunTimes[jobConfig.JobId] = DateTime.Now; RefreshJobList(); - var status = result.ShouldWakeAgent ? "Wake → Agent gestartet" : (result.LogSummary ?? "Keine Aktion"); + var status = ran ? "Ausgeführt" : "Nicht bereit (läuft evtl. schon oder ist deaktiviert)"; _jobHistoryService?.Add(new JobHistoryEntry { @@ -1181,9 +1163,9 @@ public partial class frm_main : Form else { // Agent Wakeup Job - if (_agentScheduler is null || _agentEngine is null) + if (_agentEngine is null) { - MessageBox.Show("Agent-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); + MessageBox.Show("Agent-Engine ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } @@ -1193,7 +1175,7 @@ public partial class frm_main : Form btn_runJob.Text = "⏳ Läuft..."; try { - var result = await _agentScheduler.RunNowAsync(agent, taskMessage, CancellationToken.None); + var result = await _agentEngine.RunAsync(agent, taskMessage, _instanceConfig.InstanceId, CancellationToken.None); _jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now; RefreshJobList(); @@ -1244,8 +1226,6 @@ public partial class frm_main : Form // Agent Wakeup Jobs if (agent.Scheduler is not null) { - var lastResult = _agentScheduler?.GetLastResult(agent.AgentId); - string nextRun = "—"; if (!string.IsNullOrWhiteSpace(agent.Scheduler.Cron)) { @@ -1263,7 +1243,7 @@ public partial class frm_main : Form var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime) ? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss") - : (lastResult is not null ? "Letzter Lauf bekannt" : "—"); + : "—"; _jobEntries.Add(new JobDisplayEntry { @@ -1275,8 +1255,8 @@ public partial class frm_main : Form RunOnStart = agent.Scheduler.RunOnStart, NextRun = nextRun, LastRun = agentLastRun, - LastStatus = lastResult?.Status.ToString() ?? "—", - Status = _agentScheduler is not null ? "Aktiv" : "Inaktiv" + LastStatus = "—", + Status = _taskScanner is not null ? "Aktiv" : "Inaktiv" }); } @@ -1298,8 +1278,6 @@ public partial class frm_main : Form } } - var lastToolResult = _toolJobScheduler?.GetLastResult(toolJob.JobId); - var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime) ? lastTime.ToString("yyyy-MM-dd HH:mm:ss") : "—"; @@ -1316,7 +1294,7 @@ public partial class frm_main : Form RunOnStart = toolJob.RunOnStart, NextRun = nextRun, LastRun = toolLastRun, - LastStatus = lastToolResult?.LogSummary ?? "—", + LastStatus = "—", Status = toolJob.Enabled ? "Aktiv" : "Deaktiviert" }); } @@ -1516,7 +1494,8 @@ public partial class frm_main : Form private void OnFormClosing(object? sender, FormClosingEventArgs e) { - if (e.CloseReason == CloseReason.UserClosing) + // Erzwungenes Schließen (Lizenz-Sperre/-Deaktivierung) überspringt die Rückfrage. + if (e.CloseReason == CloseReason.UserClosing && !_forceClose) { var result = MessageBox.Show( "ClawdDotNet ist für den 24/7-Betrieb ausgelegt.\n\n" + @@ -1568,17 +1547,20 @@ public partial class frm_main : Form private void btn_openAgentFolder_Click(object sender, EventArgs e) { - Process.Start("explorer.exe", _instancePath + "\\Agents"); + SystemShell.OpenFolder(Path.Combine(_instancePath, "Agents")); } private void btn_showInstanceFolder_Click(object sender, EventArgs e) { - Process.Start("explorer.exe", _instancePath); + SystemShell.OpenFolder(_instancePath); } private void btn_ShowLogFolder2_Click(object sender, EventArgs e) { - Process.Start("explorer.exe", Environment.CurrentDirectory + "/Logs"); + // _logDirectory statt CurrentDirectory + "/Logs": Das Arbeitsverzeichnis ist + // nicht zwingend das Programmverzeichnis, und die Instanz kann ein eigenes + // Log-Ziel haben — geöffnet werden soll der Ordner, in den auch geschrieben wird. + SystemShell.OpenFolder(Path.GetFullPath(_logDirectory)); } private void dgv_Jobs_CellContentClick(object sender, DataGridViewCellEventArgs e) diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs new file mode 100644 index 0000000..4dbd6fa --- /dev/null +++ b/src/ClawdDotNet.App/AppHost.cs @@ -0,0 +1,485 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Api; +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Logging; +using ClawdDotNet.Core.Memory; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Deploymentcenter; +using ClawdDotNet.Core.Deploymentcenter.Watchdog; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.AgentComm; +using ClawdDotNet.Tools.AgentEditor; +using ClawdDotNet.Tools.AgentSpawn; +using ClawdDotNet.Tools.Database; +using ClawdDotNet.Tools.DirectAPI; +using ClawdDotNet.Tools.FileRW; +using ClawdDotNet.Tools.FTP; +using ClawdDotNet.Tools.Mail; +using ClawdDotNet.Tools.SocialMediaManager; +using ClawdDotNet.Tools.Telegram; +using ClawdDotNet.Tools.TelegramClient; +using ClawdDotNet.Tools.WebFetch; +using ClawdDotNet.Tools.WebMonitor; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.App; + +/// +/// Baut alles auf, was ClawdDotNet zum Laufen braucht — ohne eine einzige Zeile +/// Oberflächencode. +/// +/// Vorher lag das in Program.cs der WinForms-Anwendung: 350 Zeilen zwischen +/// ApplicationConfiguration.Initialize() und Application.Run(form). Die +/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster +/// überhaupt entstand. Sie war nur nirgends festgehalten. +/// +/// Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung +/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl, +/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier +/// festgeschrieben zu sein. +/// +public sealed class AppHost : IAsyncDisposable +{ + private readonly List> _shutdown = []; + + public required SettingsManager Settings { get; init; } + public required InstanceDirectoryManager Directories { get; init; } + public required InstanceConfig Instance { get; init; } + public required string InstancePath { get; init; } + public required string LogDirectory { get; init; } + public required ILoggerFactory LoggerFactory { get; init; } + public required ToolRegistry Tools { get; init; } + + /// Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten. + public AgentEngine? Engine { get; private init; } + + public TaskScanner? Scanner { get; private init; } + public StagingService? Staging { get; private init; } + public SqliteUsageRepository? Usage { get; private init; } + public TelegramClientManager? TelegramClient { get; private init; } + public OpenRouterStatusService? Status { get; private init; } + + /// + /// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates). + /// Null, wenn Adresse oder Token fehlen. + /// + public DeploymentcenterService? Deploymentcenter { get; private set; } + + /// + /// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der + /// Leerlauf, damit Aufrufer nicht auf null prüfen müssen. + /// + public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance; + + /// + /// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch + /// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start. + /// + public LicenseGate? License { get; private set; } + + /// + /// Die laufende Nachprüfung. Der Aufrufer hängt sich an + /// und beendet die Anwendung, wenn es feuert. + /// + public LicenseWatch? LicenseWatch { get; private set; } + + /// Was der Aufrufer beim Start erfragen muss. + public sealed class Callbacks + { + /// + /// Wählt die Instanz. Gibt null zurück, wenn der Nutzer abbricht. + /// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen. + /// + public required Func> SelectInstance { get; init; } + + /// Wie der Lizenz-Torwächter mit dem Benutzer spricht. + public required ILicensePrompt License { get; init; } + + /// + /// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus — + /// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen + /// Dienst dauerhaft blockieren würde. + /// + public Func>? TelegramLogin { get; init; } + + public Func>? Telegram2FA { get; init; } + } + + /// + /// Ergebnis des Aufbaus. ist null, wenn der Nutzer abgebrochen hat + /// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung + /// nachzureichen: Die hat der Torwächter schon gezeigt. + /// + public readonly record struct StartupResult(AppHost? Host, string? Error); + + public static async Task StartAsync(Callbacks callbacks, CancellationToken ct = default) + { + // ─── 1. Anwendungseinstellungen ─── + var settings = new SettingsManager(); + settings.Load(); + + // ─── 2. Instanz wählen ─── + var directories = new InstanceDirectoryManager( + Path.GetFullPath(settings.AppSettings.InstancesDirectory)); + + var instancePath = await callbacks.SelectInstance(directories); + if (string.IsNullOrWhiteSpace(instancePath)) + return new StartupResult(null, null); // Abbruch, keine Meldung nötig + + InstanceConfig instance; + try + { + instance = directories.LoadInstanceConfig(instancePath); + } + catch (Exception ex) + { + return new StartupResult(null, + $"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}"); + } + + // ─── 3. Protokollierung ─── + var logDirectory = Path.GetFullPath( + !string.IsNullOrWhiteSpace(instance.LogDirectory) + ? instance.LogDirectory + : settings.AppSettings.LogDirectory); + + var minLevel = Enum.TryParse( + settings.AppSettings.MinimumLogLevel, true, out var parsed) + ? parsed + : Core.Logging.LogLevel.Info; + + var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel); + var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup"); + + logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})", + instance.InstanceName, instance.InstanceId); + logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath); + logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath); + + // ─── 4. Lizenz ─── + var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"), + callbacks.License); + + if (!await license.RunStartupCheckAsync(ct)) + { + logger.LogWarning("Start abgebrochen: keine gültige Lizenz."); + loggerFactory.Dispose(); + return new StartupResult(null, null); + } + + // ─── 5. Werkzeuge ─── + var tools = RegisterTools(); + + TelegramClientManager? telegram = null; + if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null) + { + telegram = new TelegramClientManager(instance, instancePath, + loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient")); + + tools.Register(new TelegramClientTool(telegram)); + logger.LogInformation("TelegramClient-Tool registriert"); + } + else if (instance.TelegramClient is not null) + { + logger.LogInformation( + "TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen."); + } + + var host = BuildCore(settings, directories, instance, instancePath, + logDirectory, loggerFactory, tools, telegram, logger); + + // Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK. + host.License = license; + + if (license.IsEnforcementConfigured) + { + host.LicenseWatch = new LicenseWatch( + license, loggerFactory.CreateLogger("ClawdDotNet.License")); + + host.LicenseWatch.Start(); + host._shutdown.Add(host.LicenseWatch.DisposeAsync); + } + + await host.ConnectTelegramAsync(callbacks, logger); + await host.StartDeploymentcenterAsync(loggerFactory, ct); + + return new StartupResult(host, null); + } + + // ─── Aufbau ─── + + private static ToolRegistry RegisterTools() + { + var registry = new ToolRegistry(); + + registry.Register(new FileRWTool()); + registry.Register(new TelegramTool()); + registry.Register(new MailTool()); + registry.Register(new DatabaseTool()); + registry.Register(new FTPTool()); + registry.Register(new DirectApiTool()); + registry.Register(new WebFetchTool()); + registry.Register(new WebMonitorTool()); + registry.Register(new AgentCommTool()); + registry.Register(new SocialMediaManagerTool()); + registry.Register(new AgentSpawnTool()); + registry.Register(new AgentEditorTool()); + registry.Register(new Tools.Memory.MemoryTool()); + registry.Register(new Tools.Taskboard.TaskboardTool()); + + return registry; + } + + private static AppHost BuildCore( + SettingsManager settings, + InstanceDirectoryManager directories, + InstanceConfig instance, + string instancePath, + string logDirectory, + ILoggerFactory loggerFactory, + ToolRegistry tools, + TelegramClientManager? telegram, + ILogger logger) + { + if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey)) + { + logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert"); + + return new AppHost + { + Settings = settings, + Directories = directories, + Instance = instance, + InstancePath = instancePath, + LogDirectory = logDirectory, + LoggerFactory = loggerFactory, + Tools = tools, + TelegramClient = telegram + }; + } + + var openRouter = new OpenRouterClient(instance.OpenRouterApiKey, + loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient")); + + // Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich. + var storage = new SqliteStorage(Path.Combine(instancePath, "state.db")); + var stateStore = new SqliteStateStore(storage); + var memory = new SqliteMemoryRepository(storage); + var taskRepository = new SqliteTaskRepository(storage); + var audit = new SqliteAuditRepository(storage); + var stagingRepository = new SqliteStagingRepository(storage); + var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository); + var usage = new SqliteUsageRepository(storage); + + // Preise fürs Budget: Ohne sie greift nur die Token-Grenze. + var pricing = new ModelPricingCatalog(); + _ = Task.Run(async () => + { + try { pricing.Load(await openRouter.GetAvailableModelsAsync()); } + catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ } + }); + + var engine = new AgentEngine( + openRouter, tools, new PermissionGate(), stateStore, loggerFactory, + memory, usage, pricing, taskRepository, audit, stagingGate) + { + InstanceBudget = instance.Budget + }; + + engine.SetAgentConfigProvider( + () => instance.Agents, + instance.InstanceId, + agentId => + { + var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId); + return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir; + }); + + engine.LoadPersistedChats(); + + var (scanner, staging) = BuildTaskboard( + instance, taskRepository, engine, tools, stateStore, audit, + stagingRepository, loggerFactory, logger); + + var host = new AppHost + { + Settings = settings, + Directories = directories, + Instance = instance, + InstancePath = instancePath, + LogDirectory = logDirectory, + LoggerFactory = loggerFactory, + Tools = tools, + Engine = engine, + Scanner = scanner, + Staging = staging, + Usage = usage, + TelegramClient = telegram, + Status = new OpenRouterStatusService(instance.OpenRouterApiKey) + }; + + host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; }); + + logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen"); + + return host; + } + + /// + /// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) — + /// geplante Agentenläufe wie Tool-Job-Polls sind Tasks. + /// + private static (TaskScanner?, StagingService?) BuildTaskboard( + InstanceConfig instance, + SqliteTaskRepository taskRepository, + AgentEngine engine, + ToolRegistry tools, + SqliteStateStore stateStore, + SqliteAuditRepository audit, + SqliteStagingRepository stagingRepository, + ILoggerFactory loggerFactory, + ILogger logger) + { + var sharedWorkspace = instance.Agents + .Select(a => a.SharedWorkspacePath) + .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)); + + if (string.IsNullOrWhiteSpace(sharedWorkspace)) + return (null, null); + + var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks")); + + var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit); + + var dispatcher = new EngineTaskDispatcher( + engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory); + + var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory); + + // Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten. + _ = Task.Run(async () => + { + try + { + var reset = await taskRepository.ReleaseStaleClaimsAsync( + DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None); + + var coordination = new CoordinationMigration( + board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory); + var migratedCoordination = await coordination.RunAsync(CancellationToken.None); + + var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory); + var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None); + + var imported = await board.ImportAllAsync(CancellationToken.None); + + logger.LogInformation( + "Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + " + + "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt", + imported, migratedCoordination, migratedScheduler, reset); + + scanner.Start(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen"); + } + }); + + return (scanner, staging); + } + + private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger) + { + if (TelegramClient is null || callbacks.TelegramLogin is null) return; + + TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt); + + if (callbacks.Telegram2FA is not null) + TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA(); + + try + { + await TelegramClient.ConnectAsync(CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Telegram: Login fehlgeschlagen"); + } + } + + /// + /// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und + /// die einmalige Update-Prüfung. + /// + /// Jede laufende Instanz meldet sich als eigener Monitor — der Server führt + /// sie über source + instance. Stürzt eine von mehreren ab, fällt + /// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm. + /// + private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct) + { + Deploymentcenter = DeploymentcenterService.TryCreate( + Settings.AppSettings, AppVersion, loggerFactory); + + if (Deploymentcenter is null) + return; + + _shutdown.Add(Deploymentcenter.DisposeAsync); + + var health = new InstanceHealthProvider( + Instance.InstanceName, + agentsEnabled: Engine is not null, + Instance.Budget, + Usage, + () => Instance.Agents.Count, + () => Engine?.RunningChatCount ?? 0, + // „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der + // Startabgleichung los, der erste Heartbeat geht sofort raus. + schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped); + + await Deploymentcenter.StartWatchdogAsync( + Instance, health, + saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance), + ct); + + // Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten. + _ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None); + } + + /// + /// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat, + /// Fehlermeldungen, Versionsvergleich. Kommt aus <Version> in + /// Directory.Build.props und wird zur Übersetzungszeit eingebettet + /// (Deploymentcenter.BuildInfo.targets) — zusammen mit Commit und Build-Datum. + /// + /// Nicht zu verwechseln mit ClawdDotNet.Core.BuildInfo.Build: das ist + /// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe. + /// + public static string AppVersion => ReleaseInfo.Version; + + /// Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen. + public static string BuildSummary => ReleaseInfo.Summary; + + public async ValueTask DisposeAsync() + { + foreach (var step in _shutdown) + { + try { await step(); } + catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ } + } + + if (Status is not null) await Status.DisposeAsync(); + if (Scanner is not null) await Scanner.DisposeAsync(); + if (TelegramClient is not null) await TelegramClient.DisposeAsync(); + + LoggerFactory.Dispose(); + } +} diff --git a/src/ClawdDotNet.App/ClawdDotNet.App.csproj b/src/ClawdDotNet.App/ClawdDotNet.App.csproj new file mode 100644 index 0000000..1c2478a --- /dev/null +++ b/src/ClawdDotNet.App/ClawdDotNet.App.csproj @@ -0,0 +1,49 @@ + + + + net10.0 + enable + enable + ClawdDotNet.App + + + ReleaseInfo + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Models/AgentListEntry.cs b/src/ClawdDotNet.App/Models/AgentListEntry.cs similarity index 95% rename from Models/AgentListEntry.cs rename to src/ClawdDotNet.App/Models/AgentListEntry.cs index a179640..b510ce0 100644 --- a/Models/AgentListEntry.cs +++ b/src/ClawdDotNet.App/Models/AgentListEntry.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ClawdDotNet.Models; +namespace ClawdDotNet.App.Models; /// /// Eintrag in der AgentList.json – Basisinformationen zu einem Agenten. diff --git a/Models/InstanceInfo.cs b/src/ClawdDotNet.App/Models/InstanceInfo.cs similarity index 91% rename from Models/InstanceInfo.cs rename to src/ClawdDotNet.App/Models/InstanceInfo.cs index 2290c15..c633427 100644 --- a/Models/InstanceInfo.cs +++ b/src/ClawdDotNet.App/Models/InstanceInfo.cs @@ -1,4 +1,4 @@ -namespace ClawdDotNet.Models; +namespace ClawdDotNet.App.Models; /// /// Zusammenfassung einer Instanz für die Anzeige im InstanceManager. diff --git a/Models/JobHistoryEntry.cs b/src/ClawdDotNet.App/Models/JobHistoryEntry.cs similarity index 94% rename from Models/JobHistoryEntry.cs rename to src/ClawdDotNet.App/Models/JobHistoryEntry.cs index 7678a23..4b026e1 100644 --- a/Models/JobHistoryEntry.cs +++ b/src/ClawdDotNet.App/Models/JobHistoryEntry.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ClawdDotNet.Models; +namespace ClawdDotNet.App.Models; public sealed class JobHistoryEntry { diff --git a/Models/TokenUsageRecord.cs b/src/ClawdDotNet.App/Models/TokenUsageRecord.cs similarity index 97% rename from Models/TokenUsageRecord.cs rename to src/ClawdDotNet.App/Models/TokenUsageRecord.cs index 9cfabdc..ace0d01 100644 --- a/Models/TokenUsageRecord.cs +++ b/src/ClawdDotNet.App/Models/TokenUsageRecord.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ClawdDotNet.Models; +namespace ClawdDotNet.App.Models; public sealed class TokenUsageRecord { diff --git a/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs new file mode 100644 index 0000000..b4c9a60 --- /dev/null +++ b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs @@ -0,0 +1,566 @@ +using System.ComponentModel; +using System.Text.Json; + +namespace ClawdDotNet.Models; + +static class ConfigHelper +{ + public static string GetString(Dictionary config, string key, string fallback = "") + { + var val = config.GetValueOrDefault(key); + return val switch + { + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? fallback, + JsonElement je => je.ToString(), + string s => s, + null => fallback, + _ => val.ToString() ?? fallback + }; + } + + public static int GetInt(Dictionary config, string key, int fallback = 0) + { + var val = config.GetValueOrDefault(key); + return val switch + { + JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(), + JsonElement je => int.TryParse(je.ToString(), out var r) ? r : fallback, + int i => i, + _ => int.TryParse(val?.ToString(), out var r) ? r : fallback + }; + } + + public static bool GetBool(Dictionary config, string key, bool fallback = false) + { + var val = config.GetValueOrDefault(key); + return val switch + { + JsonElement je when je.ValueKind is JsonValueKind.True => true, + JsonElement je when je.ValueKind is JsonValueKind.False => false, + JsonElement je => bool.TryParse(je.ToString(), out var r) ? r : fallback, + bool b => b, + _ => bool.TryParse(val?.ToString(), out var r) ? r : fallback + }; + } + + public static string GetStringArray(Dictionary config, string key) + { + var val = config.GetValueOrDefault(key); + return val switch + { + JsonElement je when je.ValueKind == JsonValueKind.Array => + string.Join(",", je.EnumerateArray().Select(e => e.GetString())), + object[] arr => string.Join(",", arr), + string s => s, + _ => "" + }; + } +} + +public enum FileRWAccessLevel +{ + Denied, + Read, + ReadWrite, + Admin +} + +public sealed class FileRWToolSettings +{ + [Category("Persönlicher Workspace")] + [DisplayName("Erlaubte Endungen")] + [Description("Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)")] + public string PersonalAllowedExtensions { get; set; } = ".txt,.json,.md,.html,.js,.css"; + + [Category("Shared Workspace")] + [DisplayName("Zugriffslevel")] + [Description("Legt fest, welche Operationen im SharedWorkspace erlaubt sind")] + public FileRWAccessLevel SharedAccessLevel { get; set; } = FileRWAccessLevel.Denied; + + [Category("Shared Workspace")] + [DisplayName("Erlaubte Endungen")] + [Description("Dateiendungen für den geteilten Workspace")] + public string SharedAllowedExtensions { get; set; } = ".txt,.json,.md"; + + [Category("Shared Workspace – Schutz")] + [DisplayName("Geschützte Pfade")] + [Description("Komma-getrennte Pfade im SharedWorkspace die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.")] + public string ProtectedPaths { get; set; } = "stocks/"; + + public Dictionary ToConfig() => new() + { + ["personalAllowedExtensions"] = PersonalAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + ["sharedAccessLevel"] = SharedAccessLevel.ToString(), + ["sharedAllowedExtensions"] = SharedAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + ["protectedPaths"] = ProtectedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }; + + public static FileRWToolSettings FromConfig(Dictionary config) => new() + { + PersonalAllowedExtensions = ConfigHelper.GetStringArray(config, "personalAllowedExtensions") is { Length: > 0 } s1 + ? s1 : (ConfigHelper.GetStringArray(config, "allowedExtensions") is { Length: > 0 } sOld ? sOld : ".txt,.json,.md,.html,.js,.css"), + + SharedAccessLevel = Enum.TryParse(ConfigHelper.GetString(config, "sharedAccessLevel"), true, out var level) + ? level : FileRWAccessLevel.Denied, + + SharedAllowedExtensions = ConfigHelper.GetStringArray(config, "sharedAllowedExtensions") is { Length: > 0 } s2 + ? s2 : ".txt,.json,.md", + + ProtectedPaths = ConfigHelper.GetStringArray(config, "protectedPaths") is { Length: > 0 } s3 + ? s3 : "stocks/" + }; +} + +public sealed class MailToolSettings +{ + [Category("Mail - Konto")] + [DisplayName("Benutzername")] + public string Username { get; set; } = ""; + + [Category("Mail - Konto")] + [DisplayName("Passwort")] + [PasswordPropertyText(true)] + public string Password { get; set; } = ""; + + [Category("Mail - IMAP")] + [DisplayName("IMAP-Host")] + public string ImapHost { get; set; } = ""; + + [Category("Mail - IMAP")] + [DisplayName("IMAP-Port")] + public int ImapPort { get; set; } = 993; + + [Category("Mail - SMTP")] + [DisplayName("SMTP-Host")] + public string SmtpHost { get; set; } = ""; + + [Category("Mail - SMTP")] + [DisplayName("SMTP-Port")] + public int SmtpPort { get; set; } = 587; + + [Category("Mail - Sicherheit")] + [DisplayName("Erlaubte Empfänger")] + [Description("Komma-getrennte Liste erlaubter E-Mail-Adressen")] + public string AllowedRecipients { get; set; } = ""; + + public Dictionary ToConfig() => new() + { + ["username"] = Username, + ["password"] = Password, + ["imapHost"] = ImapHost, + ["imapPort"] = ImapPort, + ["smtpHost"] = SmtpHost, + ["smtpPort"] = SmtpPort, + ["allowedRecipients"] = AllowedRecipients.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }; + + public static MailToolSettings FromConfig(Dictionary config) => new() + { + Username = ConfigHelper.GetString(config, "username"), + Password = ConfigHelper.GetString(config, "password"), + ImapHost = ConfigHelper.GetString(config, "imapHost"), + ImapPort = ConfigHelper.GetInt(config, "imapPort", 993), + SmtpHost = ConfigHelper.GetString(config, "smtpHost"), + SmtpPort = ConfigHelper.GetInt(config, "smtpPort", 587), + AllowedRecipients = ConfigHelper.GetStringArray(config, "allowedRecipients") + }; +} + +public enum DatabaseType +{ + MySql, + Postgres, + MsSql, + MongoDb +} + +public enum DatabaseAccessLevel +{ + [Description("Nur Lesen (SELECT/find)")] + ReadOnly, + [Description("Lesen und Schreiben (INSERT/UPDATE/DELETE)")] + ReadWrite, + [Description("Vollzugriff (Admin/Schema-Änderungen)")] + Admin +} + +public sealed class DatabaseToolSettings +{ + [Category("Datenbank")] + [DisplayName("Typ")] + [Description("Der zu verwendende Datenbanktyp")] + public DatabaseType Type { get; set; } = DatabaseType.MySql; + + [Category("Datenbank")] + [DisplayName("Connection-String")] + public string ConnectionString { get; set; } = ""; + + [Category("Datenbank")] + [DisplayName("Zugriffsebene")] + [Description("Legt fest, welche Operationen der Agent ausführen darf")] + public DatabaseAccessLevel AccessLevel { get; set; } = DatabaseAccessLevel.ReadOnly; + + [Category("Datenbank - Sicherheit")] + [DisplayName("Erlaubte Tabellen")] + [Description("Komma-getrennte Liste erlaubter Tabellen/Collections")] + public string AllowedTables { get; set; } = ""; + + public Dictionary ToConfig() => new() + { + ["type"] = Type.ToString().ToLowerInvariant(), + ["connectionString"] = ConnectionString, + ["accessLevel"] = AccessLevel.ToString(), + ["allowedTables"] = AllowedTables.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }; + + public static DatabaseToolSettings FromConfig(Dictionary config) => new() + { + Type = Enum.TryParse(config.GetValueOrDefault("type")?.ToString(), true, out var result) ? result : DatabaseType.MySql, + ConnectionString = config.GetValueOrDefault("connectionString")?.ToString() ?? "", + AccessLevel = Enum.TryParse(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level) + ? level + : (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly), + AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr + ? string.Join(",", arr) + : config.GetValueOrDefault("allowedTables")?.ToString() ?? "" + }; +} + +public sealed class FTPToolSettings +{ + [Category("FTP Server")] + [DisplayName("Host")] + public string Host { get; set; } = ""; + + [Category("FTP Server")] + [DisplayName("Port")] + public int Port { get; set; } = 21; + + [Category("FTP Server")] + [DisplayName("Benutzername")] + public string Username { get; set; } = ""; + + [Category("FTP Server")] + [DisplayName("Passwort")] + [PasswordPropertyText(true)] + public string Password { get; set; } = ""; + + [Category("FTP Lokal")] + [DisplayName("Root-Pfad")] + [Description("Basisverzeichnis für Dateiübertragungen")] + public string RootPath { get; set; } = "./data/"; + + public Dictionary ToConfig() => new() + { + ["host"] = Host, + ["port"] = Port, + ["username"] = Username, + ["password"] = Password, + ["rootPath"] = RootPath + }; + + public static FTPToolSettings FromConfig(Dictionary config) => new() + { + Host = ConfigHelper.GetString(config, "host"), + Port = ConfigHelper.GetInt(config, "port", 21), + Username = ConfigHelper.GetString(config, "username"), + Password = ConfigHelper.GetString(config, "password"), + RootPath = ConfigHelper.GetString(config, "rootPath", "./data/") + }; +} + +public sealed class TelegramToolSettings +{ + [Category("Telegram")] + [DisplayName("Bot-Token")] + [PasswordPropertyText(true)] + public string BotToken { get; set; } = ""; + + [Category("Telegram")] + [DisplayName("Standard Chat-ID")] + [Description("Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.")] + public string DefaultChatId { get; set; } = ""; + + [Category("Telegram - Sicherheit")] + [DisplayName("Erlaubte Chat-IDs")] + [Description("Komma-getrennte Liste erlaubter Chat-IDs")] + public string AllowedChatIds { get; set; } = ""; + + public Dictionary ToConfig() => new() + { + ["botToken"] = BotToken, + ["defaultChatId"] = DefaultChatId, + ["allowedChatIds"] = AllowedChatIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }; + + public static TelegramToolSettings FromConfig(Dictionary config) => new() + { + BotToken = ConfigHelper.GetString(config, "botToken"), + DefaultChatId = ConfigHelper.GetString(config, "defaultChatId"), + AllowedChatIds = ConfigHelper.GetStringArray(config, "allowedChatIds") + }; +} + +public sealed class DirectAPIToolSettings +{ + [Category("DirectAPI")] + [DisplayName("Standard-Provider")] + public string DefaultProvider { get; set; } = "twelvedata"; + + [Category("DirectAPI")] + [DisplayName("Cache TTL (Sekunden)")] + public int CacheTtlSeconds { get; set; } = 60; + + [Category("DirectAPI - API Keys")] + [DisplayName("Twelve Data Key")] + [PasswordPropertyText(true)] + public string TwelveDataKey { get; set; } = ""; + + [Category("DirectAPI - API Keys")] + [DisplayName("Alpha Vantage Key")] + [PasswordPropertyText(true)] + public string AlphaVantageKey { get; set; } = ""; + + public Dictionary ToConfig() => new() + { + ["defaultProvider"] = DefaultProvider, + ["cacheTtlSeconds"] = CacheTtlSeconds, + ["providers"] = new Dictionary + { + ["twelvedata"] = new { apiKey = TwelveDataKey }, + ["alphavantage"] = new { apiKey = AlphaVantageKey } + } + }; + + public static DirectAPIToolSettings FromConfig(Dictionary config) + { + var settings = new DirectAPIToolSettings + { + DefaultProvider = ConfigHelper.GetString(config, "defaultProvider", "twelvedata"), + CacheTtlSeconds = ConfigHelper.GetInt(config, "cacheTtlSeconds", 60) + }; + + if (config.GetValueOrDefault("providers") is JsonElement providersJe) + { + var providers = JsonSerializer.Deserialize>(providersJe.GetRawText()); + if (providers != null) + { + if (providers.TryGetValue("twelvedata", out var td) && td.TryGetProperty("apiKey", out var tdk)) + settings.TwelveDataKey = tdk.GetString() ?? ""; + if (providers.TryGetValue("alphavantage", out var av) && av.TryGetProperty("apiKey", out var avk)) + settings.AlphaVantageKey = avk.GetString() ?? ""; + } + } + + return settings; + } +} + +public sealed class WebFetchToolSettings +{ + [Category("WebFetch")] + [DisplayName("Erlaubte Domains")] + [Description("Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)")] + public string AllowedDomains { get; set; } = ""; + + [Category("WebFetch")] + [DisplayName("Max Response KB")] + public int MaxResponseKb { get; set; } = 512; + + [Category("WebFetch")] + [DisplayName("User Agent")] + public string UserAgent { get; set; } = "ClawdDotNet-Agent/1.0"; + + public Dictionary ToConfig() => new() + { + ["allowedDomains"] = AllowedDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + ["maxResponseKb"] = MaxResponseKb, + ["userAgent"] = UserAgent + }; + + public static WebFetchToolSettings FromConfig(Dictionary config) => new() + { + AllowedDomains = ConfigHelper.GetStringArray(config, "allowedDomains"), + MaxResponseKb = ConfigHelper.GetInt(config, "maxResponseKb", 512), + UserAgent = ConfigHelper.GetString(config, "userAgent", "ClawdDotNet-Agent/1.0") + }; +} + +public sealed class WebMonitorToolSettings +{ + [Category("WebMonitor")] + [DisplayName("Monitore (JSON)")] + [Description("JSON-Konfiguration der Monitore")] + public string MonitorsJson { get; set; } = "{}"; + + public Dictionary ToConfig() + { + try + { + return new Dictionary + { + ["monitors"] = JsonSerializer.Deserialize>(MonitorsJson) ?? new() + }; + } + catch { return new Dictionary { ["monitors"] = new Dictionary() }; } + } + + public static WebMonitorToolSettings FromConfig(Dictionary config) => new() + { + MonitorsJson = config.GetValueOrDefault("monitors") is JsonElement je ? je.GetRawText() : "{}" + }; +} + +public sealed class AgentCommToolSettings +{ + [Category("AgentComm")] + [DisplayName("Info")] + [Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.")] + [ReadOnly(true)] + public string Status { get; set; } = "Aktiv"; + + public Dictionary ToConfig() => new(); + + public static AgentCommToolSettings FromConfig(Dictionary config) => new(); +} + +public sealed class SocialMediaManagerToolSettings +{ + // ─── X (Twitter) ─── + + [Category("1. X (Twitter) - API")] + [DisplayName("Bearer Token")] + [Description("X API v2 Bearer Token für die Authentifizierung")] + [PasswordPropertyText(true)] + public string XApiKey { get; set; } = ""; + + [Category("1. X (Twitter) - Monitoring")] + [DisplayName("Überwachte Accounts")] + [Description("Komma-getrennte Liste von X-Accounts die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone")] + public string XWatchAccounts { get; set; } = ""; + + // ─── Reddit ─── + + [Category("2. Reddit - Monitoring")] + [DisplayName("Überwachte Subreddits")] + [Description("Komma-getrennte Liste von Subreddits die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options")] + public string RedditWatchSubreddits { get; set; } = ""; + + [Category("2. Reddit - Monitoring")] + [DisplayName("Posts pro Subreddit")] + [Description("Maximale Anzahl Posts die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)")] + public int RedditPostLimit { get; set; } = 15; + + // ─── YouTube / STT ─── + + [Category("3. YouTube / STT")] + [DisplayName("OpenRouter API Key")] + [Description("API Key für Speech-to-Text Transkription über OpenRouter")] + [PasswordPropertyText(true)] + public string OpenRouterApiKey { get; set; } = ""; + + [Category("3. YouTube / STT")] + [DisplayName("STT Modell")] + [Description("OpenRouter Modell-ID für die Transkription")] + public string STTModel { get; set; } = "openai/whisper-1"; + + [Category("3. YouTube / STT")] + [DisplayName("YouTube Kanäle")] + [Description("Komma-getrennte Liste von YouTube Kanal-URLs für automatische Überwachung")] + public string YoutubeChannels { get; set; } = ""; + + public Dictionary ToConfig() => new() + { + ["xApiKey"] = XApiKey, + ["xWatchAccounts"] = SplitToArray(XWatchAccounts), + ["redditWatchSubreddits"] = SplitToArray(RedditWatchSubreddits), + ["redditPostLimit"] = RedditPostLimit, + ["openRouterApiKey"] = OpenRouterApiKey, + ["sttModel"] = STTModel, + ["youtubeChannels"] = SplitToArray(YoutubeChannels) + }; + + public static SocialMediaManagerToolSettings FromConfig(Dictionary config) => new() + { + XApiKey = ConfigHelper.GetString(config, "xApiKey"), + XWatchAccounts = ConfigHelper.GetStringArray(config, "xWatchAccounts"), + RedditWatchSubreddits = ConfigHelper.GetStringArray(config, "redditWatchSubreddits"), + RedditPostLimit = ConfigHelper.GetInt(config, "redditPostLimit", 15), + OpenRouterApiKey = ConfigHelper.GetString(config, "openRouterApiKey"), + STTModel = ConfigHelper.GetString(config, "sttModel", "openai/whisper-1"), + YoutubeChannels = ConfigHelper.GetStringArray(config, "youtubeChannels") + }; + + private static string[] SplitToArray(string csv) + => csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} + +public sealed class AgentEditorToolSettings +{ + [Category("AgentEditor")] + [DisplayName("Info")] + [Description("Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen, zu bearbeiten und neue Agenten zu erstellen. Keine weitere Konfiguration nötig.")] + [ReadOnly(true)] + public string Status { get; set; } = "Aktiv"; + + public Dictionary ToConfig() => new(); + + public static AgentEditorToolSettings FromConfig(Dictionary config) => new(); +} + +public sealed class AgentSpawnToolSettings +{ + [Category("AgentSpawn")] + [DisplayName("Info")] + [Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.")] + [ReadOnly(true)] + public string Status { get; set; } = "Aktiv"; + + public Dictionary ToConfig() => new(); + + public static AgentSpawnToolSettings FromConfig(Dictionary config) => new(); +} + +public static class ToolSettingsFactory +{ + public static object? CreateViewModel(string toolName, Dictionary? config) + { + config ??= new(); + return toolName switch + { + "FileRW" => FileRWToolSettings.FromConfig(config), + "Mail" => MailToolSettings.FromConfig(config), + "Database" => DatabaseToolSettings.FromConfig(config), + "Telegram" => TelegramToolSettings.FromConfig(config), + "FTP" => FTPToolSettings.FromConfig(config), + "DirectAPI" => DirectAPIToolSettings.FromConfig(config), + "WebFetch" => WebFetchToolSettings.FromConfig(config), + "WebMonitor" => WebMonitorToolSettings.FromConfig(config), + "AgentComm" => AgentCommToolSettings.FromConfig(config), + "SocialMediaManager" => SocialMediaManagerToolSettings.FromConfig(config), + "AgentSpawn" => AgentSpawnToolSettings.FromConfig(config), + "AgentEditor" => AgentEditorToolSettings.FromConfig(config), + _ => null + }; + } + + public static Dictionary? ToConfig(string toolName, object? viewModel) + { + return viewModel switch + { + FileRWToolSettings f => f.ToConfig(), + MailToolSettings m => m.ToConfig(), + DatabaseToolSettings d => d.ToConfig(), + TelegramToolSettings t => t.ToConfig(), + FTPToolSettings ftp => ftp.ToConfig(), + DirectAPIToolSettings dapi => dapi.ToConfig(), + WebFetchToolSettings wf => wf.ToConfig(), + WebMonitorToolSettings wm => wm.ToConfig(), + AgentCommToolSettings ac => ac.ToConfig(), + SocialMediaManagerToolSettings smm => smm.ToConfig(), + AgentSpawnToolSettings asp => asp.ToConfig(), + AgentEditorToolSettings ae => ae.ToConfig(), + _ => null + }; + } +} diff --git a/src/ClawdDotNet.App/Services/BackupScheduler.cs b/src/ClawdDotNet.App/Services/BackupScheduler.cs new file mode 100644 index 0000000..73faa8c --- /dev/null +++ b/src/ClawdDotNet.App/Services/BackupScheduler.cs @@ -0,0 +1,169 @@ +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Storage; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.App.Services; + +/// +/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung. +/// +/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und +/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten +/// mitsichern will, macht das von Hand. +/// +/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit +/// eine Änderung ohne Neustart greift. +/// +/// Takt. Früher ein System.Windows.Forms.Timer — der braucht eine +/// Nachrichtenschleife und damit ein Fenster. Jetzt über +/// einen : läuft ohne Oberfläche, driftet nicht, und Tests +/// können die Zeit steuern statt zu warten. +/// +public sealed class BackupScheduler : IAsyncDisposable +{ + private readonly string _instanceDir; + private readonly string _instanceName; + private readonly SettingsManager _settings; + private readonly ILogger _logger; + private readonly TimeProvider _clock; + private readonly TimeSpan _tick; + private readonly BackupService _service = new(); + private readonly CancellationTokenSource _cts = new(); + + private Task? _loop; + + /// Verhindert mehrere Sicherungen am selben Tag. + private DateTime? _lastRun; + + public event Action? OnBackupCreated; + + public BackupScheduler( + string instanceDir, + string instanceName, + SettingsManager settings, + ILogger logger, + TimeProvider? clock = null, + TimeSpan? tick = null) + { + _instanceDir = instanceDir; + _instanceName = instanceName; + _settings = settings; + _logger = logger; + _clock = clock ?? TimeProvider.System; + + // Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt. + _tick = tick ?? TimeSpan.FromMinutes(1); + } + + public void Start() => _loop ??= RunLoopAsync(_cts.Token); + + private async Task RunLoopAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(_tick, _clock); + + while (await timer.WaitForNextTickAsync(ct)) + { + try + { + await TickAsync(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Ein Fehlschlag darf die Anwendung nicht stören. + _logger.LogError(ex, "Automatische Sicherung fehlgeschlagen"); + } + } + } + + /// Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können. + public async Task TickAsync() + { + var settings = _settings.AppSettings; + if (!settings.AutoBackupEnabled) + return; + + if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled)) + return; + + var now = _clock.GetLocalNow().DateTime; + + // Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief. + if (now.TimeOfDay < scheduled) return; + if (_lastRun?.Date == now.Date) return; + + // Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht, + // sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem. + _lastRun = now; + + await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now); + } + + private async Task RunAsync(string folder, int keepCount, DateTime now) + { + // PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das + // nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter + // Windows nicht mehr anlegen lässt. + var safeName = PortableFileName.Sanitize(_instanceName, "Instanz"); + + var file = Path.Combine( + Path.GetFullPath(folder), + $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip"); + + var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions + { + Secrets = SecretMode.Exclude, + IncludeChatHistory = true, + IncludeLogs = false + }); + + _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)", + result.ZipPath, result.SizeBytes); + + ApplyRotation(Path.GetFullPath(folder), safeName, keepCount); + + OnBackupCreated?.Invoke(result.ZipPath); + } + + /// Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest. + private void ApplyRotation(string folder, string safeName, int keepCount) + { + if (keepCount <= 0 || !Directory.Exists(folder)) + return; + + try + { + var prefix = $"backup_{safeName}_"; + + var obsolete = new DirectoryInfo(folder) + .GetFiles("*.zip") + .Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison)) + .OrderByDescending(f => f.LastWriteTime) + .Skip(keepCount) + .ToList(); + + foreach (var file in obsolete) + { + file.Delete(); + _logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen"); + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { /* erwartet */ } + } + + _cts.Dispose(); + } +} diff --git a/src/ClawdDotNet.App/Services/DeploymentcenterService.cs b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs new file mode 100644 index 0000000..9666952 --- /dev/null +++ b/src/ClawdDotNet.App/Services/DeploymentcenterService.cs @@ -0,0 +1,269 @@ +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Deploymentcenter; +using ClawdDotNet.Core.Deploymentcenter.Watchdog; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.App.Services; + +/// +/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat, +/// Fehler-Stream, Bugtracker und Update-Prüfung. +/// +/// Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz +/// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides +/// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu +/// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab. +/// +/// Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein +/// status/error-Umschlag), einen eigenen Zwischenspeicher und muss vor +/// allem anderen laufen. Dafür ist zuständig. +/// +public sealed class DeploymentcenterService : IAsyncDisposable +{ + private readonly DeploymentcenterApi _api; + private readonly string _appToken; + private readonly string _build; + private readonly ILogger _logger; + + private WatchdogHeartbeatService? _heartbeat; + + public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance; + + /// Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden. + public BugtrackerClient? Bugtracker { get; private set; } + + /// Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist. + public UpdateAvailability? Update { get; private set; } + + private DeploymentcenterService( + DeploymentcenterApi api, string appToken, string build, ILogger logger) + { + _api = api; + _appToken = appToken; + _build = build; + _logger = logger; + } + + /// + /// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt null zurück, + /// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines + /// dieser Module darf ein Startgrund oder ein Hindernis sein. + /// + public static DeploymentcenterService? TryCreate( + AppSettings settings, string build, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter"); + + if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl)) + { + logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt – Anbindung aus."); + return null; + } + + if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken)) + { + logger.LogInformation( + "Deploymentcenter: kein Token hinterlegt – Heartbeat, Fehler-Stream und " + + "Bugtracker bleiben aus."); + return null; + } + + DeploymentcenterApi api; + try + { + api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar – Anbindung aus."); + return null; + } + + var service = new DeploymentcenterService( + api, settings.DeploymentcenterToken, build, logger); + + if (settings.ErrorReportingEnabled) + { + service.Errors = new ErrorReporter( + api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger); + } + + service.Bugtracker = new BugtrackerClient( + api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build); + + return service; + } + + // ─── Watchdog ─── + + /// + /// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist. + /// + /// Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über + /// das Paar source + instance. Fällt eine von mehreren aus, fällt + /// genau deren Monitor — und nur der schlägt Alarm. + /// + /// + /// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration + /// geschrieben werden soll (dort verschlüsselt). + /// + public async Task StartWatchdogAsync( + InstanceConfig instance, + IInstanceHealthProvider health, + Action saveInstanceConfig, + CancellationToken ct = default) + { + var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog); + if (service is not { Enabled: true, AutoStart: true }) + return; + + var watchdog = instance.Watchdog; + var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct); + + try + { + _heartbeat = WatchdogHeartbeatService.Create( + _api.BaseUrl, + token, + watchdog.Source, + watchdog.ResolveInstance(instance.InstanceId), + watchdog.Group, + DescribePlatform(), + _build, + watchdog.IntervalSeconds, + health, + _logger); + + _heartbeat.Start(); + + _logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s", + watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden"); + } + } + + /// + /// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte + /// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite. + /// + /// Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das + /// Master-Token liegt, sondern ein auf watchdog:ping und + /// bugtracker:report beschränktes, das sich einzeln widerrufen lässt. + /// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter + /// Rechtelage läuft, ist genau dann still, wenn man es braucht. + /// + private async Task ResolveInstanceTokenAsync( + InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct) + { + if (instance.Watchdog.HasToken) + return instance.Watchdog.AgentToken; + + try + { + var provisioned = await new TokenProvisioner(_api).ProvisionAsync( + clientName: $"ClawdDotNet {instance.InstanceName}", + instanceId: instance.InstanceId, + scopes: TokenProvisioner.InstanceScopes, + ct: ct); + + instance.Watchdog.AgentToken = provisioned.Token; + saveInstanceConfig(); + + _logger.LogInformation( + "Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})", + provisioned.TokenId, string.Join(", ", provisioned.Scopes)); + + return provisioned.Token; + } + catch (Exception ex) + { + // Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf + // keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben. + _logger.LogInformation( + "Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) – " + + "es wird mit dem hinterlegten Token gemeldet.", ex.Message); + + return _appToken; + } + } + + private static string DescribePlatform() => + $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / " + + $".NET {System.Environment.Version}"; + + // ─── Updates ─── + + /// + /// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das + /// Ergebnis wird protokolliert und über bereitgestellt; ob und + /// wann aktualisiert wird, entscheidet der Benutzer. + /// + public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default) + { + if (!settings.UpdateCheckEnabled) + return; + + try + { + var client = new global::Deploymentcenter.Client.UpdateClient(); + var result = await client.CheckForUpdateAsync( + settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion, + settings.UpdateChannel, cancellationToken: ct); + + if (result.Error is not null) + { + _logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert)."); + return; + } + + // Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische + // latest.json in camelCase, die API in snake_case, jeweils über ein eigenes + // Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an — + // und der ist genau der Rückfall, wenn die latest.json fehlt. + Update = new UpdateAvailability( + result.UpdateAvailable, + result.LatestRelease?.Version ?? currentVersion, + result.IsCritical, + result.LatestRelease?.Changelog, + result.LatestRelease?.PackageUrl); + + if (result.UpdateAvailable) + { + _logger.LogInformation("Update verfügbar: {Version}{Critical}", + Update.LatestVersion, Update.IsCritical ? " (kritisch)" : ""); + } + else + { + _logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert)."); + } + } + + public async ValueTask DisposeAsync() + { + if (_heartbeat is not null) + await _heartbeat.DisposeAsync(); + + if (Errors is IDisposable disposableReporter) + disposableReporter.Dispose(); + + _api.Dispose(); + } +} + +/// Ergebnis der Update-Prüfung. +/// +/// Adresse des Pakets — für einen späteren Anschluss des update-agent. Solange +/// der nicht eingebunden ist, dient sie nur der Anzeige. +/// +public sealed record UpdateAvailability( + bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes, + string? DownloadUrl = null); diff --git a/src/ClawdDotNet.App/Services/ILicensePrompt.cs b/src/ClawdDotNet.App/Services/ILicensePrompt.cs new file mode 100644 index 0000000..e8d3c07 --- /dev/null +++ b/src/ClawdDotNet.App/Services/ILicensePrompt.cs @@ -0,0 +1,93 @@ +namespace ClawdDotNet.App.Services; + +/// +/// Wie der mit dem Benutzer spricht. +/// +/// Vorher rief er unmittelbar MessageBox.Show und einen WinForms-Dialog. +/// Das band die Lizenzprüfung an WinForms — und wäre im kopflosen Betrieb fatal +/// gewesen: Ein Dienst, der beim Start ein Fenster öffnet und auf eine Eingabe wartet, +/// hängt für immer, ohne dass jemand die Meldung je zu sehen bekäme. +/// +/// Es gibt drei Umsetzungen: +/// +/// AvaloniaLicensePrompt — Dialoge im Fenster (Desktop). +/// — Eingabe über die Konsole (Kommandozeile). +/// — antwortet nie (systemd-Dienst). +/// +/// +public interface ILicensePrompt +{ + /// + /// Fragt einen Lizenzschlüssel ab. null heißt Abbruch — der Aufrufer beendet + /// die Anwendung. + /// + /// Wird angezeigt, damit der Nutzer ihn an den Support geben kann. + /// Warum der bisherige Schlüssel nicht taugt; null beim ersten Fragen. + /// Vorbelegung des Eingabefelds. + Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey); + + /// Eine Meldung, die keine Antwort braucht (etwa „offline gültig bis …"). + Task ShowInfoAsync(string title, string message); + + /// Ein Fehler, nach dem die Anwendung nicht weiterläuft. + Task ShowErrorAsync(string title, string message); +} + +/// +/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab. +/// +/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren +/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile +/// hinterlegt (--license-set-key). +/// +public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePrompt +{ + public Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey) + { + log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. " + + (problem is null ? "" : $"Grund: {problem}. ") + + "Schlüssel mit '--license-set-key ' hinterlegen."); + + return Task.FromResult(null); + } + + public Task ShowInfoAsync(string title, string message) + { + log($"{title}: {message}"); + return Task.CompletedTask; + } + + public Task ShowErrorAsync(string title, string message) + { + log($"{title}: {message}"); + return Task.CompletedTask; + } +} + +/// Eingabe über die Konsole — für Kommandozeilenaufrufe. +public sealed class ConsoleLicensePrompt : ILicensePrompt +{ + public Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey) + { + if (problem is not null) + Console.Error.WriteLine($"Lizenz: {problem}"); + + Console.WriteLine($"Hardware-ID: {hardwareId}"); + Console.Write("Lizenzschlüssel: "); + + var input = Console.ReadLine()?.Trim(); + return Task.FromResult(string.IsNullOrWhiteSpace(input) ? null : input); + } + + public Task ShowInfoAsync(string title, string message) + { + Console.WriteLine($"{title}: {message}"); + return Task.CompletedTask; + } + + public Task ShowErrorAsync(string title, string message) + { + Console.Error.WriteLine($"{title}: {message}"); + return Task.CompletedTask; + } +} diff --git a/Services/InstanceDirectoryManager.cs b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs similarity index 99% rename from Services/InstanceDirectoryManager.cs rename to src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs index e93fba9..4dd4c32 100644 --- a/Services/InstanceDirectoryManager.cs +++ b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs @@ -2,9 +2,9 @@ using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Storage; -using ClawdDotNet.Models; +using ClawdDotNet.App.Models; -namespace ClawdDotNet.Services; +namespace ClawdDotNet.App.Services; /// /// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten. diff --git a/Services/JobHistoryService.cs b/src/ClawdDotNet.App/Services/JobHistoryService.cs similarity index 97% rename from Services/JobHistoryService.cs rename to src/ClawdDotNet.App/Services/JobHistoryService.cs index a577b98..f20366f 100644 --- a/Services/JobHistoryService.cs +++ b/src/ClawdDotNet.App/Services/JobHistoryService.cs @@ -1,7 +1,7 @@ using System.Text.Json; -using ClawdDotNet.Models; +using ClawdDotNet.App.Models; -namespace ClawdDotNet.Services; +namespace ClawdDotNet.App.Services; /// /// Thread-safe JSON persistence for job execution history. diff --git a/src/ClawdDotNet.App/Services/LicenseGate.cs b/src/ClawdDotNet.App/Services/LicenseGate.cs new file mode 100644 index 0000000..4087d00 --- /dev/null +++ b/src/ClawdDotNet.App/Services/LicenseGate.cs @@ -0,0 +1,237 @@ +using ClawdDotNet.App.Settings; +using Deploymentcenter.Client; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.App.Services; + +/// +/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters. +/// +/// Nur ein Urteil sperrt. Das SDK trennt seit 2.1 zwei Dinge, die vorher +/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz +/// (revoked, expired, not_found, activation_limit, +/// suspended, clock_rollback) und ein gescheiterter Versuch, überhaupt +/// eine zu bekommen (). Nur das Urteil +/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig +/// aussperren. +/// +/// Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung +/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und +/// trägt damit über Ausfälle hinweg — begrenzt durch cache_ttl_hours des Projekts +/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz. +/// +/// Was diese Fassung nicht kann. Deaktivieren (Aktivierungsplatz freigeben) +/// verlangt den shared_key des Servers. Der gehört nicht in eine ausgelieferte +/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben"). +/// Eine Signaturprüfung findet nicht statt — siehe . +/// +public sealed class LicenseGate +{ + private readonly SettingsManager _settings; + private readonly ILogger _logger; + private readonly ILicensePrompt _prompt; + private readonly LicenseClient _client; + private readonly string _serverUrl; + + private string? _hardwareId; + + public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt) + { + _settings = settings; + _logger = logger; + _prompt = prompt; + + _serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl) + ? LicenseInfo.DefaultServerUrl + : settings.AppSettings.DeploymentcenterUrl.Trim(); + + // Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das + // trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist. + LicenseClient.DefaultAppVersion = ReleaseInfo.Version; + + // Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze + // von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der + // Server nicht antwortete. + _client = new LicenseClient(); + } + + /// + /// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung + /// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas + /// eintragen kann. + /// + public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl); + + /// + /// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung + /// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. + /// + public string HardwareId => + // Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst. + _hardwareId ??= global::Deploymentcenter.Client.HardwareId + .GetHardwareId(LicenseInfo.ProductSlug).HardwareId; + + /// + /// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein + /// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet. + /// + public Task RevalidateAsync(CancellationToken ct = default) + => ValidateAsync(_settings.AppSettings.LicenseKey, ct); + + /// + /// Startprüfung. Fragt über nach, bis eine nutzbare + /// Lizenz vorliegt, oder gibt false zurück — dann beendet der Aufrufer die + /// Anwendung. + /// + public async Task RunStartupCheckAsync(CancellationToken ct = default) + { + if (!IsEnforcementConfigured) + { + _logger.LogWarning( + "Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen."); + return true; + } + + var key = _settings.AppSettings.LicenseKey; + + if (string.IsNullOrWhiteSpace(key)) + { + key = await _prompt.RequestKeyAsync(HardwareId, null, null); + if (key is null) return false; + } + + var result = await ValidateAsync(key, ct); + + while (!result.IsValid) + { + // Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer + // Schlüssel würde daran nichts ändern, und danach zu fragen ließe den + // Benutzer raten. Der Notausschalter greift, sobald der Server wieder + // antwortet (LicenseWatch). + if (result.IsTransient) + { + _logger.LogWarning( + "Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.", + result.Status, result.Message); + + await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz", + "Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder " + + "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später " + + "erneut."); + + return true; + } + + if (result.Status == "clock_rollback") + { + // Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung. + await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz", + "Die Lizenzprüfung konnte nicht abgeschlossen werden. " + + "Bitte den Support kontaktieren."); + return false; + } + + _logger.LogWarning( + "Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})", + result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl); + + var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key); + if (retry is null) return false; + + key = retry; + result = await ValidateAsync(key, ct); + } + + _settings.AppSettings.LicenseKey = key; + + try + { + _settings.Save(); + } + catch (SettingsPersistenceException ex) + { + // Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja — + // beim nächsten Start wird eben erneut gefragt. + _logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden"); + } + + if (result.IsCached) + { + var until = Describe(result.CacheExpiresAt); + + _logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until); + + await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz", + $"Lizenzserver nicht erreichbar. Offline gültig bis {until}."); + } + + return true; + } + + private async Task ValidateAsync(string key, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(key)) + { + return new LicenseValidationResult + { + IsValid = false, + Status = "not_found", + Message = "Kein Lizenzschlüssel hinterlegt." + }; + } + + try + { + return await _client.ValidateAsync( + LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als + // IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem + // nicht als Urteil über die Lizenz gelten. + _logger.LogError(ex, "Lizenzprüfung fehlgeschlagen."); + + return new LicenseValidationResult + { + IsValid = false, + Status = "server_unavailable", + Message = "Lizenz konnte nicht geprüft werden.", + IsTransient = true + }; + } + } + + private static string Describe(long? unixSeconds) => + unixSeconds is { } seconds and > 0 + ? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g") + : "auf Weiteres"; + + private static string DescribeProblem(LicenseValidationResult result) => result.Status switch + { + "revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.", + "suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.", + "expired" => "Diese Lizenz ist abgelaufen.", + "activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. " + + "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste " + + "über \"Freigeben\" räumen.", + // Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes + // Projekt und unbekannter Schlüssel. Welches davon, steht nur in message — + // deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch + // eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man + // sucht am falschen Ende. + "not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss " + + $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt " + + "sein und der Schlüssel dort hinterlegt." + + (string.IsNullOrWhiteSpace(result.Message) + ? "" + : $" (Server: {result.Message})"), + _ => string.IsNullOrWhiteSpace(result.Message) + ? "Die Lizenz ist nicht gültig." + : result.Message + }; +} diff --git a/src/ClawdDotNet.App/Services/LicenseInfo.cs b/src/ClawdDotNet.App/Services/LicenseInfo.cs new file mode 100644 index 0000000..dd35388 --- /dev/null +++ b/src/ClawdDotNet.App/Services/LicenseInfo.cs @@ -0,0 +1,38 @@ +namespace ClawdDotNet.App.Services; + +/// +/// Fest hinterlegte Lizenz-Eckdaten. +/// +/// Kein Public-Key mehr. Die frühere Fassung führte einen +/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine +/// Gegenseite: Der Client liest ausschließlich das Feld status aus der Antwort, +/// eine Signaturprüfung findet nicht statt (siehe +/// docs/Deploymentcenter-Anbindung-Review.md, Abschnitt 2.1). Ein Schlüssel, der +/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist. +/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück. +/// +/// Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann +/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz. +/// +public static class LicenseInfo +{ + /// + /// Projekt-Slug, wie er in dc_projects angelegt ist. Gilt für alle Module: + /// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe + /// Projekttabelle zu. + /// + /// Hier stand bis zur Umstellung clawd — der Name aus dem + /// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt + /// clawddotnet. Der Server beantwortet einen unbekannten Slug mit demselben + /// not_found wie einen unbekannten Schlüssel, weshalb das wie ein falsch + /// eingegebener Lizenzschlüssel aussah. + /// + public const string ProductSlug = "clawddotnet"; + + /// + /// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine + /// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung + /// und teilen sich diese Adresse. + /// + public const string DefaultServerUrl = "https://dc.mhdf.de"; +} diff --git a/src/ClawdDotNet.App/Services/LicenseWatch.cs b/src/ClawdDotNet.App/Services/LicenseWatch.cs new file mode 100644 index 0000000..7f3aa79 --- /dev/null +++ b/src/ClawdDotNet.App/Services/LicenseWatch.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.App.Services; + +/// +/// Prüft die Lizenz im laufenden Betrieb nach. +/// +/// Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die +/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle +/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro +/// Klick. +/// +/// Nur ein Urteil zählt. Ein Netzproblem, eine Drosselung oder eine +/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als +/// IsTransient, und ein Serverausfall darf nicht alle laufenden Instanzen +/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg. +/// +public sealed class LicenseWatch : IAsyncDisposable +{ + private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12); + + private readonly LicenseGate _gate; + private readonly ILogger _logger; + private readonly TimeSpan _interval; + + private CancellationTokenSource? _cts; + private Task? _loop; + + /// + /// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber + /// ohne Rückfrage; der übergebene Text erklärt den Grund. + /// + public event Func? Revoked; + + public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null) + { + _gate = gate; + _logger = logger; + _interval = interval ?? DefaultInterval; + } + + public void Start() + { + if (_loop is { IsCompleted: false }) + return; + + _cts = new CancellationTokenSource(); + _loop = RunAsync(_cts.Token); + } + + private async Task RunAsync(CancellationToken ct) + { + try + { + using var timer = new PeriodicTimer(_interval); + + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false); + + if (result.IsValid) + continue; + + if (result.IsTransient) + { + _logger.LogInformation( + "Lizenz-Nachprüfung ohne Ergebnis ({Status}) – Betrieb läuft weiter.", + result.Status); + continue; + } + + _logger.LogWarning("Lizenz gilt nicht mehr ({Status}) – Instanz wird beendet.", + result.Status); + + if (Revoked is { } handler) + { + await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). " + + "ClawdDotNet wird beendet.").ConfigureAwait(false); + } + + return; + } + } + catch (OperationCanceledException) + { + // Regulärer Stopp. + } + catch (Exception ex) + { + // Die Nachprüfung darf den Betrieb nicht mitnehmen. + _logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen."); + } + } + + public async ValueTask DisposeAsync() + { + if (_cts is null) + return; + + await _cts.CancelAsync().ConfigureAwait(false); + + if (_loop is not null) + { + try { await _loop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* erwartet */ } + } + + _cts.Dispose(); + } +} diff --git a/src/ClawdDotNet.App/Services/LogTail.cs b/src/ClawdDotNet.App/Services/LogTail.cs new file mode 100644 index 0000000..2248778 --- /dev/null +++ b/src/ClawdDotNet.App/Services/LogTail.cs @@ -0,0 +1,154 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace ClawdDotNet.App.Services; + +/// Eine gelesene Logzeile. +/// Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei. +/// INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat. +public readonly record struct LogLine(string Module, string Level, string Text); + +/// Ab welcher Stufe angezeigt wird. +public enum LogLevelFilter { All, Info, Warn, Error } + +/// +/// Liest neu hinzugekommene Zeilen aus den Logdateien. +/// +/// Die vorige Fassung (LiveLogViewerService) schrieb unmittelbar in eine +/// RichTextBox und taktete über einen System.Windows.Forms.Timer — Lesen +/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur +/// das Lesen; was damit geschieht, entscheidet der Aufrufer. +/// +/// Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das +/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen. +/// +public sealed partial class LogTail(string logDirectory) +{ + private readonly ConcurrentDictionary _positions = new(); + + [GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)] + private static partial Regex LevelPattern(); + + /// Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld. + public IReadOnlyList AvailableModules() + { + var directory = TodayDirectory(); + if (directory is null) return []; + + return Directory.GetFiles(directory, "*.log") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrEmpty(name)) + .Select(name => name!) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Alles, was seit dem letzten Aufruf dazugekommen ist. + /// + /// leer heißt: alle Module. + /// + public IReadOnlyList ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All) + { + var directory = TodayDirectory(); + if (directory is null) return []; + + var result = new List(); + + foreach (var path in Directory.GetFiles(directory, "*.log")) + { + var moduleName = Path.GetFileNameWithoutExtension(path); + + if (!string.IsNullOrEmpty(module) + && !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase)) + { + // Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters + // die gesamte bisherige Datei auf einmal herein. + TrackWithoutReading(path); + continue; + } + + ReadFile(path, moduleName, level, result); + } + + return result; + } + + private void ReadFile(string path, string module, LogLevelFilter level, List into) + { + var lastPosition = _positions.GetOrAdd(path, 0L); + + try + { + // FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die + // Datei auch ersetzen können, während wir lesen. + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < lastPosition) + lastPosition = 0; // rotiert oder gekürzt + + if (stream.Length == lastPosition) + return; + + stream.Seek(lastPosition, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + + while (reader.ReadLine() is { } line) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + var lineLevel = ExtractLevel(line); + if (!Passes(lineLevel, level)) continue; + + into.Add(new LogLine(module, lineLevel, line)); + } + + _positions[path] = stream.Position; + } + catch (IOException) + { + // Wird gerade geschrieben — beim nächsten Takt erneut versuchen. + } + } + + private void TrackWithoutReading(string path) + { + try { _positions[path] = new FileInfo(path).Length; } + catch (IOException) { } + } + + private static string ExtractLevel(string line) + { + var match = LevelPattern().Match(line); + return match.Success ? match.Groups[1].Value.ToUpperInvariant() : ""; + } + + private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch + { + LogLevelFilter.All => true, + // Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine + // Fehlermeldung, die der Filter verschluckt. + _ when lineLevel.Length == 0 => true, + LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL", + LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL", + LogLevelFilter.Error => lineLevel is "ERR" or "FTL", + _ => true + }; + + /// + /// Das Verzeichnis des heutigen Tages, oder null. + /// + /// DateTime.Now und nicht UTC: Die Schreibseite legt die Verzeichnisse nach + /// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit + /// TZ=UTC wechselt der Ordner damit um Mitternacht UTC — richtig, aber + /// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht. + /// + private string? TodayDirectory() + { + if (!Directory.Exists(logDirectory)) return null; + + var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd")); + return Directory.Exists(directory) ? directory : null; + } +} diff --git a/Services/OpenRouterStatusService.cs b/src/ClawdDotNet.App/Services/OpenRouterStatusService.cs similarity index 84% rename from Services/OpenRouterStatusService.cs rename to src/ClawdDotNet.App/Services/OpenRouterStatusService.cs index cd1b059..44f5719 100644 --- a/Services/OpenRouterStatusService.cs +++ b/src/ClawdDotNet.App/Services/OpenRouterStatusService.cs @@ -4,12 +4,25 @@ using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; -namespace ClawdDotNet.Services; +namespace ClawdDotNet.App.Services; -public sealed class OpenRouterStatusService : IDisposable +/// +/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab. +/// +/// Der Takt lief früher über einen System.Windows.Forms.Timer — der braucht eine +/// Nachrichtenschleife und damit ein Fenster. Jetzt : läuft +/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt. +/// +/// wird auf einem Hintergrundfaden ausgelöst. Wer daran +/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia +/// über Dispatcher.UIThread. +/// +public sealed class OpenRouterStatusService : IAsyncDisposable { private readonly HttpClient _http; - private readonly System.Windows.Forms.Timer _timer; + private readonly TimeSpan _interval; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; private readonly ConcurrentBag _usageRecords = new(); @@ -40,17 +53,26 @@ public sealed class OpenRouterStatusService : IDisposable _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet"); - _timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 }; - _timer.Tick += async (_, _) => await CheckStatusAsync(); + _interval = TimeSpan.FromSeconds(checkIntervalSeconds); } public void Start() { - _timer.Start(); + _loop ??= RunLoopAsync(_cts.Token); _ = CheckStatusAsync(); } - public void Stop() => _timer.Stop(); + private async Task RunLoopAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(_interval); + + while (await timer.WaitForNextTickAsync(ct)) + { + // CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier + // muss nichts mehr behandelt werden. + await CheckStatusAsync(); + } + } public void RecordUsage(string model, int promptTokens, int completionTokens) { @@ -238,10 +260,17 @@ public sealed class OpenRouterStatusService : IDisposable CreditsTooltip = sb.ToString().TrimEnd(); } - public void Dispose() + public async ValueTask DisposeAsync() { - _timer.Stop(); - _timer.Dispose(); + await _cts.CancelAsync(); + + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { /* erwartet */ } + } + + _cts.Dispose(); _http.Dispose(); } diff --git a/src/ClawdDotNet.App/Settings/AppSettings.cs b/src/ClawdDotNet.App/Settings/AppSettings.cs new file mode 100644 index 0000000..efdf126 --- /dev/null +++ b/src/ClawdDotNet.App/Settings/AppSettings.cs @@ -0,0 +1,153 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.App.Settings; + +/// +/// Anwendungsweite Einstellungen (nicht instanzgebunden). +/// +/// Die -, - und +/// -Angaben stammen aus der PropertyGrid-Zeit. Sie +/// bleiben stehen: Sie sind die Beschriftungen und Hilfetexte, aus denen die +/// Avalonia-Einstellungsansicht gebaut wird — nur eben von Hand statt automatisch. +/// +public sealed class AppSettings +{ + // Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis. + // Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht + // schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde. + // Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths). + [Category("Allgemein")] + [DisplayName("Log-Verzeichnis")] + [Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")] + [JsonPropertyName("logDirectory")] + public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs"); + + [Category("Allgemein")] + [DisplayName("Instanzen-Verzeichnis")] + [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")] + [JsonPropertyName("instancesDirectory")] + public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances"); + + // DefaultConfigPath ist ersatzlos entfallen. Die Eigenschaft war als „(Legacy)" + // markiert und wurde von keiner Stelle mehr gelesen — sie stand nur noch als + // relativer Pfad in der Datei und hätte unter Linux ohnehin ins Leere gezeigt. + + [Category("Allgemein")] + [DisplayName("Minimaler Log-Level")] + [Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")] + [JsonPropertyName("minimumLogLevel")] + public string MinimumLogLevel { get; set; } = "Info"; + + [Category("UI")] + [DisplayName("Max. Log-Zeilen in UI")] + [Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")] + [JsonPropertyName("maxLogLinesInUi")] + public int MaxLogLinesInUi { get; set; } = 2000; + + [Category("UI")] + [DisplayName("Log-Aktualisierungsintervall (ms)")] + [Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")] + [JsonPropertyName("logRefreshIntervalMs")] + public int LogRefreshIntervalMs { get; set; } = 500; + + [Category("API")] + [DisplayName("Status-Check-Intervall (Sek)")] + [Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")] + [JsonPropertyName("statusCheckIntervalSeconds")] + public int StatusCheckIntervalSeconds { get; set; } = 60; + + [Category("API")] + [DisplayName("OpenRouter Base-URL")] + [Description("Basis-URL der OpenRouter-API.")] + [JsonPropertyName("openRouterBaseUrl")] + public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/"; + + [Category("Backup")] + [DisplayName("Backup-Verzeichnis")] + [Description("Ordner, in dem Sicherungen abgelegt werden.")] + [JsonPropertyName("backupDirectory")] + public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups"); + + [Category("Backup")] + [DisplayName("Automatisch sichern")] + [Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")] + [JsonPropertyName("autoBackupEnabled")] + public bool AutoBackupEnabled { get; set; } + + [Category("Backup")] + [DisplayName("Uhrzeit der automatischen Sicherung")] + [Description("Tageszeit im Format HH:mm.")] + [JsonPropertyName("autoBackupTime")] + public string AutoBackupTime { get; set; } = "03:00"; + + [Category("Backup")] + [DisplayName("Aufbewahrte Sicherungen")] + [Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")] + [JsonPropertyName("backupKeepCount")] + public int BackupKeepCount { get; set; } = 14; + + // ─── Deploymentcenter ─── + // + // Ein Server, ein Token. Lizenz, Watchdog, Updates, Fehler-Stream und Bugtracker + // sind Module derselben Anwendung — die frühere Aufteilung auf zwei Adressen + // (watchdog.mhdf.de, license.mhdf.de) mit je eigenem Schlüssel gibt es nicht mehr. + + [Category("Deploymentcenter")] + [DisplayName("Server-URL")] + [Description("Basis-URL des Deploymentcenters (nur HTTPS, Ausnahme localhost).")] + [JsonPropertyName("deploymentcenterUrl")] + public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de"; + + [Category("Deploymentcenter")] + [DisplayName("Token")] + [Description("Master-Token mit den Rechten 'watchdog:ping' und 'bugtracker:report'. " + + "Jede Instanz tauscht es beim ersten Start gegen ein eigenes, " + + "eingeschränktes Sub-Token. Wird verschlüsselt gespeichert.")] + [PasswordPropertyText(true)] + [JsonPropertyName("deploymentcenterToken")] + public string DeploymentcenterToken { get; set; } = ""; + + [Category("Deploymentcenter")] + [DisplayName("Umgebung")] + [Description("Wird an Fehler- und Bugtracker-Meldungen gehängt: production oder development.")] + [JsonPropertyName("deploymentcenterEnvironment")] + public string DeploymentcenterEnvironment { get; set; } = "production"; + + [Category("Deploymentcenter")] + [DisplayName("Fehler automatisch melden")] + [Description("Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. " + + "Derselbe Fehler geht höchstens alle fünf Minuten einmal raus.")] + [JsonPropertyName("errorReportingEnabled")] + public bool ErrorReportingEnabled { get; set; } = true; + + [Category("Deploymentcenter")] + [DisplayName("Beim Start auf Updates prüfen")] + [Description("Fragt einmalig beim Start, ob ein neueres Release vorliegt. Blockiert nicht.")] + [JsonPropertyName("updateCheckEnabled")] + public bool UpdateCheckEnabled { get; set; } = true; + + [Category("Deploymentcenter")] + [DisplayName("Update-Kanal")] + [Description("prod, beta oder dev.")] + [JsonPropertyName("updateChannel")] + public string UpdateChannel { get; set; } = "prod"; + + // ─── Lizenz ─── + + [Category("Lizenz")] + [DisplayName("Lizenzschlüssel")] + [Description("Der Lizenzschlüssel für ClawdDotNet. Wird verschlüsselt gespeichert.")] + [PasswordPropertyText(true)] + [JsonPropertyName("licenseKey")] + public string LicenseKey { get; set; } = ""; + + // LicensePublicKeyBase64 und LicenseEndpoints sind entfallen. Das Deploymentcenter + // signiert seine Antworten nicht (siehe LicenseInfo), ein Public-Key hätte also + // nichts zu prüfen; und der Lizenzserver ist dasselbe Deploymentcenter, dessen + // Adresse oben steht — zwei Felder für eine Adresse waren nur eine Gelegenheit, + // sie widersprüchlich zu füllen. + + public override string ToString() => "Anwendungseinstellungen"; +} diff --git a/src/ClawdDotNet.App/Settings/SettingsManager.cs b/src/ClawdDotNet.App/Settings/SettingsManager.cs new file mode 100644 index 0000000..0eb0116 --- /dev/null +++ b/src/ClawdDotNet.App/Settings/SettingsManager.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.App.Settings; + +/// +/// Lädt und speichert die anwendungsweiten Einstellungen. +/// +/// Ablageort. Bis zur Linux-Portierung lag Settings.json neben der +/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt +/// die Anwendung in /opt oder /usr/lib und ist für den Dienstbenutzer nicht +/// beschreibbar. Jetzt entscheidet — XDG unter +/// Linux, %APPDATA% unter Windows, per CLAWD_CONFIG_DIR überschreibbar. +/// +/// Kein Migrationspfad. Bewusst: Zum Zeitpunkt der Umstellung lief noch +/// keine Installation produktiv. Eine bestehende Settings.json neben der +/// Programmdatei wird also nicht übernommen — der Ort wechselt einmal sauber, +/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst. +/// +/// Keine Meldungsfenster. Diese Schicht kennt keine Oberfläche. Ein +/// Speicherfehler kommt als heraus; ob daraus +/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer. +/// +public sealed class SettingsManager +{ + private const string SettingsFileName = "Settings.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true + }; + + private readonly string _settingsPath; + + public AppSettings AppSettings { get; private set; } = new(); + + /// Der Ort der Einstellungsdatei — für Meldungen und Diagnose. + public string SettingsPath => _settingsPath; + + public SettingsManager(string? basePath = null) + { + var dir = basePath ?? AppPaths.ConfigDirectory; + _settingsPath = Path.Combine(dir, SettingsFileName); + } + + public void Load() + { + if (!File.Exists(_settingsPath)) + { + AppSettings = new AppSettings(); + Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird + return; + } + + try + { + var json = AtomicFile.ReadAllText(_settingsPath); + AppSettings = JsonSerializer.Deserialize(json, JsonOptions) + ?? new AppSettings(); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + // Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben + // weiterzumachen ist besser, als gar nicht zu starten. + AppSettings = new AppSettings(); + return; + } + + // Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im + // Klartext gehalten. + AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey); + AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken); + } + + /// + /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner, + /// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen + /// unbrauchbaren Wert an eine Gegenstelle zu schicken. + /// + private static string TryUnprotect(string value) + { + try { return SecretProtector.Unprotect(value) ?? ""; } + catch (SecretProtectionException) { return ""; } + } + + /// Wenn die Datei nicht geschrieben werden kann. + public void Save() + { + // Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext. + var plainLicenseKey = AppSettings.LicenseKey; + var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken; + + try + { + AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? ""; + AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? ""; + + AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!); + + var json = JsonSerializer.Serialize(AppSettings, JsonOptions); + AtomicFile.WriteAllText(_settingsPath, json); + AppPaths.RestrictToOwner(_settingsPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or SecretProtectionException) + { + throw new SettingsPersistenceException( + $"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}", + ex); + } + finally + { + AppSettings.LicenseKey = plainLicenseKey; + AppSettings.DeploymentcenterToken = plainDeploymentcenterToken; + } + } +} + +public sealed class SettingsPersistenceException(string message, Exception inner) + : Exception(message, inner); diff --git a/src/ClawdDotNet.Core/Audit/AuditModels.cs b/src/ClawdDotNet.Core/Audit/AuditModels.cs new file mode 100644 index 0000000..86cb984 --- /dev/null +++ b/src/ClawdDotNet.Core/Audit/AuditModels.cs @@ -0,0 +1,110 @@ +namespace ClawdDotNet.Core.Audit; + +/// Ausgang eines Tool-Aufrufs, von der Engine festgestellt. +public enum AuditStatus +{ + /// Tool lief und lieferte ein Ergebnis. + Ok, + + /// Tool meldete einen Fehler oder warf eine Ausnahme. + Error, + + /// Das hat den Aufruf abgelehnt. + Denied, + + /// Das angeforderte Tool ist dem Agenten nicht zugewiesen/unbekannt. + NotFound, + + /// Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt. + Staged +} + +/// +/// Ein Eintrag im Audit-Log: ein einzelner Tool-Aufruf, wie die Engine ihn gesehen hat. +/// +/// Die Herkunft wird von der Engine gestempelt, nie vom Agenten behauptet: +/// , und stammen aus dem +/// Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Einträge sind +/// unveränderlich — eine Korrektur ist ein neuer Eintrag, kein Überschreiben. +/// +public sealed record AuditEntry +{ + public long Id { get; init; } + + /// Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs. + public string RunId { get; init; } = ""; + + public string AgentId { get; init; } = ""; + + /// Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source). + public string Model { get; init; } = ""; + + /// Verantwortliche Session/Kanal (webview, telegram, task …). unknown, + /// wenn nicht bekannt — geraten wird nichts. + public string Source { get; init; } = AuditSource.Unknown; + + public string Tool { get; init; } = ""; + + /// Übergebene Argumente (gekappt). Rohdaten, wie das Modell sie schickte. + public string Arguments { get; init; } = ""; + + public AuditStatus Status { get; init; } + + /// Kurze Notiz zum Ausgang (Fehlermeldung, knapper Hinweis). + public string Summary { get; init; } = ""; + + public long DurationMs { get; init; } + + public DateTime OccurredAt { get; init; } +} + +/// +/// Abschluss-Beleg eines Laufs (Receipt): das Ergebnis mit Schritten, Tokens und Kosten. +/// Verknüpft RunUsage mit einem Task und macht so C7 („Kosten pro Ergebnis") +/// weitgehend zum Abfallprodukt. +/// +public sealed record RunReceipt +{ + public long Id { get; init; } + public string RunId { get; init; } = ""; + public string AgentId { get; init; } = ""; + public string Model { get; init; } = ""; + public string Source { get; init; } = AuditSource.Unknown; + + /// Verknüpfter Task, falls der Lauf aus dem Taskboard kam — sonst null. + public string? TaskId { get; init; } + + /// Endzustand (aus AgentRunStatus). + public string Status { get; init; } = ""; + + public int StepCount { get; init; } + public int PromptTokens { get; init; } + public int CompletionTokens { get; init; } + public int CachedTokens { get; init; } + + public decimal CostUsd { get; init; } + public bool CostIsKnown { get; init; } + + public long DurationMs { get; init; } + + /// Kurzer Verweis auf das Ergebnis (gekappte Schlussnachricht). + public string ResultRef { get; init; } = ""; + + public DateTime OccurredAt { get; init; } +} + +/// Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit +/// ChatSource; steht für „nicht bekannt", nicht für geraten. +public static class AuditSource +{ + public const string Unknown = "unknown"; + + /// Ein direkter, quellenloser Lauf (z. B. RunAsync ohne Kanal). + public const string Direct = "direct"; + + /// Ausführung eines freigegebenen, eingefrorenen Aufrufs (A2). + public const string Approval = "approval"; + + public static string Normalize(string? source) + => string.IsNullOrWhiteSpace(source) ? Unknown : source.Trim(); +} diff --git a/src/ClawdDotNet.Core/Audit/IAuditRepository.cs b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs new file mode 100644 index 0000000..508f258 --- /dev/null +++ b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs @@ -0,0 +1,29 @@ +namespace ClawdDotNet.Core.Audit; + +/// +/// Das Audit-Log (A3): append-only. Es gibt kein Ändern und kein Löschen — Korrekturen +/// sind neue Einträge. Das ist die bewusste Designregel, nicht eine fehlende Funktion. +/// +public interface IAuditRepository +{ + /// Schreibt einen Tool-Aufruf ins Log. + Task AppendAsync(AuditEntry entry, CancellationToken ct); + + /// Hält den Abschluss-Beleg eines Laufs fest. + Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct); + + /// Die jüngsten Log-Einträge (für eine Übersicht/Diagnose). + Task> ListRecentAsync(int limit, CancellationToken ct); + + /// Alle Aufrufe eines Laufs, in zeitlicher Reihenfolge. + Task> ListForRunAsync(string runId, CancellationToken ct); + + /// Der Abschluss-Beleg eines Laufs, falls vorhanden. + Task GetReceiptForRunAsync(string runId, CancellationToken ct); + + /// Alle Belege zu einem Task — die Kosten pro Ergebnis (C7). + Task> ListReceiptsForTaskAsync(string taskId, CancellationToken ct); + + /// Zahl der Log-Einträge insgesamt. + Task CountAsync(CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs new file mode 100644 index 0000000..fda88f0 --- /dev/null +++ b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs @@ -0,0 +1,182 @@ +using System.Globalization; +using ClawdDotNet.Core.Storage; +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Audit; + +/// +/// Das Audit-Log in der Instanz-Datenbank. Bewusst nur Einfügen und Lesen — es gibt keine +/// Update-/Delete-Methoden, weil die Unveränderlichkeit die eigentliche Zusage ist. +/// +public sealed class SqliteAuditRepository : IAuditRepository +{ + private readonly SqliteStorage _storage; + + public SqliteAuditRepository(SqliteStorage storage) => _storage = storage; + + public Task AppendAsync(AuditEntry entry, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO AuditLog + (RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt) + VALUES + (@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt) + """; + cmd.Parameters.AddWithValue("@runId", entry.RunId); + cmd.Parameters.AddWithValue("@agentId", entry.AgentId); + cmd.Parameters.AddWithValue("@model", entry.Model); + cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source)); + cmd.Parameters.AddWithValue("@tool", entry.Tool); + cmd.Parameters.AddWithValue("@arguments", entry.Arguments); + cmd.Parameters.AddWithValue("@status", entry.Status.ToString()); + cmd.Parameters.AddWithValue("@summary", entry.Summary); + cmd.Parameters.AddWithValue("@durationMs", entry.DurationMs); + cmd.Parameters.AddWithValue("@occurredAt", Format(entry.OccurredAt)); + await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO RunReceipts + (RunId, AgentId, Model, Source, TaskId, Status, StepCount, + PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown, + DurationMs, ResultRef, OccurredAt) + VALUES + (@runId, @agentId, @model, @source, @taskId, @status, @stepCount, + @prompt, @completion, @cached, @cost, @costKnown, + @durationMs, @resultRef, @occurredAt) + """; + cmd.Parameters.AddWithValue("@runId", receipt.RunId); + cmd.Parameters.AddWithValue("@agentId", receipt.AgentId); + cmd.Parameters.AddWithValue("@model", receipt.Model); + cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(receipt.Source)); + cmd.Parameters.AddWithValue("@taskId", (object?)receipt.TaskId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@status", receipt.Status); + cmd.Parameters.AddWithValue("@stepCount", receipt.StepCount); + cmd.Parameters.AddWithValue("@prompt", receipt.PromptTokens); + cmd.Parameters.AddWithValue("@completion", receipt.CompletionTokens); + cmd.Parameters.AddWithValue("@cached", receipt.CachedTokens); + cmd.Parameters.AddWithValue("@cost", receipt.CostUsd.ToString(CultureInfo.InvariantCulture)); + cmd.Parameters.AddWithValue("@costKnown", receipt.CostIsKnown ? 1 : 0); + cmd.Parameters.AddWithValue("@durationMs", receipt.DurationMs); + cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef); + cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt)); + await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public async Task> ListRecentAsync(int limit, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog ORDER BY Id DESC LIMIT @limit"; + cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000)); + return await ReadEntriesAsync(cmd, ct); + } + + public async Task> ListForRunAsync(string runId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id"; + cmd.Parameters.AddWithValue("@runId", runId); + return await ReadEntriesAsync(cmd, ct); + } + + public async Task GetReceiptForRunAsync(string runId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1"; + cmd.Parameters.AddWithValue("@runId", runId); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null; + } + + public async Task> ListReceiptsForTaskAsync(string taskId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE TaskId = @taskId ORDER BY Id"; + cmd.Parameters.AddWithValue("@taskId", taskId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(ReadReceipt(reader)); + return results; + } + + public async Task CountAsync(CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM AuditLog"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); + } + + // ─── Hilfsfunktionen ─── + + private const string AuditColumns = + "Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt"; + + private const string ReceiptColumns = + "Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " + + "CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt"; + + private static async Task> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct) + { + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(ReadEntry(reader)); + return results; + } + + private static AuditEntry ReadEntry(SqliteDataReader r) => new() + { + Id = r.GetInt64(0), + RunId = r.GetString(1), + AgentId = r.GetString(2), + Model = r.GetString(3), + Source = r.GetString(4), + Tool = r.GetString(5), + Arguments = r.GetString(6), + Status = Enum.TryParse(r.GetString(7), out var s) ? s : AuditStatus.Ok, + Summary = r.GetString(8), + DurationMs = r.GetInt64(9), + OccurredAt = Parse(r.GetString(10)) + }; + + private static RunReceipt ReadReceipt(SqliteDataReader r) => new() + { + Id = r.GetInt64(0), + RunId = r.GetString(1), + AgentId = r.GetString(2), + Model = r.GetString(3), + Source = r.GetString(4), + TaskId = r.IsDBNull(5) ? null : r.GetString(5), + Status = r.GetString(6), + StepCount = r.GetInt32(7), + PromptTokens = r.GetInt32(8), + CompletionTokens = r.GetInt32(9), + CachedTokens = r.GetInt32(10), + CostUsd = decimal.TryParse(r.GetString(11), NumberStyles.Any, CultureInfo.InvariantCulture, out var c) ? c : 0m, + CostIsKnown = r.GetInt32(12) != 0, + DurationMs = r.GetInt64(13), + ResultRef = r.GetString(14), + OccurredAt = Parse(r.GetString(15)) + }; + + private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); + + private static DateTime Parse(string value) + => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) + ? dt + : DateTime.MinValue; +} diff --git a/src/ClawdDotNet.Core/Backup/BackupService.cs b/src/ClawdDotNet.Core/Backup/BackupService.cs index 9e3f774..0547ee0 100644 --- a/src/ClawdDotNet.Core/Backup/BackupService.cs +++ b/src/ClawdDotNet.Core/Backup/BackupService.cs @@ -377,11 +377,10 @@ public sealed class BackupService var root = Path.GetFullPath(targetDir); var full = Path.GetFullPath(Path.Combine(root, relative)); - var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar) - ? root - : root + Path.DirectorySeparatorChar; - - if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase)) + // Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel" + // zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische + // Verknüpfung hinauszeigen. Beides steckt in PathBoundary. + if (!PathBoundary.IsInside(full, root)) throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus."); return full; diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index ef22d28..b7ae115 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -188,6 +188,14 @@ public sealed class LoopGuardConfig [JsonPropertyName("maxContextTokens")] public int MaxContextTokens { get; set; } = 100_000; + /// + /// Obergrenze für die Ausgabe eines einzelnen Schritts (max_tokens im Request). + /// Deckelt die teuerste Token-Art gegen Ausreißer (B11/T8). 0 = keine Angabe, dann gilt + /// der Standard des Anbieters. + /// + [JsonPropertyName("maxResponseTokens")] + public int MaxResponseTokens { get; set; } = 8_192; + [JsonPropertyName("compactionThreshold")] public double CompactionThreshold { get; set; } = 0.80; diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index 47ef240..1d57e87 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -25,6 +25,10 @@ public sealed class InstanceConfig [JsonPropertyName("telegramClient")] public TelegramClientConfig? TelegramClient { get; set; } + /// Anbindung an das Watchdog-Modul des Deploymentcenters (Instanz-Heartbeat). + [JsonPropertyName("watchdog")] + public WatchdogConfig Watchdog { get; set; } = new(); + /// Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze. [JsonPropertyName("budget")] public InstanceBudget Budget { get; set; } = new(); diff --git a/src/ClawdDotNet.Core/Config/ServiceConfig.cs b/src/ClawdDotNet.Core/Config/ServiceConfig.cs index f3286ea..30d8f6f 100644 --- a/src/ClawdDotNet.Core/Config/ServiceConfig.cs +++ b/src/ClawdDotNet.Core/Config/ServiceConfig.cs @@ -34,6 +34,7 @@ public static class BuiltInServices public const string AgentChatWebUI = "AgentChatWebUI"; public const string AgentWebsite = "AgentWebsite"; public const string ClawdDotNetApi = "ClawdDotNetApi"; + public const string InstanceWatchdog = "InstanceWatchdog"; public static List CreateDefaults() => [ @@ -69,6 +70,17 @@ public static class BuiltInServices AutoStart = true, BuiltIn = true, Description = "REST-API für die Kommunikation mit der WebApp" + }, + new() + { + ServiceId = "svc_watchdog", + Name = "Instanz-Watchdog", + Type = InstanceWatchdog, + Port = 0, + Enabled = false, + AutoStart = true, + BuiltIn = true, + Description = "Sendet Heartbeats ans Deploymentcenter (URL/Token in den Anwendungseinstellungen)" } ]; } diff --git a/src/ClawdDotNet.Core/Config/WatchdogConfig.cs b/src/ClawdDotNet.Core/Config/WatchdogConfig.cs new file mode 100644 index 0000000..3de7e50 --- /dev/null +++ b/src/ClawdDotNet.Core/Config/WatchdogConfig.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace ClawdDotNet.Core.Config; + +/// +/// Pro-Instanz-Teil der Watchdog-Anbindung ans Deploymentcenter. +/// +/// Ein Monitor je Instanz. Der Monitor wird serverseitig über das Paar +/// source + instance geführt. Alle Instanzen melden unter derselben +/// und tragen ihre eigene — damit hat jede +/// laufende Instanz einen eigenen Zustand, ein eigenes Intervall und einen eigenen +/// Metrik-Verlauf. Fällt eine von dreien aus, fällt genau deren Monitor. +/// +/// Server-URL und das anwendungsweite Token liegen in den Anwendungseinstellungen. +/// Beim ersten Start tauscht die Instanz das Token gegen ein eigenes, eingeschränktes +/// Sub-Token (/api/tokens/v1/provision) und legt es hier verschlüsselt ab — +/// danach liegt auf der Instanz nicht mehr das Master-Token. +/// +/// Ein/Aus läuft über den eingebauten Dienst InstanceWatchdog. +/// +public sealed class WatchdogConfig +{ + /// Dienst-Kennung im Deploymentcenter. Alle Instanzen teilen sich dieselbe Source. + [JsonPropertyName("source")] + public string Source { get; set; } = "clawddotnet"; + + /// + /// Name dieser Instanz im Monitor. Leer bedeutet: die InstanceId wird + /// verwendet — stabil, aber im Dashboard nichtssagend. Wer lesbare Namen möchte, + /// trägt hier einen ein; ein späterer Wechsel legt allerdings einen neuen Monitor an. + /// + [JsonPropertyName("instance")] + public string Instance { get; set; } = ""; + + /// Gruppierung im Dashboard (reine Anzeige, keine Hierarchie). + [JsonPropertyName("group")] + public string Group { get; set; } = "ClawdDotNet"; + + /// + /// Sende-Takt in Sekunden. Daraus leitet der Evaluator die Schwellen ab: + /// nach dem Doppelten warning, nach dem Vierfachen down. + /// + [JsonPropertyName("intervalSeconds")] + public int IntervalSeconds { get; set; } = 60; + + /// + /// Das für diese Instanz ausgestellte Sub-Token. Wird automatisch gesetzt und + /// verschlüsselt gespeichert. + /// + [JsonPropertyName("agentToken")] + public string AgentToken { get; set; } = ""; + + /// True, sobald ein eigenes Token vorliegt. + [JsonIgnore] + public bool HasToken => !string.IsNullOrWhiteSpace(AgentToken); + + /// Der Wert, der als instance gemeldet wird. + public string ResolveInstance(string instanceId) => + string.IsNullOrWhiteSpace(Instance) ? instanceId : Instance.Trim(); +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs b/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs new file mode 100644 index 0000000..3ca3648 --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs @@ -0,0 +1,89 @@ +using System.Text.Json; + +namespace ClawdDotNet.Core.Deploymentcenter; + +/// Was aus einem Bugtracker-Eintrag geworden ist. +/// Nummer des Eintrags im Deploymentcenter. +/// False, wenn ein bestehender Eintrag hochgezählt wurde. +/// Wie oft dieses Vorkommnis bisher gezählt wurde. +/// Adresse der Übersicht, für einen Hinweis an den Benutzer. +public sealed record BugtrackerReport(long ItemId, bool IsNew, int OccurrenceCount, string? Url); + +/// Art eines Eintrags. Der Server kennt darüber hinaus noch idea. +public static class BugtrackerItemType +{ + public const string Bug = "bug"; + public const string Feature = "feature"; + public const string Idea = "idea"; +} + +/// +/// Anbindung an POST /api/bugtracker/v1/report — der Weg, auf dem ClawdDotNet +/// selbst (oder ein Benutzer über die Oberfläche) einen Fehler oder Wunsch einträgt. +/// +/// Abgegrenzt vom : Der meldet ungefangene +/// Ausnahmen automatisch; hier geht es um bewusst formulierte Einträge mit Titel und +/// Beschreibung. Serverseitig landen beide in derselben Tabelle — was richtig ist, +/// denn ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste. +/// +/// Der Absender kommt aus dem Token und lässt sich nicht frei wählen — +/// sonst könnte sich ein Agent als ein anderer ausgeben. +/// +public sealed class BugtrackerClient( + DeploymentcenterApi api, string projectSlug, string environment, string build) +{ + /// Siehe . + /// + /// Freier Idempotenz-Schlüssel. Zweimal derselbe Wert erzeugt keinen zweiten + /// Eintrag — nützlich, wenn eine Meldung nach einem Verbindungsabbruch wiederholt + /// wird. + /// + public async Task ReportAsync( + string type, + string title, + string? description = null, + string severity = "medium", + string? clientRef = null, + IReadOnlyDictionary? context = null, + CancellationToken ct = default) + { + var payload = new Dictionary + { + ["project_slug"] = projectSlug, + ["type"] = type, + ["title"] = title, + ["description"] = description, + ["severity"] = severity, + ["environment"] = environment, + ["build_version"] = build + }; + + if (!string.IsNullOrWhiteSpace(clientRef)) + payload["client_ref"] = clientRef; + + if (context is { Count: > 0 }) + payload["context"] = context; + + var response = await api.PostAsync("/api/bugtracker/v1/report", payload, ct) + .ConfigureAwait(false); + + return new BugtrackerReport( + ReadLong(response, "item_id"), + ReadBool(response, "is_new"), + (int)ReadLong(response, "occurrence_count"), + ReadString(response, "url")); + } + + private static long ReadLong(JsonElement root, string name) => + root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number + ? v.GetInt64() + : 0; + + private static bool ReadBool(JsonElement root, string name) => + root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; + + private static string? ReadString(JsonElement root, string name) => + root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs b/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs new file mode 100644 index 0000000..19fd3d5 --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs @@ -0,0 +1,172 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ClawdDotNet.Core.Deploymentcenter; + +/// +/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream, +/// Bugtracker, Token-Provisionierung). +/// +/// Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag — +/// {"status":"success",…} bzw. {"status":"error","error":{"code":…}}. Der +/// code ist stabil und für Programme gedacht, die message für Menschen. +/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine +/// mit dem Code daran. +/// +/// Ausnahme: Die Lizenz-Endpunkte tragen diesen Umschlag bewusst +/// nicht — dort steht im Feld status der Lizenzzustand. Sie werden +/// deshalb nicht hierüber, sondern über Deploymentcenter.Client angesprochen. +/// +public sealed class DeploymentcenterApi : IDisposable +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + private readonly HttpClient _http; + private readonly bool _ownsHttp; + private readonly string _token; + + /// Die Basis-URL ohne abschließenden Schrägstrich. + public string BaseUrl { get; } + + /// Basis-URL des Deploymentcenters, etwa https://dc.mhdf.de. + /// Token mit den nötigen Rechten. Geht als Authorization: Bearer mit. + /// Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt. + public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl)); + + // Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist + // nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören + // könnte, und eine lokale Testinstallation hat selten ein Zertifikat. + if (!IsAcceptableUrl(baseUrl)) + { + throw new ArgumentException( + "Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).", + nameof(baseUrl)); + } + + BaseUrl = baseUrl.TrimEnd('/'); + _token = token ?? throw new ArgumentNullException(nameof(token)); + + _ownsHttp = httpClient is null; + _http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + } + + private static bool IsAcceptableUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + if (uri.Scheme == Uri.UriSchemeHttps) + return true; + + return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback; + } + + public async Task PostAsync(string path, object payload, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path) + { + Content = new StringContent( + JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json") + }; + + return await SendAsync(request, ct).ConfigureAwait(false); + } + + public async Task GetAsync(string path, CancellationToken ct) + { + using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path); + return await SendAsync(request, ct).ConfigureAwait(false); + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (_token.Length > 0) + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token); + + using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + + JsonElement root; + try + { + // Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird — + // ein JsonElement daraus wäre danach nicht mehr lesbar. + using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + root = document.RootElement.Clone(); + } + catch (JsonException) + { + throw new DeploymentcenterException( + "invalid_response", + $"Antwort war kein JSON (HTTP {(int)response.StatusCode}).", + response.StatusCode); + } + + if (IsErrorEnvelope(root, out var code, out var message)) + throw new DeploymentcenterException(code, message, response.StatusCode); + + if (!response.IsSuccessStatusCode) + { + throw new DeploymentcenterException( + "http_error", + $"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.", + response.StatusCode); + } + + return root; + } + + private static bool IsErrorEnvelope(JsonElement root, out string code, out string message) + { + code = "error"; + message = "Unbekannter Fehler."; + + if (root.ValueKind != JsonValueKind.Object) + return false; + + if (!root.TryGetProperty("status", out var status) + || status.ValueKind != JsonValueKind.String + || !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) + { + if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String) + code = c.GetString() ?? code; + + if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String) + message = m.GetString() ?? message; + } + + return true; + } + + public void Dispose() + { + if (_ownsHttp) + _http.Dispose(); + } +} + +/// +/// Ein vom Deploymentcenter abgelehnter Aufruf. ist der stabile +/// Fehlercode aus dem Umschlag (unauthorized, rate_limited, …) — er ist +/// zum Auswerten gedacht, der Text nicht. +/// +public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode) + : Exception($"{message} [{code}]") +{ + public string Code { get; } = code; + + public HttpStatusCode StatusCode { get; } = statusCode; + + /// Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab. + public bool IsAuthorizationProblem => + StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden; +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs b/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs new file mode 100644 index 0000000..75e475c --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Deploymentcenter; + +/// Meldet Laufzeitfehler an das Deploymentcenter. +public interface IErrorReporter +{ + /// + /// Meldet eine Ausnahme. heißt: Der Prozess endet daran. + /// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen. + /// + Task ReportAsync(Exception exception, bool fatal = false, + IReadOnlyDictionary? context = null, CancellationToken ct = default); +} + +/// Tut nichts. Für abgeschaltete Meldung und für Tests. +public sealed class NullErrorReporter : IErrorReporter +{ + public static readonly NullErrorReporter Instance = new(); + + public Task ReportAsync(Exception exception, bool fatal = false, + IReadOnlyDictionary? context = null, CancellationToken ct = default) + => Task.FromResult(false); +} + +/// +/// Anbindung an POST /api/errors/v1/report — den Eingang für den globalen +/// Ausnahmebehandler. +/// +/// Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die +/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter, +/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt +/// Alarm, wenn er plötzlich hundertmal so oft auftritt. +/// +/// Eigener Schutz gegen Fehlerschleifen. Der Server drosselt auf 300 +/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler +/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das +/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server +/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung. +/// +public sealed class ErrorReporter : IErrorReporter, IDisposable +{ + /// Wie lange derselbe Fehler nach einer Meldung stumm bleibt. + private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5); + + /// Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab. + private const int MaxStackTraceLength = 8000; + + private readonly DeploymentcenterApi _api; + private readonly bool _ownsApi; + private readonly string _projectSlug; + private readonly string _environment; + private readonly string _build; + private readonly ILogger _logger; + private readonly Func _now; + + private readonly Dictionary _lastSent = []; + private readonly Lock _gate = new(); + + public ErrorReporter( + DeploymentcenterApi api, + string projectSlug, + string environment, + string build, + ILogger logger, + bool ownsApi = false, + Func? now = null) + { + _api = api; + _projectSlug = projectSlug; + _environment = environment; + _build = build; + _logger = logger; + _ownsApi = ownsApi; + _now = now ?? (() => DateTimeOffset.UtcNow); + } + + public async Task ReportAsync(Exception exception, bool fatal = false, + IReadOnlyDictionary? context = null, CancellationToken ct = default) + { + if (!ShouldSend(exception)) + return false; + + var payload = new Dictionary + { + ["project_slug"] = _projectSlug, + ["exception"] = exception.GetType().FullName, + ["message"] = exception.Message, + ["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength), + ["level"] = fatal ? "fatal" : "error", + ["build"] = _build, + ["environment"] = _environment, + + // Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der + // Server den Zähler, statt einen zweiten Eintrag anzulegen. + ["client_ref"] = Fingerprint(exception) + }; + + if (context is { Count: > 0 }) + payload["context"] = context; + + if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType) + payload["file"] = declaringType; + + try + { + await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + // Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten: + // Der ursprüngliche Fehler ginge dabei verloren. + _logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert)."); + return false; + } + } + + /// Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet. + private bool ShouldSend(Exception exception) + { + var key = Fingerprint(exception); + var now = _now(); + + lock (_gate) + { + if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow) + return false; + + // Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst. + if (_lastSent.Count > 200) + { + foreach (var stale in _lastSent + .Where(e => now - e.Value > RepeatWindow) + .Select(e => e.Key) + .ToList()) + { + _lastSent.Remove(stale); + } + } + + _lastSent[key] = now; + return true; + } + } + + /// + /// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung + /// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und + /// dann wäre jeder Aufruf ein neuer Fehler. + /// + private static string Fingerprint(Exception exception) + { + var frame = exception.StackTrace? + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault()? + .Trim() ?? ""; + + return $"{exception.GetType().FullName}|{frame}"; + } + + private static string Truncate(string value, int max) => + value.Length <= max ? value : value[..max] + "\n… (gekürzt)"; + + public void Dispose() + { + if (_ownsApi) + _api.Dispose(); + } +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs b/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs new file mode 100644 index 0000000..4ea743c --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace ClawdDotNet.Core.Deploymentcenter; + +/// Ein für diese Instanz ausgestelltes Sub-Token. +/// Der Klartext — wird nur einmal ausgeliefert. +/// Kennung zum Widerrufen in der Verwaltung. +/// Welche Rechte tatsächlich durchgereicht wurden. +public sealed record ProvisionedToken(string Token, string TokenId, IReadOnlyList Scopes); + +/// +/// Tauscht das anwendungsweite Master-Token gegen ein eigenes Sub-Token je Instanz +/// (POST /api/tokens/v1/provision). +/// +/// Das ersetzt die frühere Selbstregistrierung über POST /api/register — +/// diesen Endpunkt gibt es im Deploymentcenter nicht (und im alten WatchDog-Server war +/// er der einzige Weg, überhaupt an einen Token zu kommen). Der Zweck bleibt derselbe +/// und ist es wert, erhalten zu bleiben: Auf den Instanzen liegt danach nicht das +/// Master-Token, sondern ein eingeschränktes, einzeln widerrufbares. +/// +/// Rechte lassen sich dabei nur einschränken, nie erweitern — was das +/// Master-Token nicht hat, bekommt auch das Sub-Token nicht. +/// +public sealed class TokenProvisioner(DeploymentcenterApi api) +{ + /// Was eine Instanz braucht: Heartbeats senden und Fehler melden. + public static readonly string[] InstanceScopes = ["watchdog:ping", "bugtracker:report"]; + + public async Task ProvisionAsync( + string clientName, + string instanceId, + IReadOnlyList scopes, + string environment = "production", + CancellationToken ct = default) + { + var payload = new + { + client_name = clientName, + instance_id = instanceId, + scopes, + environment + }; + + var response = await api.PostAsync("/api/tokens/v1/provision", payload, ct) + .ConfigureAwait(false); + + var token = response.TryGetProperty("sub_token", out var t) && t.ValueKind == JsonValueKind.String + ? t.GetString() + : null; + + if (string.IsNullOrWhiteSpace(token)) + { + throw new DeploymentcenterException( + "no_token", + "Die Provisionierung lieferte kein Token.", + System.Net.HttpStatusCode.OK); + } + + var tokenId = response.TryGetProperty("token_id", out var i) && i.ValueKind == JsonValueKind.String + ? i.GetString() ?? "" + : ""; + + var granted = new List(); + if (response.TryGetProperty("scopes", out var s) && s.ValueKind == JsonValueKind.Array) + { + granted.AddRange(s.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString()!)); + } + + return new ProvisionedToken(token, tokenId, granted); + } +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs new file mode 100644 index 0000000..627b491 --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealth.cs @@ -0,0 +1,54 @@ +namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; + +/// +/// Die Statuswerte, die der Watchdog kennt. stopped und maintenance sind +/// angekündigte Zustände — der Evaluator lässt solche Monitore in Ruhe, statt wenige +/// Minuten nach einem geplanten Herunterfahren einen Fehlalarm zu erzeugen. +/// +public static class WatchdogStatus +{ + public const string Ok = "ok"; + public const string Warning = "warning"; + public const string Error = "error"; + public const string Stopped = "stopped"; + public const string Maintenance = "maintenance"; +} + +/// +/// Eine selbst ermittelte Teilprüfung. Das Deploymentcenter interpretiert den Namen +/// nicht — es liest nur und . Was „gesund" +/// bedeutet, entscheidet damit jede Anwendung selbst. +/// +/// Schlägt eine Prüfung fehl, stuft der Server einen als ok gemeldeten +/// Heartbeat auf warning herab. Das ist der Unterschied zwischen „ein Faden +/// läuft" und „die Anwendung tut, was sie soll". +/// +public sealed record HealthCheck(bool Ok, string? Message = null); + +/// Momentaufnahme des Instanz-Zustands für einen Heartbeat. +/// Einer der Werte aus . +/// Kurzbegründung, erscheint im Dashboard. +/// +/// Nur Zahlen: Das Deploymentcenter legt sie mit Zeitstempel ab (14 Tage) und vergleicht +/// den aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische +/// Werte würden dabei stillschweigend verworfen — beschreibende Angaben gehören +/// deshalb in oder in die Checks. +/// +/// Selbst ermittelter Gesundheitszustand je Teilbereich. +public sealed record InstanceHealth( + string Status, + string? Message, + IReadOnlyDictionary Metrics, + IReadOnlyDictionary Checks) +{ + public static InstanceHealth Ok(string? message = null) => new( + WatchdogStatus.Ok, message, + new Dictionary(), + new Dictionary()); +} + +/// Liefert vor jedem Heartbeat den aktuellen Instanz-Zustand. +public interface IInstanceHealthProvider +{ + Task GetAsync(CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs new file mode 100644 index 0000000..e4100ea --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/InstanceHealthProvider.cs @@ -0,0 +1,128 @@ +using ClawdDotNet.Core.Accounting; +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; + +/// +/// Leitet den Instanz-Zustand für den Heartbeat ab. +/// +/// Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der +/// selbst ermittelte Gesundheitszustand als checks mit — der Server stuft einen +/// als ok gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in +/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet +/// brav ok, während der Aufgaben-Scanner seit einer Stunde tot ist. +/// +/// +/// error — kein OpenRouter-Key konfiguriert (Agenten deaktiviert). +/// warning — Tagesbudget der Instanz erschöpft oder Scanner steht. +/// ok — sonst. +/// +/// +/// Die Metriken sind bewusst schlank und ausschließlich numerisch: keine +/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf. +/// +public sealed class InstanceHealthProvider : IInstanceHealthProvider +{ + private readonly string _instanceName; + private readonly bool _agentsEnabled; + private readonly InstanceBudget _budget; + private readonly IUsageRepository? _usage; + private readonly Func _agentCount; + private readonly Func _runningChats; + private readonly Func? _schedulerRunning; + private readonly Func _now; + + public InstanceHealthProvider( + string instanceName, + bool agentsEnabled, + InstanceBudget budget, + IUsageRepository? usage, + Func agentCount, + Func runningChats, + Func? schedulerRunning = null, + Func? now = null) + { + _instanceName = instanceName; + _agentsEnabled = agentsEnabled; + _budget = budget; + _usage = usage; + _agentCount = agentCount; + _runningChats = runningChats; + _schedulerRunning = schedulerRunning; + _now = now ?? (() => DateTime.Now); + } + + public async Task GetAsync(CancellationToken ct) + { + var metrics = new Dictionary + { + ["agentCount"] = _agentCount(), + ["runningChats"] = _runningChats() + }; + + var checks = new Dictionary + { + ["agents"] = new(_agentsEnabled, + _agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.") + }; + + if (_schedulerRunning is not null) + { + var running = _schedulerRunning(); + checks["scheduler"] = new(running, + running ? null : "Aufgaben-Scanner läuft nicht."); + } + + string? budgetProblem = null; + + if (_usage is not null) + { + var today = DateOnly.FromDateTime(_now()); + var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false); + + metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4); + metrics["todayTokens"] = used.TotalTokens; + + if (Exceeds(_budget.DailyCostUsd, used.CostUsd)) + { + budgetProblem = + $"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD."; + } + else if (Exceeds(_budget.DailyTokens, used.TotalTokens)) + { + budgetProblem = + $"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}."; + } + + checks["budget"] = new(budgetProblem is null, budgetProblem); + } + + // Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind + // Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen. + if (!_agentsEnabled) + { + return new InstanceHealth( + WatchdogStatus.Error, + $"{_instanceName}: Kein OpenRouter-API-Key konfiguriert – Agenten deaktiviert.", + metrics, checks); + } + + if (budgetProblem is not null) + return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks); + + if (_schedulerRunning is not null && !_schedulerRunning()) + { + return new InstanceHealth( + WatchdogStatus.Warning, + $"{_instanceName}: Aufgaben-Scanner läuft nicht.", + metrics, checks); + } + + return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks); + } + + /// 0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard. + private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit; + + private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit; +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs new file mode 100644 index 0000000..7413204 --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogClient.cs @@ -0,0 +1,174 @@ +using System.Text.Json; + +namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; + +/// Was der Server zu einem Heartbeat zurückmeldet. +/// Der daraus abgeleitete Monitor-Zustand (up, warning, …). +/// Welche der mitgeschickten Prüfungen fehlgeschlagen sind. +public sealed record WatchdogPingResult(string State, IReadOnlyList FailingChecks); + +/// +/// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters. +/// +public interface IWatchdogClient +{ + Task SendHeartbeatAsync( + InstanceHealth health, int intervalSeconds, CancellationToken ct); + + Task SendEventAsync( + string kind, string severity, string? message, object? meta, CancellationToken ct); +} + +/// +/// Watchdog-Anbindung: POST /api/watchdog/v1/ping und +/// POST /api/watchdog/v1/event. +/// +/// Ein Monitor je Instanz. Der Schlüssel des Monitors ist das Paar +/// source + instance (so das Datenbankschema: +/// UNIQUE KEY uq_monitor (source, instance)). Alle ClawdDotNet-Instanzen melden +/// unter derselben source und tragen ihre eigene instance — damit ist jede +/// laufende Instanz ein eigener Monitor mit eigenem Zustand, eigenem Intervall und +/// eigenem Metrik-Verlauf. Stürzt eine von dreien ab, fällt genau deren Monitor. +/// +/// Der Monitor entsteht beim ersten Heartbeat von selbst (INSERT … ON DUPLICATE +/// KEY UPDATE) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht +/// nötig. +/// +public sealed class WatchdogClient : IWatchdogClient, IDisposable +{ + private readonly DeploymentcenterApi _api; + private readonly bool _ownsApi; + private readonly string _source; + private readonly string _instance; + private readonly string _group; + private readonly string _os; + private readonly string _version; + + public WatchdogClient( + DeploymentcenterApi api, string source, string instance, string group, string os, + string version, bool ownsApi = false) + { + _api = api; + _ownsApi = ownsApi; + _source = source; + _instance = instance; + _group = group; + _os = os; + _version = version; + } + + public static WatchdogClient Create( + string baseUrl, string token, string source, string instance, string group, string os, + string version, HttpClient? httpClient = null) + => new(new DeploymentcenterApi(baseUrl, token, httpClient), + source, instance, group, os, version, ownsApi: true); + + public async Task SendHeartbeatAsync( + InstanceHealth health, int intervalSeconds, CancellationToken ct) + { + var payload = new Dictionary + { + ["source"] = _source, + ["instance"] = _instance, + ["type"] = "heartbeat", + ["status"] = health.Status, + ["interval"] = intervalSeconds, + ["message"] = health.Message, + ["group"] = _group, + ["os"] = _os, + + // Landet in watchdog_monitors.app_version. Damit steht im Dashboard, welche + // Fassung eine Instanz gerade fährt — bei mehreren Instanzen der + // Unterschied zwischen „läuft" und „läuft noch auf der alten Version". + ["version"] = _version + }; + + // Leere Objekte weglassen: Der Server übernimmt health_json nur, wenn etwas + // mitkommt — ein leeres würde den letzten bekannten Zustand nicht ersetzen, + // aber unnötig Platz im Protokoll kosten. + if (health.Checks.Count > 0) + { + payload["checks"] = health.Checks.ToDictionary( + c => c.Key, + c => (object)new { ok = c.Value.Ok, message = c.Value.Message }); + } + + if (health.Metrics.Count > 0) + payload["metrics"] = health.Metrics; + + var response = await _api.PostAsync("/api/watchdog/v1/ping", payload, ct) + .ConfigureAwait(false); + + return ReadPingResult(response); + } + + private static WatchdogPingResult ReadPingResult(JsonElement response) + { + if (!response.TryGetProperty("monitor", out var monitor) + || monitor.ValueKind != JsonValueKind.Object) + { + return new WatchdogPingResult("unknown", []); + } + + var state = monitor.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString() ?? "unknown" + : "unknown"; + + var failing = new List(); + if (monitor.TryGetProperty("failing_checks", out var checks) + && checks.ValueKind == JsonValueKind.Array) + { + failing.AddRange(checks.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString()!)); + } + + return new WatchdogPingResult(state, failing); + } + + /// + /// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige + /// -Werte siehe — der Server + /// weist andere ab. + /// + public async Task SendEventAsync( + string kind, string severity, string? message, object? meta, CancellationToken ct) + { + var payload = new + { + source = _source, + instance = _instance, + kind, + severity, + message, + meta + }; + + await _api.PostAsync("/api/watchdog/v1/event", payload, ct).ConfigureAwait(false); + } + + public void Dispose() + { + if (_ownsApi) + _api.Dispose(); + } +} + +/// +/// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte +/// start und stop — beide stehen nicht auf dieser Liste und wurden +/// stillschweigend als started abgelegt. +/// +public static class WatchdogEventKind +{ + public const string Started = "started"; + public const string StoppedGraceful = "stopped_graceful"; + public const string CrashSuspected = "crash_suspected"; + public const string HardError = "hard_error"; + public const string Recovered = "recovered"; + public const string WarningRaised = "warning_raised"; + public const string WarningCleared = "warning_cleared"; + public const string MaintenanceStart = "maintenance_start"; + public const string MaintenanceEnd = "maintenance_end"; + public const string WatchdogStarted = "watchdog_started"; +} diff --git a/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs new file mode 100644 index 0000000..c00ebbe --- /dev/null +++ b/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; + +/// +/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende. +/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen — +/// alle Sendefehler werden geloggt und verschluckt. +/// +/// Sauberes Beenden. Beim Herunterfahren geht ein Heartbeat mit +/// status: "stopped" raus. Der Evaluator lässt einen so gemeldeten Monitor in +/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen +/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den +/// Monitor-Zustand. +/// +public sealed class WatchdogHeartbeatService : IAsyncDisposable +{ + private readonly IWatchdogClient _client; + private readonly bool _ownsClient; + private readonly IInstanceHealthProvider _health; + private readonly int _intervalSeconds; + private readonly ILogger _logger; + + private CancellationTokenSource? _cts; + private Task? _loop; + private string _lastState = "unknown"; + + public WatchdogHeartbeatService( + IWatchdogClient client, + IInstanceHealthProvider health, + int intervalSeconds, + ILogger logger, + bool ownsClient = false) + { + _client = client; + _health = health; + _intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400); + _logger = logger; + _ownsClient = ownsClient; + } + + /// + /// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration + /// (fehlende oder nicht-HTTPS-URL). + /// + public static WatchdogHeartbeatService Create( + string baseUrl, string token, string source, string instance, string group, string os, + string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger) + { + var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version); + return new WatchdogHeartbeatService( + client, health, intervalSeconds, logger, ownsClient: true); + } + + public bool IsRunning => _loop is { IsCompleted: false }; + + /// Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige. + public string LastState => _lastState; + + public void Start() + { + if (IsRunning) + return; + + _cts = new CancellationTokenSource(); + _loop = RunAsync(_cts.Token); + _logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds); + } + + private async Task RunAsync(CancellationToken ct) + { + await TrySendAsync( + () => _client.SendEventAsync( + WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct), + "Start-Ereignis").ConfigureAwait(false); + + // Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen + // Intervall im Dashboard auftaucht. + await BeatAsync(ct).ConfigureAwait(false); + + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds)); + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + await BeatAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Regulärer Stopp. + } + } + + private async Task BeatAsync(CancellationToken ct) + { + InstanceHealth health; + try + { + health = await _health.GetAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen + // rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall. + _logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning."); + health = new InstanceHealth( + WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.", + new Dictionary(), new Dictionary()); + } + + await TrySendAsync(async () => + { + var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct) + .ConfigureAwait(false); + + if (result.State != _lastState) + { + _logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}", + _lastState, result.State, + result.FailingChecks.Count > 0 + ? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})" + : ""); + + _lastState = result.State; + } + }, "Heartbeat").ConfigureAwait(false); + } + + private async Task TrySendAsync(Func send, string what) + { + try + { + await send().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem) + { + // Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor + // für immer stumm, und niemand merkt es, weil ja nichts abstürzt. + _logger.LogWarning( + "Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).", + what, ex.Code); + } + catch (Exception ex) + { + // Ausfall des Monitorings darf den Betrieb nie stören. + _logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what); + } + } + + public async ValueTask DisposeAsync() + { + if (_cts is null) + return; + + await _cts.CancelAsync().ConfigureAwait(false); + + if (_loop is not null) + { + try { await _loop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* erwartet */ } + catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); } + } + + // Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein + // Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft. + try + { + using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + await _client.SendHeartbeatAsync( + new InstanceHealth( + WatchdogStatus.Stopped, "Instanz planmäßig beendet.", + new Dictionary(), new Dictionary()), + _intervalSeconds, stopCts.Token).ConfigureAwait(false); + + await _client.SendEventAsync( + WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert)."); + } + + _cts.Dispose(); + + if (_ownsClient && _client is IDisposable disposable) + disposable.Dispose(); + } +} diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 1934c8c..14d5773 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Memory; @@ -14,13 +15,16 @@ using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Engine; -public sealed class AgentEngine : IAgentMessageRouter +public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecutor { private readonly IChatCompletionClient _client; private readonly ToolRegistry _toolRegistry; private readonly PermissionGate _permissionGate; private readonly IStateStore _stateStore; private readonly IMemoryRepository? _memoryRepository; + private readonly Tasks.ITaskRepository? _taskRepository; + private readonly IAuditRepository? _auditRepository; + private readonly Staging.StagingGate? _stagingGate; private readonly IUsageRepository? _usageRepository; private readonly BudgetGuard? _budgetGuard; private readonly ModelPricingCatalog? _pricing; @@ -67,7 +71,10 @@ public sealed class AgentEngine : IAgentMessageRouter ILoggerFactory loggerFactory, IMemoryRepository? memoryRepository = null, IUsageRepository? usageRepository = null, - ModelPricingCatalog? pricing = null) + ModelPricingCatalog? pricing = null, + Tasks.ITaskRepository? taskRepository = null, + IAuditRepository? auditRepository = null, + Staging.StagingGate? stagingGate = null) { _client = client; _toolRegistry = toolRegistry; @@ -75,6 +82,9 @@ public sealed class AgentEngine : IAgentMessageRouter _stateStore = stateStore; _loggerFactory = loggerFactory; _memoryRepository = memoryRepository; + _taskRepository = taskRepository; + _auditRepository = auditRepository; + _stagingGate = stagingGate; _usageRepository = usageRepository; _pricing = pricing; _budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository); @@ -100,14 +110,18 @@ public sealed class AgentEngine : IAgentMessageRouter AgentConfig agentConfig, string userMessage, string instanceId, - CancellationToken externalCt) + CancellationToken externalCt, + string? source = null, + string? taskId = null) { // Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten. if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) return denied; - var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt); + var runId = Guid.NewGuid().ToString("N"); + var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source); await RecordUsageAsync(agentConfig, result); + await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId); return result; } @@ -115,7 +129,9 @@ public sealed class AgentEngine : IAgentMessageRouter AgentConfig agentConfig, string userMessage, string instanceId, - CancellationToken externalCt) + CancellationToken externalCt, + string runId, + string? source) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); @@ -157,7 +173,11 @@ public sealed class AgentEngine : IAgentMessageRouter { Model = agentConfig.Model, Messages = messages, - Tools = toolDefinitions.Count > 0 ? toolDefinitions : null + Tools = toolDefinitions.Count > 0 ? toolDefinitions : null, + // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen. + MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0 + ? agentConfig.LoopGuard.MaxResponseTokens + : null }; var response = await _client.CompleteAsync(request, ct); @@ -194,7 +214,7 @@ public sealed class AgentEngine : IAgentMessageRouter foreach (var toolCall in assistantMessage.ToolCalls) { var toolResult = await ExecuteToolCallAsync( - toolCall, agentConfig, instanceId, tools, logger, ct); + toolCall, agentConfig, instanceId, tools, logger, ct, runId, source); messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); } @@ -282,11 +302,14 @@ public sealed class AgentEngine : IAgentMessageRouter string userMessage, string instanceId, CancellationToken externalCt, - string? source = null) + string? source = null, + string? taskId = null) { if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) return denied; + var runId = Guid.NewGuid().ToString("N"); + // Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine // wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat. using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); @@ -307,8 +330,9 @@ public sealed class AgentEngine : IAgentMessageRouter try { - var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source); + var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId); await RecordUsageAsync(agentConfig, result); + await RecordReceiptAsync(runId, agentConfig, result, source, taskId); return result; } finally @@ -323,7 +347,8 @@ public sealed class AgentEngine : IAgentMessageRouter string userMessage, string instanceId, CancellationToken runCt, - string? source) + string? source, + string runId) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); @@ -380,7 +405,11 @@ public sealed class AgentEngine : IAgentMessageRouter { Model = agentConfig.Model, Messages = messages, - Tools = toolDefinitions.Count > 0 ? toolDefinitions : null + Tools = toolDefinitions.Count > 0 ? toolDefinitions : null, + // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen. + MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0 + ? agentConfig.LoopGuard.MaxResponseTokens + : null }; var response = await _client.CompleteAsync(request, ct); @@ -418,7 +447,7 @@ public sealed class AgentEngine : IAgentMessageRouter foreach (var toolCall in assistantMessage.ToolCalls) { var toolResult = await ExecuteToolCallAsync( - toolCall, agentConfig, instanceId, tools, logger, ct); + toolCall, agentConfig, instanceId, tools, logger, ct, runId, source); messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); } @@ -538,6 +567,12 @@ public sealed class AgentEngine : IAgentMessageRouter return _runningChats.ContainsKey(agentId); } + /// Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat. + public int RunningChatCount + { + get { lock (_lock) return _runningChats.Count; } + } + /// /// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten, /// die beim nächsten Schritt tatsächlich an das Modell gehen. @@ -805,9 +840,19 @@ public sealed class AgentEngine : IAgentMessageRouter string instanceId, IReadOnlyList availableTools, ILogger logger, - CancellationToken ct) + CancellationToken ct, + string runId, + string? source) { var toolName = toolCall.Function.Name; + var arguments = toolCall.Function.Arguments ?? ""; + var sw = Stopwatch.StartNew(); + + // Das Audit wird von der Engine gestempelt (A3) — Herkunft aus dem Wissen der + // Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf + // nicht scheitern lassen. + Task Audit(AuditStatus status, string summary) + => RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds); try { @@ -815,29 +860,36 @@ public sealed class AgentEngine : IAgentMessageRouter var tool = availableTools.FirstOrDefault(t => t.Name == toolName); if (tool is null) + { + await Audit(AuditStatus.NotFound, $"Tool '{toolName}' nicht zugewiesen/unbekannt"); return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found.")); + } + + // Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt + // ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen. + if (_stagingGate is not null) + { + var intercept = await _stagingGate.InterceptAsync( + agentConfig.AgentId, instanceId, runId, toolName, arguments, ct); + + if (intercept.Outcome == Staging.StagingOutcome.Denied) + { + await Audit(AuditStatus.Denied, intercept.Message); + return JsonSerializer.Serialize(new { error = intercept.Message }); + } + + if (intercept.Outcome == Staging.StagingOutcome.Staged) + { + await Audit(AuditStatus.Staged, intercept.Message); + return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis + } + } var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments) ? default : JsonDocument.Parse(toolCall.Function.Arguments).RootElement; - var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg) - ? cfg.AsReadOnly() - : new Dictionary().AsReadOnly(); - - var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution"); - - var context = new AgentToolContext( - agentConfig.AgentId, - instanceId, - toolConfig, - _stateStore, - toolLogger, - ct, - agentConfig.WorkspacePath, - agentConfig.SharedWorkspacePath, - this, - _memoryRepository); + var context = BuildToolContext(agentConfig, instanceId, toolName, ct); logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId); @@ -846,7 +898,10 @@ public sealed class AgentEngine : IAgentMessageRouter logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success); if (!result.Success) + { + await Audit(AuditStatus.Error, result.ErrorMessage ?? ""); return JsonSerializer.Serialize(new { error = result.ErrorMessage }); + } var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars); if (content.Length != result.Content.Length) @@ -856,22 +911,92 @@ public sealed class AgentEngine : IAgentMessageRouter toolName, result.Content.Length, agentConfig.MaxToolResultChars); } + await Audit(AuditStatus.Ok, ""); return content; } catch (ToolAccessDeniedException ex) { logger.LogWarning("Tool access denied: {Message}", ex.Message); + await Audit(AuditStatus.Denied, ex.Message); return JsonSerializer.Serialize(new { error = ex.Message }); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen - // Schritt weiter und der Abbruch greift erst verzögert. + // Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit — + // der Aufruf kam nicht zum Abschluss. throw; } catch (Exception ex) { logger.LogError(ex, "Tool {Tool} threw an exception", toolName); + await Audit(AuditStatus.Error, ex.Message); + return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" }); + } + } + + private AgentToolContext BuildToolContext( + AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct) + { + var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg) + ? cfg.AsReadOnly() + : new Dictionary().AsReadOnly(); + + var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution"); + + return new AgentToolContext( + agentConfig.AgentId, + instanceId, + toolConfig, + _stateStore, + toolLogger, + ct, + agentConfig.WorkspacePath, + agentConfig.SharedWorkspacePath, + this, + _memoryRepository, + _taskRepository); + } + + /// + /// Führt einen freigegebenen, eingefrorenen Aufruf aus (A2) — mit gültigem Tool-Kontext, + /// aber ohne LLM-Schleife und ohne erneute Staging-Prüfung. Genau der übergebene + /// Argument-JSON wird ausgeführt (Plan-Freeze). + /// + public async Task ExecuteApprovedCallAsync( + string agentId, string tool, string argumentsJson, string runId, CancellationToken ct) + { + var config = _agentConfigProvider?.Invoke().FirstOrDefault(a => a.AgentId == agentId); + if (config is null) + return JsonSerializer.Serialize(new { error = $"Agent '{agentId}' nicht gefunden." }); + + var agentTool = _toolRegistry.GetForAgent(config).FirstOrDefault(t => t.Name == tool) + ?? _toolRegistry.Get(tool); + if (agentTool is null) + return JsonSerializer.Serialize(new { error = $"Tool '{tool}' nicht gefunden." }); + + var sw = Stopwatch.StartNew(); + try + { + var input = string.IsNullOrWhiteSpace(argumentsJson) + ? default + : JsonDocument.Parse(argumentsJson).RootElement; + + var context = BuildToolContext(config, _instanceId, tool, ct); + var result = await agentTool.ExecuteAsync(input, context, ct); + + var status = result.Success ? AuditStatus.Ok : AuditStatus.Error; + await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson, + status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds); + + return result.Success + ? TruncateToolResult(result.Content, config.MaxToolResultChars) + : JsonSerializer.Serialize(new { error = result.ErrorMessage }); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson, + AuditStatus.Error, ex.Message, sw.ElapsedMilliseconds); return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" }); } } @@ -938,6 +1063,84 @@ public sealed class AgentEngine : IAgentMessageRouter } } + // ─── Audit-Log und Receipts (A3) ─── + + /// + /// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den + /// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan. + /// + private async Task RecordAuditAsync( + string runId, AgentConfig agentConfig, string? source, string tool, + string arguments, AuditStatus status, string summary, long durationMs) + { + if (_auditRepository is null) + return; + + try + { + await _auditRepository.AppendAsync(new AuditEntry + { + RunId = runId, + AgentId = agentConfig.AgentId, + Model = agentConfig.Model, + Source = AuditSource.Normalize(source), + Tool = tool, + Arguments = Cap(arguments, 4_000), + Status = status, + Summary = Cap(summary, 500), + DurationMs = durationMs, + OccurredAt = DateTime.Now + }, CancellationToken.None); + } + catch (Exception ex) + { + _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit") + .LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool); + } + } + + /// + /// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit. + /// + private async Task RecordReceiptAsync( + string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId) + { + if (_auditRepository is null) + return; + + try + { + var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens); + + await _auditRepository.RecordReceiptAsync(new RunReceipt + { + RunId = runId, + AgentId = agentConfig.AgentId, + Model = agentConfig.Model, + Source = AuditSource.Normalize(source), + TaskId = taskId, + Status = result.Status.ToString(), + StepCount = result.StepCount, + PromptTokens = result.PromptTokens, + CompletionTokens = result.CompletionTokens, + CachedTokens = result.CachedTokens, + CostUsd = estimate?.Usd ?? 0m, + CostIsKnown = estimate?.IsKnown ?? false, + DurationMs = (long)result.Duration.TotalMilliseconds, + ResultRef = Cap(result.FinalMessage ?? "", 500), + OccurredAt = DateTime.Now + }, CancellationToken.None); + } + catch (Exception ex) + { + _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Receipt") + .LogWarning(ex, "Receipt konnte nicht geschrieben werden für {AgentId}", agentConfig.AgentId); + } + } + + private static string Cap(string value, int max) + => value.Length <= max ? value : value[..max] + "…"; + /// Sammelt die Token-Zahlen über alle Schritte eines Runs. private sealed class TokenTally { diff --git a/src/ClawdDotNet.Core/Engine/ChatEntry.cs b/src/ClawdDotNet.Core/Engine/ChatEntry.cs index 30c0a16..5215519 100644 --- a/src/ClawdDotNet.Core/Engine/ChatEntry.cs +++ b/src/ClawdDotNet.Core/Engine/ChatEntry.cs @@ -18,4 +18,5 @@ public static class ChatSource public const string Telegram = "telegram"; public const string AgentComm = "agentcomm"; public const string Job = "job"; + public const string Task = "task"; } diff --git a/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs b/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs deleted file mode 100644 index 5944842..0000000 --- a/src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs +++ /dev/null @@ -1,143 +0,0 @@ -using ClawdDotNet.Core.Config; -using ClawdDotNet.Core.Engine; -using Microsoft.Extensions.Logging; - -namespace ClawdDotNet.Core.Scheduling; - -public sealed class AgentScheduler : IAsyncDisposable -{ - private readonly AgentEngine _engine; - private readonly string _instanceId; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cts = new(); - private readonly List _schedulerTasks = new(); - private readonly Dictionary _lastResults = new(); - private readonly Lock _resultsLock = new(); - - public event Action? OnRunCompleted; - - public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory) - { - _engine = engine; - _instanceId = instanceId; - _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling"); - } - - public void RegisterAgent(AgentConfig agentConfig) - { - if (agentConfig.Scheduler is null) - return; - - _logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}", - agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart); - - var task = RunScheduledAgentAsync(agentConfig, _cts.Token); - _schedulerTasks.Add(task); - } - - public void RegisterAll(IEnumerable agents) - { - foreach (var agent in agents) - RegisterAgent(agent); - } - - public async Task RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct) - { - _logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId); - var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct); - StoreResult(agentConfig.AgentId, result); - OnRunCompleted?.Invoke(agentConfig.AgentId, result); - return result; - } - - public AgentRunResult? GetLastResult(string agentId) - { - lock (_resultsLock) - return _lastResults.GetValueOrDefault(agentId); - } - - private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct) - { - var scheduler = agentConfig.Scheduler!; - - if (scheduler.RunOnStart) - { - await ExecuteScheduledRunAsync(agentConfig, ct); - } - - if (string.IsNullOrWhiteSpace(scheduler.Cron)) - return; - - var cron = CronExpression.Parse(scheduler.Cron); - - while (!ct.IsCancellationRequested) - { - var now = DateTime.Now; - var next = cron.GetNextOccurrence(now); - - if (next is null) - { - _logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId); - return; - } - - var delay = next.Value - now; - _logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value); - - try - { - await Task.Delay(delay, ct); - } - catch (OperationCanceledException) - { - break; - } - - await ExecuteScheduledRunAsync(agentConfig, ct); - } - } - - private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct) - { - try - { - var result = await _engine.RunAsync( - agentConfig, - agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.", - _instanceId, - ct); - - StoreResult(agentConfig.AgentId, result); - OnRunCompleted?.Invoke(agentConfig.AgentId, result); - - _logger.LogInformation( - "Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}", - agentConfig.AgentId, result.Status, result.TokensUsed); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId); - } - } - - private void StoreResult(string agentId, AgentRunResult result) - { - lock (_resultsLock) - _lastResults[agentId] = result; - } - - public async ValueTask DisposeAsync() - { - await _cts.CancelAsync(); - - try - { - await Task.WhenAll(_schedulerTasks); - } - catch (OperationCanceledException) - { - } - - _cts.Dispose(); - } -} diff --git a/src/ClawdDotNet.Core/Scheduling/CronExpression.cs b/src/ClawdDotNet.Core/Scheduling/CronExpression.cs index 2901a1f..edd9a2d 100644 --- a/src/ClawdDotNet.Core/Scheduling/CronExpression.cs +++ b/src/ClawdDotNet.Core/Scheduling/CronExpression.cs @@ -90,6 +90,9 @@ public sealed class CronExpression foreach (var part in field.Split(',')) { + if (part.Length == 0) + throw Bad(field, "leeres Teilfeld"); + if (part == "*") { for (var i = min; i <= max; i++) result.Add(i); @@ -97,23 +100,44 @@ public sealed class CronExpression else if (part.Contains('/')) { var split = part.Split('/'); - var start = split[0] == "*" ? min : int.Parse(split[0]); - var step = int.Parse(split[1]); + if (split.Length != 2) + throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'"); + + var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max); + var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife for (var i = start; i <= max; i += step) result.Add(i); } else if (part.Contains('-')) { var split = part.Split('-'); - var from = int.Parse(split[0]); - var to = int.Parse(split[1]); + if (split.Length != 2) + throw Bad(field, "Bereich erwartet die Form 'von-bis'"); + + var from = ParseNumber(field, split[0], min, max); + var to = ParseNumber(field, split[1], min, max); + if (from > to) + throw Bad(field, $"Bereich {from}-{to} ist rückwärts"); + for (var i = from; i <= to; i++) result.Add(i); } else { - result.Add(int.Parse(part)); + result.Add(ParseNumber(field, part, min, max)); } } return result; } + + private static int ParseNumber(string field, string value, int min, int max) + { + if (!int.TryParse(value, out var n)) + throw Bad(field, $"'{value}' ist keine Zahl"); + if (n < min || n > max) + throw Bad(field, $"{n} liegt außerhalb von {min}-{max}"); + return n; + } + + private static FormatException Bad(string field, string reason) + => new($"Ungültiges Cron-Feld '{field}': {reason}."); } diff --git a/src/ClawdDotNet.Core/Scheduling/TimeZones.cs b/src/ClawdDotNet.Core/Scheduling/TimeZones.cs new file mode 100644 index 0000000..73ee765 --- /dev/null +++ b/src/ClawdDotNet.Core/Scheduling/TimeZones.cs @@ -0,0 +1,96 @@ +namespace ClawdDotNet.Core.Scheduling; + +/// +/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie +/// geschrieben wurden. +/// +/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux +/// unterschiedlich benannt — "W. Europe Standard Time" gegen +/// "Europe/Berlin". Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis +/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist, +/// muss auf dem anderen lesbar bleiben. +/// +/// Zuvor fing TaskSchedule die unbekannte Kennung ab und rechnete still +/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer +/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten, +/// und was sich nicht deuten lässt, meldet als null +/// zurück, statt es zu erraten. +/// +/// Voraussetzung auf dem Zielsystem: Die Umsetzung zwischen beiden +/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus tzdata. In +/// einem schlanken Abbild (Alpine ohne icu-libs, distroless) oder bei +/// InvariantGlobalization=true fehlen sie — dann schlägt jede Auflösung außer +/// UTC fehl. Beides gehört ins Abbild. +/// +public static class TimeZones +{ + /// + /// Die lokale Zeitzone in IANA-Schreibweise (Europe/Berlin). + /// + /// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf + /// Linux, macOS und — seit .NET 8 — auch auf Windows. + /// + public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id); + + /// + /// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise. + /// Gibt null zurück, wenn sie auf diesem System nicht auflösbar ist — + /// der Aufrufer muss dann entscheiden, und zwar sichtbar. + /// + public static TimeZoneInfo? TryResolve(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return null; + + var value = id.Trim(); + + if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase)) + return TimeZoneInfo.Utc; + + // Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide + // Formen an. Wenn das reicht, sind wir fertig. + try { return TimeZoneInfo.FindSystemTimeZoneById(value); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + + // Sonst die jeweils andere Schreibweise versuchen. + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana)) + { + try { return TimeZoneInfo.FindSystemTimeZoneById(iana); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + } + + if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows)) + { + try { return TimeZoneInfo.FindSystemTimeZoneById(windows); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + } + + return null; + } + + /// + /// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das + /// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet. + /// + public static bool IsKnown(string? id) + => string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null; + + /// + /// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt + /// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer, + /// als sie durchzureichen. + /// + public static string ToIana(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return ""; + + var value = id.Trim(); + + // Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen). + if (value.Contains('/')) return value; + + return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value; + } +} diff --git a/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs b/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs deleted file mode 100644 index 798e76e..0000000 --- a/src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs +++ /dev/null @@ -1,213 +0,0 @@ -using ClawdDotNet.Core.Config; -using ClawdDotNet.Core.Engine; -using ClawdDotNet.Core.State; -using ClawdDotNet.Core.Tools; -using Microsoft.Extensions.Logging; - -namespace ClawdDotNet.Core.Scheduling; - -public sealed class ToolJobScheduler : IAsyncDisposable -{ - private readonly AgentEngine _engine; - private readonly ToolRegistry _toolRegistry; - private readonly IStateStore _stateStore; - private readonly string _instanceId; - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly CancellationTokenSource _cts = new(); - private readonly List _schedulerTasks = new(); - private readonly Dictionary _lastResults = new(); - private readonly Lock _resultsLock = new(); - - public event Action? OnJobTick; - - public ToolJobScheduler( - AgentEngine engine, - ToolRegistry toolRegistry, - IStateStore stateStore, - ILoggerFactory loggerFactory, - string instanceId) - { - _engine = engine; - _toolRegistry = toolRegistry; - _stateStore = stateStore; - _instanceId = instanceId; - _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob"); - } - - public void RegisterAll(IEnumerable agents) - { - foreach (var agent in agents) - { - foreach (var jobConfig in agent.ToolJobs) - { - if (!jobConfig.Enabled) - continue; - - var tool = _toolRegistry.Get(jobConfig.ToolName); - if (tool is not IToolJobProvider provider) - { - _logger.LogWarning( - "Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found", - jobConfig.ToolName, jobConfig.JobId, agent.AgentId); - continue; - } - - _logger.LogInformation( - "Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}", - agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron); - - var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token); - _schedulerTasks.Add(task); - } - } - } - - public ToolJobResult? GetLastResult(string jobId) - { - lock (_resultsLock) - return _lastResults.GetValueOrDefault(jobId); - } - - /// - /// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans). - /// - public async Task TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct) - { - var tool = _toolRegistry.Get(jobConfig.ToolName); - if (tool is not IToolJobProvider provider) - return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert."); - - _logger.LogInformation( - "Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}", - agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId); - - await ExecuteTickAsync(agentConfig, jobConfig, provider, ct); - - lock (_resultsLock) - return _lastResults.GetValueOrDefault(jobConfig.JobId) - ?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden."); - } - - private async Task RunToolJobAsync( - AgentConfig agentConfig, - ToolJobConfig jobConfig, - IToolJobProvider provider, - CancellationToken ct) - { - if (jobConfig.RunOnStart) - { - await ExecuteTickAsync(agentConfig, jobConfig, provider, ct); - } - - if (string.IsNullOrWhiteSpace(jobConfig.Cron)) - return; - - var cron = CronExpression.Parse(jobConfig.Cron); - - while (!ct.IsCancellationRequested && jobConfig.Enabled) - { - var now = DateTime.Now; - var next = cron.GetNextOccurrence(now); - - if (next is null) - { - _logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId); - return; - } - - var delay = next.Value - now; - _logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}", - jobConfig.JobId, jobConfig.JobTypeId, next.Value); - - try - { - await Task.Delay(delay, ct); - } - catch (OperationCanceledException) - { - break; - } - - await ExecuteTickAsync(agentConfig, jobConfig, provider, ct); - } - } - - private async Task ExecuteTickAsync( - AgentConfig agentConfig, - ToolJobConfig jobConfig, - IToolJobProvider provider, - CancellationToken ct) - { - var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job"); - - if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName)) - { - _logger.LogWarning( - "Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'", - jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId); - - jobConfig.Enabled = false; - - var disabledResult = new ToolJobResult(false, null, - $"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'"); - lock (_resultsLock) - _lastResults[jobConfig.JobId] = disabledResult; - OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult); - return; - } - - try - { - var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg) - ? (IReadOnlyDictionary)cfg.AsReadOnly() - : new Dictionary().AsReadOnly(); - - var result = await provider.ExecuteJobAsync( - jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct, - agentConfig.AgentId, agentConfig.WorkspacePath); - - lock (_resultsLock) - _lastResults[jobConfig.JobId] = result; - - OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result); - - _logger.LogInformation( - "Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}", - agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary); - - if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage)) - { - _logger.LogInformation( - "Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}", - agentConfig.AgentId, jobConfig.JobId, result.UseChatContext); - - if (result.UseChatContext) - await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job); - else - await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}", - agentConfig.AgentId, jobConfig.JobId); - } - } - - public async ValueTask DisposeAsync() - { - await _cts.CancelAsync(); - - try - { - await Task.WhenAll(_schedulerTasks); - } - catch (OperationCanceledException) - { - } - - _cts.Dispose(); - } -} diff --git a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs index d502d54..72b7341 100644 --- a/src/ClawdDotNet.Core/Security/ConfigSecrets.cs +++ b/src/ClawdDotNet.Core/Security/ConfigSecrets.cs @@ -45,6 +45,9 @@ public static class ConfigSecrets telegram.Password2FA = transform(telegram.Password2FA); } + if (config.Watchdog is { } watchdog) + watchdog.AgentToken = transform(watchdog.AgentToken) ?? ""; + foreach (var agent in config.Agents) Apply(agent, transform); } diff --git a/src/ClawdDotNet.Core/Security/SecretKeyStore.cs b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs new file mode 100644 index 0000000..98af8e1 --- /dev/null +++ b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs @@ -0,0 +1,107 @@ +using System.Security.Cryptography; +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Core.Security; + +/// +/// Verwaltet den lokalen Schlüssel, mit dem die +/// enc:v2-Werte sichert. +/// +/// Ein Schlüssel je Benutzer und Rechner. Er liegt als +/// secret.key in — bewusst außerhalb +/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt. +/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein +/// nützt auf einem anderen Rechner nichts. +/// +/// Rechte. Unter Unix 0600. Unter Windows erbt die Datei die +/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt +/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format +/// plattformübergreifend ist. +/// +public static class SecretKeyStore +{ + private const string KeyFileName = "secret.key"; + private const int KeySize = 32; // AES-256 + + /// DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows). + private static readonly byte[] KeyEntropy = + System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2"); + + private static readonly Lock Gate = new(); + private static byte[]? _cached; + private static string? _overrideDirectory; + + public static string KeyFilePath => Path.Combine(Directory(), KeyFileName); + + /// + /// Verlegt den Schlüssel — für Tests, damit sie den echten Benutzerschlüssel weder + /// lesen noch überschreiben. + /// + public static void UseDirectory(string? directory) + { + lock (Gate) + { + _overrideDirectory = directory; + _cached = null; + } + } + + /// + /// Liest den Schlüssel oder legt ihn beim ersten Aufruf an. + /// + /// Wenn das Verzeichnis nicht beschreibbar ist. + public static byte[] GetOrCreateKey() + { + lock (Gate) + { + if (_cached is not null) return _cached; + + var directory = AppPaths.EnsureDirectory(Directory()); + var path = Path.Combine(directory, KeyFileName); + + if (File.Exists(path)) + { + var stored = Unwrap(File.ReadAllBytes(path)); + if (stored.Length == KeySize) + { + _cached = stored; + return _cached; + } + + // Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen + // würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt, + // warum — deshalb hier abbrechen und den Pfad nennen. + throw new CryptographicException( + $"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). " + + "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen."); + } + + var key = RandomNumberGenerator.GetBytes(KeySize); + + // Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der + // würde alle Geheimnisse dieser Installation unlesbar machen. + AtomicFile.WriteAllBytes(path, Wrap(key)); + AppPaths.RestrictToOwner(path); + + _cached = key; + return _cached; + } + } + + private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory; + + /// Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden. + private static byte[] Wrap(byte[] key) + => OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key; + + private static byte[] Unwrap(byte[] stored) + => OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored; + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static byte[] ProtectWithDpapi(byte[] key) + => ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser); + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static byte[] UnprotectWithDpapi(byte[] stored) + => ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser); +} diff --git a/src/ClawdDotNet.Core/Security/SecretProtector.cs b/src/ClawdDotNet.Core/Security/SecretProtector.cs index 89bdc34..02ed5e4 100644 --- a/src/ClawdDotNet.Core/Security/SecretProtector.cs +++ b/src/ClawdDotNet.Core/Security/SecretProtector.cs @@ -1,6 +1,7 @@ using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; +using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Security; @@ -12,23 +13,46 @@ namespace ClawdDotNet.Core.Security; /// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein /// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge. /// -/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben -/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe -/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft — -/// für einen lokal laufenden Dienst ist das die angemessene Stufe. +/// Zwei Formate. /// -/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen -/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird. +/// +/// enc:v1: — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird +/// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen +/// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2. +/// enc:v2: — AES-256-GCM mit einem Schlüssel aus +/// . Läuft auf jeder Plattform. +/// +/// +/// Warum v2 überhaupt nötig wurde. Die vorige Fassung gab unter Linux +/// stillschweigend den Klartext zurück — Protect verschlüsselte dort schlicht +/// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das +/// schlechter gewesen als auf einem Einzelplatz-Windows. +/// +/// Schutzstufe. Dieselbe wie DPAPI zuvor: gegen Weitergabe der +/// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht +/// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt +/// deshalb bewusst außerhalb des Instanzverzeichnisses: Eine Sicherung der +/// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin +/// allein die Sicherungsrichtlinie in BackupService. /// public static class SecretProtector { - private const string Prefix = "enc:v1:"; + private const string PrefixV1 = "enc:v1:"; + private const string PrefixV2 = "enc:v2:"; /// Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist. private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1"); + /// Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft. + private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2"); + + private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge + private const int TagSize = 16; + public static bool IsProtected(string? value) - => value?.StartsWith(Prefix, StringComparison.Ordinal) == true; + => value is not null + && (value.StartsWith(PrefixV2, StringComparison.Ordinal) + || value.StartsWith(PrefixV1, StringComparison.Ordinal)); /// /// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert, @@ -39,18 +63,35 @@ public static class SecretProtector if (string.IsNullOrEmpty(plainText) || IsProtected(plainText)) return plainText; - if (!OperatingSystem.IsWindows()) - return plainText; - try { - var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText)); - return Prefix + Convert.ToBase64String(encrypted); + var key = SecretKeyStore.GetOrCreateKey(); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var plain = Encoding.UTF8.GetBytes(plainText); + var cipher = new byte[plain.Length]; + var tag = new byte[TagSize]; + + using (var aes = new AesGcm(key, TagSize)) + aes.Encrypt(nonce, plain, cipher, tag, AssociatedData); + + // nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist. + var payload = new byte[NonceSize + TagSize + cipher.Length]; + nonce.CopyTo(payload, 0); + tag.CopyTo(payload, NonceSize); + cipher.CopyTo(payload, NonceSize + TagSize); + + return PrefixV2 + Convert.ToBase64String(payload); } - catch (CryptographicException) + catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) { - // Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren. - return plainText; + // Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis). + // Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem + // Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen. + throw new SecretProtectionException( + "Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht " + + $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden " + + "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex); } } @@ -60,34 +101,74 @@ public static class SecretProtector /// public static string? Unprotect(string? value) { - if (string.IsNullOrEmpty(value) || !IsProtected(value)) - return value; + if (string.IsNullOrEmpty(value)) return value; - if (!OperatingSystem.IsWindows()) - return value; + if (value.StartsWith(PrefixV2, StringComparison.Ordinal)) + return UnprotectV2(value[PrefixV2.Length..]); - var payload = value[Prefix.Length..]; + if (value.StartsWith(PrefixV1, StringComparison.Ordinal)) + return UnprotectV1(value[PrefixV1.Length..]); + return value; // Klartext aus älteren Konfigurationen + } + + private static string UnprotectV2(string payload) + { try { - var decrypted = UnprotectWindows(Convert.FromBase64String(payload)); - return Encoding.UTF8.GetString(decrypted); + var raw = Convert.FromBase64String(payload); + if (raw.Length < NonceSize + TagSize) + throw new CryptographicException("Der verschlüsselte Block ist unvollständig."); + + var key = SecretKeyStore.GetOrCreateKey(); + + var nonce = raw.AsSpan(0, NonceSize); + var tag = raw.AsSpan(NonceSize, TagSize); + var cipher = raw.AsSpan(NonceSize + TagSize); + var plain = new byte[cipher.Length]; + + using (var aes = new AesGcm(key, TagSize)) + aes.Decrypt(nonce, cipher, tag, plain, AssociatedData); + + return Encoding.UTF8.GetString(plain); } - catch (Exception ex) when (ex is CryptographicException or FormatException) + catch (Exception ex) when (ex is CryptographicException or FormatException + or IOException or UnauthorizedAccessException) { - // Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht - // lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein - // unbrauchbarer Schlüssel an die API gehen. throw new SecretProtectionException( - "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " + - "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " + - "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex); + "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " + + "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel " + + $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. " + + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex); } } - [SupportedOSPlatform("windows")] - private static byte[] ProtectWindows(byte[] data) - => ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser); + private static string UnprotectV1(string payload) + { + if (!OperatingSystem.IsWindows()) + { + // Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext + // durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API. + throw new SecretProtectionException( + "Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt " + + "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die " + + "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im " + + "plattformübergreifenden Format vor.", + new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar.")); + } + + try + { + return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload))); + } + catch (Exception ex) when (ex is CryptographicException or FormatException) + { + throw new SecretProtectionException( + "Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " + + "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " + + "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex); + } + } [SupportedOSPlatform("windows")] private static byte[] UnprotectWindows(byte[] data) diff --git a/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs b/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs new file mode 100644 index 0000000..941acd4 --- /dev/null +++ b/src/ClawdDotNet.Core/Staging/SqliteStagingRepository.cs @@ -0,0 +1,139 @@ +using System.Globalization; +using ClawdDotNet.Core.Storage; +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Staging; + +/// +/// Die Staging-Warteschlange in der Instanz-Datenbank. Der bedingte Statuswechsel +/// () ist dieselbe atomare Claim-Technik wie beim +/// Taskboard: Er verhindert, dass zwei Reviewer denselben Vorschlag doppelt entscheiden. +/// +public sealed class SqliteStagingRepository : IStagingRepository +{ + private readonly SqliteStorage _storage; + + public SqliteStagingRepository(SqliteStorage storage) => _storage = storage; + + public Task AppendAsync(StagedCall call, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO StagedCalls + (RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, CreatedAt) + VALUES + (@runId, @agentId, @instanceId, @tool, @action, @args, @proposal, 'Pending', @createdAt); + SELECT last_insert_rowid(); + """; + cmd.Parameters.AddWithValue("@runId", call.RunId); + cmd.Parameters.AddWithValue("@agentId", call.AgentId); + cmd.Parameters.AddWithValue("@instanceId", call.InstanceId); + cmd.Parameters.AddWithValue("@tool", call.Tool); + cmd.Parameters.AddWithValue("@action", (object?)call.Action ?? DBNull.Value); + cmd.Parameters.AddWithValue("@args", call.ArgumentsJson); + cmd.Parameters.AddWithValue("@proposal", call.Proposal); + cmd.Parameters.AddWithValue("@createdAt", Format(DateTime.UtcNow)); + return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct)); + }, ct); + + public async Task GetAsync(long id, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Id = @id LIMIT 1"; + cmd.Parameters.AddWithValue("@id", id); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? Read(reader) : null; + } + + public async Task> ListPendingAsync(CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Status = 'Pending' ORDER BY Id"; + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + return results; + } + + public Task TryTransitionAsync( + long id, StagingStatus from, StagingStatus to, + string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE StagedCalls + SET Status = @to, DecidedBy = @decidedBy, DecidedAt = @now, + RejectionReason = @reason + WHERE Id = @id AND Status = @from + """; + cmd.Parameters.AddWithValue("@to", to.ToString()); + cmd.Parameters.AddWithValue("@decidedBy", (object?)decidedBy ?? DBNull.Value); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@reason", (object?)rejectionReason ?? DBNull.Value); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@from", from.ToString()); + return await cmd.ExecuteNonQueryAsync(ct) == 1; + }, ct); + + public Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE StagedCalls + SET Status = @status, ResultRef = @resultRef, DecidedAt = @now + WHERE Id = @id + """; + cmd.Parameters.AddWithValue("@status", status.ToString()); + cmd.Parameters.AddWithValue("@resultRef", (object?)resultRef ?? DBNull.Value); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@id", id); + await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public async Task CountPendingAsync(CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM StagedCalls WHERE Status = 'Pending'"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); + } + + // ─── Hilfsfunktionen ─── + + private const string Columns = + "Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " + + "CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason"; + + private static StagedCall Read(SqliteDataReader r) => new() + { + Id = r.GetInt64(0), + RunId = r.GetString(1), + AgentId = r.GetString(2), + InstanceId = r.GetString(3), + Tool = r.GetString(4), + Action = r.IsDBNull(5) ? null : r.GetString(5), + ArgumentsJson = r.GetString(6), + Proposal = r.GetString(7), + Status = Enum.TryParse(r.GetString(8), out var s) ? s : StagingStatus.Pending, + CreatedAt = Parse(r.GetString(9)), + DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)), + DecidedBy = r.IsDBNull(11) ? null : r.GetString(11), + ResultRef = r.IsDBNull(12) ? null : r.GetString(12), + RejectionReason = r.IsDBNull(13) ? null : r.GetString(13) + }; + + private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); + + private static DateTime Parse(string value) + => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) + ? dt + : DateTime.MinValue; +} diff --git a/src/ClawdDotNet.Core/Staging/StagingGate.cs b/src/ClawdDotNet.Core/Staging/StagingGate.cs new file mode 100644 index 0000000..e2d094a --- /dev/null +++ b/src/ClawdDotNet.Core/Staging/StagingGate.cs @@ -0,0 +1,119 @@ +using System.Text.Json; + +namespace ClawdDotNet.Core.Staging; + +/// Was die Engine mit einem Aufruf tun soll, nachdem das Gate ihn geprüft hat. +public enum StagingOutcome +{ + /// Normal ausführen. + Proceed, + + /// Als Vorschlag angelegt — die Ausführung wartet auf Freigabe. + Staged, + + /// Abgelehnt (Policy deny). + Denied +} + +/// Ergebnis der Gate-Prüfung samt der Nachricht, die der Agent als Tool-Ergebnis sieht. +public readonly record struct StagingInterception(StagingOutcome Outcome, string Message, long StagedId) +{ + public static StagingInterception Proceed { get; } = new(StagingOutcome.Proceed, "", 0); +} + +/// +/// Der Durchsetzungspunkt (A2): prüft die Policy für einen konkreten Aufruf und legt bei +/// approve einen eingefrorenen Vorschlag an, statt auszuführen. Optional an der +/// Engine — ohne Gate läuft alles wie bisher. +/// +public sealed class StagingGate +{ + private readonly StagingPolicy _policy; + private readonly IStagingRepository _repo; + + public StagingGate(StagingPolicy policy, IStagingRepository repo) + { + _policy = policy; + _repo = repo; + } + + public async Task InterceptAsync( + string agentId, string instanceId, string runId, + string tool, string argumentsJson, CancellationToken ct) + { + var action = ExtractAction(argumentsJson); + + switch (_policy.Decide(tool, action)) + { + case StagingDecision.Auto: + return StagingInterception.Proceed; + + case StagingDecision.Deny: + return new StagingInterception( + StagingOutcome.Denied, + $"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.", + 0); + + case StagingDecision.Approve: + var id = await _repo.AppendAsync(new StagedCall + { + RunId = runId, + AgentId = agentId, + InstanceId = instanceId, + Tool = tool, + Action = action, + ArgumentsJson = argumentsJson, + Proposal = BuildProposal(tool, action, argumentsJson) + }, ct); + + return new StagingInterception( + StagingOutcome.Staged, + $"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " + + "Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " + + "mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.", + id); + + default: + return StagingInterception.Proceed; + } + } + + /// Liest das action-Argument, wenn vorhanden — der Aktionsschlüssel der Policy. + public static string? ExtractAction(string argumentsJson) + { + if (string.IsNullOrWhiteSpace(argumentsJson)) + return null; + try + { + using var doc = JsonDocument.Parse(argumentsJson); + return doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("action", out var a) + && a.ValueKind == JsonValueKind.String + ? a.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private static string Label(string tool, string? action) + => string.IsNullOrWhiteSpace(action) ? tool : $"{tool}.{action}"; + + private static string BuildProposal(string tool, string? action, string argumentsJson) + { + var args = argumentsJson.Length > 500 ? argumentsJson[..500] + "…" : argumentsJson; + return $"{Label(tool, action)} {args}".Trim(); + } +} + +/// +/// Führt einen freigegebenen, eingefrorenen Aufruf aus — mit gültigem Tool-Kontext, aber +/// ohne LLM-Schleife. Von der Engine implementiert. +/// +public interface IFrozenCallExecutor +{ + Task ExecuteApprovedCallAsync( + string agentId, string tool, string argumentsJson, string runId, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Staging/StagingModels.cs b/src/ClawdDotNet.Core/Staging/StagingModels.cs new file mode 100644 index 0000000..48b3857 --- /dev/null +++ b/src/ClawdDotNet.Core/Staging/StagingModels.cs @@ -0,0 +1,95 @@ +namespace ClawdDotNet.Core.Staging; + +/// Was mit einem Tool-Aufruf geschehen soll — die Policy-Entscheidung. +public enum StagingDecision +{ + /// Ausführen wie bisher. + Auto, + + /// Stagen und auf menschliche Freigabe warten. + Approve, + + /// Gar nicht erst vorschlagen — ablehnen. + Deny +} + +/// Lebenszyklus eines eingefrorenen Aufrufs. +public enum StagingStatus +{ + /// Vorgeschlagen, wartet auf Entscheidung. + Pending, + + /// Freigegeben und beansprucht (wird ausgeführt). + Approved, + + /// Freigegeben und ausgeführt. + Executed, + + /// Freigegeben, aber die Ausführung schlug fehl. + Failed, + + /// Abgelehnt. + Rejected +} + +/// +/// Ein eingefrorener, konkreter Tool-Aufruf, der auf eine Freigabe wartet. „Eingefroren" +/// heißt: Tool, Aktion und die exakten Argumente zum Zeitpunkt des Vorschlags. +/// Ausgeführt wird genau das (Plan-Freeze) — nie eine nachträglich veränderte Fassung. +/// +public sealed record StagedCall +{ + public long Id { get; init; } + + /// Lauf, aus dem der Vorschlag stammt — verbindet ihn mit dem Audit-Log. + public string RunId { get; init; } = ""; + + public string AgentId { get; init; } = ""; + public string InstanceId { get; init; } = ""; + + public string Tool { get; init; } = ""; + public string? Action { get; init; } + + /// Die eingefrorenen Argumente (roher JSON, exakt wie vom Modell geschickt). + public string ArgumentsJson { get; init; } = ""; + + /// Kurze, menschenlesbare Zusammenfassung des Vorschlags. + public string Proposal { get; init; } = ""; + + public StagingStatus Status { get; init; } = StagingStatus.Pending; + + public DateTime CreatedAt { get; init; } + public DateTime? DecidedAt { get; init; } + public string? DecidedBy { get; init; } + + /// Nach der Ausführung: kurzer Verweis auf das Ergebnis. + public string? ResultRef { get; init; } + + /// Bei Ablehnung: der Grund. + public string? RejectionReason { get; init; } +} + +public interface IStagingRepository +{ + /// Legt einen Vorschlag an und gibt seine Id zurück. + Task AppendAsync(StagedCall call, CancellationToken ct); + + Task GetAsync(long id, CancellationToken ct); + + /// Die offenen Vorschläge (Pending), älteste zuerst — für die Review-Ansicht. + Task> ListPendingAsync(CancellationToken ct); + + /// + /// Atomarer, bedingter Statuswechsel: nur wirksam, wenn der Vorschlag noch im Status + /// steht. Verhindert, dass zwei Reviewer denselben Vorschlag + /// doppelt entscheiden. Gibt zurück, ob der Wechsel gelang. + /// + Task TryTransitionAsync( + long id, StagingStatus from, StagingStatus to, + string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct); + + /// Setzt Endstatus und Ergebnis-Verweis nach der Ausführung (zweite Phase). + Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct); + + Task CountPendingAsync(CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Staging/StagingPolicy.cs b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs new file mode 100644 index 0000000..cef4716 --- /dev/null +++ b/src/ClawdDotNet.Core/Staging/StagingPolicy.cs @@ -0,0 +1,52 @@ +namespace ClawdDotNet.Core.Staging; + +/// +/// Entscheidet je (Tool, Aktion), ob ein Aufruf läuft, gestaged oder abgelehnt wird. +/// +/// Auflösung vom Speziellen zum Allgemeinen: Tool.AktionTool → Standard. +/// Rein und ohne Zustand, damit die Entscheidung testbar bleibt. +/// +public sealed class StagingPolicy +{ + private readonly Dictionary _rules; + private readonly StagingDecision _default; + + public StagingPolicy( + IReadOnlyDictionary? rules = null, + StagingDecision defaultDecision = StagingDecision.Auto) + { + _rules = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in rules ?? DefaultRules) + _rules[key] = value; + _default = defaultDecision; + } + + public StagingDecision Decide(string tool, string? action) + { + if (!string.IsNullOrWhiteSpace(action) + && _rules.TryGetValue($"{tool}.{action}", out var specific)) + return specific; + + if (_rules.TryGetValue(tool, out var byTool)) + return byTool; + + return _default; + } + + /// + /// Eingebaute Standardregeln — genau die irreversiblen Aktionen aus der Roadmap, nach + /// Sichtung der Tools. Lesende Aktionen bleiben bewusst außen vor (Staging soll + /// schützen, nicht lähmen). Überschreibbar per Konfiguration. + /// + public static IReadOnlyDictionary DefaultRules { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Mail.send"] = StagingDecision.Approve, + ["Telegram.send_message"] = StagingDecision.Approve, + ["Database.insert"] = StagingDecision.Approve, + ["Database.upsert"] = StagingDecision.Approve, + ["FileRW.delete"] = StagingDecision.Approve, + ["FTP.upload"] = StagingDecision.Approve, + ["FTP.delete"] = StagingDecision.Approve + }; +} diff --git a/src/ClawdDotNet.Core/Staging/StagingService.cs b/src/ClawdDotNet.Core/Staging/StagingService.cs new file mode 100644 index 0000000..945f719 --- /dev/null +++ b/src/ClawdDotNet.Core/Staging/StagingService.cs @@ -0,0 +1,156 @@ +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Tasks; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Staging; + +public enum StagingResultKind { NotFound, AlreadyDecided, Executed, Failed, Rejected } + +public readonly record struct StagingResult(StagingResultKind Kind, string Message) +{ + public static StagingResult NotFound { get; } = new(StagingResultKind.NotFound, "Vorschlag nicht gefunden."); + public static StagingResult AlreadyDecided { get; } = + new(StagingResultKind.AlreadyDecided, "Der Vorschlag wurde bereits entschieden."); +} + +/// +/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft. +/// +/// Kein pausierter Lauf: Bei Freigabe wird der eingefrorene Aufruf direkt +/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen +/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record +/// ins Audit-Log (A3) geschrieben. +/// +public sealed class StagingService +{ + private readonly IStagingRepository _repo; + private readonly IFrozenCallExecutor _executor; + private readonly TaskboardService _board; + private readonly IAuditRepository? _audit; + private readonly ILogger _logger; + + public StagingService( + IStagingRepository repo, + IFrozenCallExecutor executor, + TaskboardService board, + ILoggerFactory loggerFactory, + IAuditRepository? audit = null) + { + _repo = repo; + _executor = executor; + _board = board; + _audit = audit; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging"); + } + + public Task> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct); + public Task GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct); + public Task CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct); + + public async Task ApproveAsync(long id, string decidedBy, CancellationToken ct) + { + var call = await _repo.GetAsync(id, ct); + if (call is null) return StagingResult.NotFound; + + // Atomar beanspruchen — ein zweiter Reviewer läuft ins Leere, bevor irgendetwas + // ausgeführt wird. + if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct)) + return StagingResult.AlreadyDecided; + + string result; + StagingStatus final; + try + { + // Genau der eingefrorene Aufruf — nie eine neu formulierte Fassung. + result = await _executor.ExecuteApprovedCallAsync( + call.AgentId, call.Tool, call.ArgumentsJson, Guid.NewGuid().ToString("N"), ct); + final = StagingStatus.Executed; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + result = $"Ausführung fehlgeschlagen: {ex.Message}"; + final = StagingStatus.Failed; + _logger.LogError(ex, "Freigegebener Aufruf #{Id} ({Tool}) schlug fehl", id, call.Tool); + } + + await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct); + await RecordAuditAsync(call, decidedBy, final == StagingStatus.Executed ? AuditStatus.Ok : AuditStatus.Error, + $"Freigegeben von {decidedBy}", ct); + await WakeAgentAsync(call, + $"Freigabe-Ergebnis: {call.Tool}", + $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n" + + $"Ergebnis:\n{result}\n\nSetze deine Arbeit fort.", ct); + + return new StagingResult( + final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result); + } + + public async Task RejectAsync(long id, string decidedBy, string reason, CancellationToken ct) + { + var call = await _repo.GetAsync(id, ct); + if (call is null) return StagingResult.NotFound; + + if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct)) + return StagingResult.AlreadyDecided; + + await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct); + await WakeAgentAsync(call, + $"Freigabe abgelehnt: {call.Tool}", + $"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" + + $"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct); + + return new StagingResult(StagingResultKind.Rejected, reason); + } + + // ─── Hilfsfunktionen ─── + + private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct) + { + try + { + await _board.CreateAsync(new TaskItem + { + Title = title, + Body = body, + Status = TaskItemStatus.Todo, + Assignee = "@" + call.AgentId, + Type = TaskItemType.Work + }, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id); + } + } + + private async Task RecordAuditAsync( + StagedCall call, string decidedBy, AuditStatus status, string summary, CancellationToken ct) + { + if (_audit is null) return; + try + { + await _audit.AppendAsync(new AuditEntry + { + RunId = call.RunId, + AgentId = call.AgentId, + Model = "", + Source = "approval", + Tool = call.Tool, + Arguments = Cap(call.ArgumentsJson, 4_000), + Status = status, + Summary = Cap(summary, 500), + OccurredAt = DateTime.Now + }, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Approval-Record für #{Id} konnte nicht geschrieben werden", call.Id); + } + } + + private static string Label(StagedCall call) + => string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}"; + + private static string Cap(string value, int max) + => value.Length <= max ? value : value[..max] + "…"; +} diff --git a/src/ClawdDotNet.Core/Storage/AppPaths.cs b/src/ClawdDotNet.Core/Storage/AppPaths.cs new file mode 100644 index 0000000..df55737 --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/AppPaths.cs @@ -0,0 +1,123 @@ +namespace ClawdDotNet.Core.Storage; + +/// +/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten. +/// +/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben +/// der Programmdatei (AppDomain.CurrentDomain.BaseDirectory). Unter Windows in +/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in /opt +/// oder /usr/lib und ist für den Dienstbenutzer nicht schreibbar. +/// +/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten +/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, %APPDATA% unter +/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die +/// nach /var/lib schreiben sollen, und für Tests. +/// +public static class AppPaths +{ + private const string AppFolder = "ClawdDotNet"; + + /// Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert. + public static string ConfigDirectory { get; } = ResolveConfig(); + + /// Instanzen, Datenbanken, Logs. Groß, oft geschrieben. + public static string DataDirectory { get; } = ResolveData(); + + /// + /// Das Verzeichnis der Programmdatei. + /// + /// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die + /// AppSettings.json noch am alten Ort, und ein Update darf sie nicht + /// verwaisen lassen. + /// + public static string ProgramDirectory => AppContext.BaseDirectory; + + /// Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein. + public static string EnsureDirectory(string path) + { + Directory.CreateDirectory(path); + + // 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten. + // Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux + // wären es sonst je nach umask 0755 — für alle lesbar. + if (!OperatingSystem.IsWindows()) + { + try + { + File.SetUnixFileMode(path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ } + catch (UnauthorizedAccessException) { } + } + + return path; + } + + /// Schränkt eine Datei unter Unix auf 0600 ein. Unter Windows wirkungslos. + public static void RestrictToOwner(string filePath) + { + if (OperatingSystem.IsWindows()) return; + + try + { + File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + // ─── Auflösung ─── + + private static string ResolveConfig() + { + if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir) + return explicitDir; + + if (OperatingSystem.IsWindows()) + return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder); + + if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg) + return Path.Combine(xdg, AppFolder.ToLowerInvariant()); + + if (FromEnvironment("HOME") is { } home) + return Path.Combine(home, ".config", AppFolder.ToLowerInvariant()); + + // Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer + // Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt. + return Path.Combine("/var/lib", AppFolder.ToLowerInvariant()); + } + + private static string ResolveData() + { + if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir) + return explicitDir; + + if (OperatingSystem.IsWindows()) + return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder); + + if (FromEnvironment("XDG_DATA_HOME") is { } xdg) + return Path.Combine(xdg, AppFolder.ToLowerInvariant()); + + if (FromEnvironment("HOME") is { } home) + return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant()); + + return Path.Combine("/var/lib", AppFolder.ToLowerInvariant()); + } + + private static string? FromEnvironment(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + /// + /// GetFolderPath kann eine leere Zeichenkette liefern (Dienstkonto ohne + /// geladenes Profil). Path.Combine("", …) ergäbe dann einen relativen Pfad — + /// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden. + /// + private static string Combine(string root, string folder) + => string.IsNullOrWhiteSpace(root) + ? Path.Combine(AppContext.BaseDirectory, folder) + : Path.Combine(root, folder); +} diff --git a/src/ClawdDotNet.Core/Storage/AtomicFile.cs b/src/ClawdDotNet.Core/Storage/AtomicFile.cs index d3d612b..ae800a3 100644 --- a/src/ClawdDotNet.Core/Storage/AtomicFile.cs +++ b/src/ClawdDotNet.Core/Storage/AtomicFile.cs @@ -29,11 +29,15 @@ public static class AtomicFile /// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt /// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab. /// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar. + /// + /// Der Schlüssel kommt aus : Unter Windows + /// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe + /// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen. /// private static readonly ConcurrentDictionary PathLocks = new(); private static SemaphoreSlim LockFor(string fullPath) - => PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1)); + => PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1)); /// /// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren. diff --git a/src/ClawdDotNet.Core/Storage/PathBoundary.cs b/src/ClawdDotNet.Core/Storage/PathBoundary.cs new file mode 100644 index 0000000..5b01c15 --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/PathBoundary.cs @@ -0,0 +1,155 @@ +namespace ClawdDotNet.Core.Storage; + +/// +/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut. +/// +/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit +/// OrdinalIgnoreCase — die Annahme von Windows, dass Groß- und Kleinschreibung +/// keine Rolle spielt. Unter Linux ist das falsch: /home/x/Workspace und +/// /home/x/workspace sind zwei verschiedene Verzeichnisse. Ein Kandidat im +/// zweiten würde als „innerhalb" des ersten durchgehen. +/// +/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung. +/// +/// Zweiter Punkt, den es unter Windows so nicht gab: symbolische Verknüpfungen. +/// Path.GetFullPath löst sie nicht auf — es rechnet nur .. heraus. Legt +/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach /etc an, liegt +/// workspace/etc/passwd nach reiner Zeichenkettenrechnung innerhalb, zeigt aber +/// hinaus. löst deshalb jeden Pfadabschnitt auf. +/// +public static class PathBoundary +{ + /// + /// Wie das Dateisystem Pfade vergleicht. + /// + /// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist + /// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber + /// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt, + /// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab, + /// statt zu wenig. + /// + public static StringComparison Comparison { get; } = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + public static StringComparer Comparer { get; } = + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + + /// Absoluter Pfad mit genau einem abschließenden Trenner. + public static string NormalizeDirectory(string path) + { + var full = Path.GetFullPath(path); + return full.EndsWith(Path.DirectorySeparatorChar) + ? full + : full + Path.DirectorySeparatorChar; + } + + /// + /// Prüft, ob im Verzeichnis liegt. + /// + /// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne + /// den abschließenden Trenner gälte …/Workspace-Backup als Teil von + /// …/Workspace. + /// + /// Symbolische Verknüpfungen werden aufgelöst () — ein + /// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb. + /// + public static bool IsInside(string candidate, string root) + { + var normalizedRoot = Canonicalize(NormalizeDirectory(root)); + var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate)); + + // Der Root selbst gilt als innerhalb. + if (string.Equals( + normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar), + normalizedRoot.TrimEnd(Path.DirectorySeparatorChar), + Comparison)) + { + return true; + } + + return normalizedCandidate.StartsWith( + NormalizeDirectory(normalizedRoot), Comparison); + } + + /// + /// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen. + /// + /// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort + /// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen. + /// + public static string CanonicalKey(string path) + { + var full = Path.GetFullPath(path); + return Comparison == StringComparison.OrdinalIgnoreCase + ? full.ToLowerInvariant() + : full; + } + + /// + /// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf. + /// + /// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt + /// workspace/daten nach /etc, dann liegt workspace/daten/passwd + /// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung + /// ist. ResolveLinkTarget(returnFinalTarget: true) folgt dabei ganzen Ketten, + /// ein Aufruf je Abschnitt genügt also. + /// + /// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst + /// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine + /// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft. + /// + public static string Canonicalize(string path) + { + var full = Path.GetFullPath(path); + + var root = Path.GetPathRoot(full); + if (string.IsNullOrEmpty(root)) + return full; + + var rest = full[root.Length..] + .Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + var current = root; + + foreach (var segment in rest) + { + current = Path.Combine(current, segment); + + string? target; + try + { + // Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder + // nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten. + target = Directory.Exists(current) + ? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName + : File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName; + } + catch (IOException) + { + // Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar — + // wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet + // dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab. + return full; + } + catch (UnauthorizedAccessException) + { + return full; + } + + if (!string.IsNullOrEmpty(target)) + { + current = Path.IsPathRooted(target) + ? target + : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target)); + } + } + + return Path.GetFullPath(current); + } +} diff --git a/src/ClawdDotNet.Core/Storage/PortableFileName.cs b/src/ClawdDotNet.Core/Storage/PortableFileName.cs new file mode 100644 index 0000000..261ac5e --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/PortableFileName.cs @@ -0,0 +1,55 @@ +namespace ClawdDotNet.Core.Storage; + +/// +/// Erzeugt Dateinamen, die auf Windows und Linux gültig sind. +/// +/// Hintergrund (Linux-Portierung): liefert +/// unter Windows 41 Zeichen, unter Unix genau zwei (\0 und /). Wer sich +/// darauf verlässt, erzeugt unter Linux Namen wie bericht:2026.zip — dort +/// zulässig, unter Windows nicht anlegbar. +/// +/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien, +/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere +/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine +/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt. +/// +public static class PortableFileName +{ + /// Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt. + private static readonly char[] WindowsReserved = + ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + + /// + /// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt. + /// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim + /// Zurückspielen auf. + /// + private static readonly string[] ReservedNames = + [ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + ]; + + public static string Sanitize(string? name, string fallback = "unbenannt") + { + if (string.IsNullOrWhiteSpace(name)) + return fallback; + + var chars = name.Select(c => + c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray(); + + // Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab — + // aus "bericht." würde "bericht", und zwei Dateien fielen zusammen. + var result = new string(chars).TrimEnd('.', ' '); + + if (result.Length == 0) + return fallback; + + var stem = Path.GetFileNameWithoutExtension(result); + if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase)) + result = "_" + result; + + return result; + } +} diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index b0bbacc..582943f 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -152,6 +152,112 @@ public sealed class SqliteStorage CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent ON RunUsage (OccurredAt DESC); + + -- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in + -- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar. + CREATE TABLE IF NOT EXISTS Tasks ( + Id TEXT PRIMARY KEY, + Title TEXT NOT NULL DEFAULT '', + Status TEXT NOT NULL DEFAULT 'todo', + Type TEXT NOT NULL DEFAULT 'work', + Priority INTEGER NOT NULL DEFAULT 3, + Assignee TEXT NOT NULL DEFAULT '@human', + WhenKind TEXT NULL, + WhenValue TEXT NULL, + WhenTz TEXT NULL, + RequireApproval INTEGER NOT NULL DEFAULT 0, + Acceptance TEXT NOT NULL DEFAULT '', + -- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche. + BlockedBy TEXT NOT NULL DEFAULT '', + OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0, + Body TEXT NOT NULL DEFAULT '', + FileName TEXT NOT NULL DEFAULT '', + -- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird. + ToolName TEXT NOT NULL DEFAULT '', + JobTypeId TEXT NOT NULL DEFAULT '', + -- Ausführungszustand: nur das Board schreibt hier. + LastOccurrence TEXT NULL, + ClaimToken TEXT NULL, + ClaimedAt TEXT NULL, + CreatedAt TEXT NOT NULL, + UpdatedAt TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_Tasks_Status + ON Tasks (Status); + + CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee + ON Tasks (Assignee); + + -- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft + -- stempelt die Engine, Korrekturen sind neue Zeilen. + CREATE TABLE IF NOT EXISTS AuditLog ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId TEXT NOT NULL, + AgentId TEXT NOT NULL, + Model TEXT NOT NULL DEFAULT '', + Source TEXT NOT NULL DEFAULT 'unknown', + Tool TEXT NOT NULL, + Arguments TEXT NOT NULL DEFAULT '', + Status TEXT NOT NULL, + Summary TEXT NOT NULL DEFAULT '', + DurationMs INTEGER NOT NULL DEFAULT 0, + OccurredAt TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_AuditLog_Run + ON AuditLog (RunId, Id); + + CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent + ON AuditLog (Id DESC); + + -- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task. + CREATE TABLE IF NOT EXISTS RunReceipts ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId TEXT NOT NULL, + AgentId TEXT NOT NULL, + Model TEXT NOT NULL DEFAULT '', + Source TEXT NOT NULL DEFAULT 'unknown', + TaskId TEXT NULL, + Status TEXT NOT NULL DEFAULT '', + StepCount INTEGER NOT NULL DEFAULT 0, + PromptTokens INTEGER NOT NULL DEFAULT 0, + CompletionTokens INTEGER NOT NULL DEFAULT 0, + CachedTokens INTEGER NOT NULL DEFAULT 0, + CostUsd TEXT NOT NULL DEFAULT '0', + CostIsKnown INTEGER NOT NULL DEFAULT 0, + DurationMs INTEGER NOT NULL DEFAULT 0, + ResultRef TEXT NOT NULL DEFAULT '', + OccurredAt TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task + ON RunReceipts (TaskId); + + CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run + ON RunReceipts (RunId); + + -- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird + -- genau der gespeicherte Argument-JSON (Plan-Freeze). + CREATE TABLE IF NOT EXISTS StagedCalls ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RunId TEXT NOT NULL, + AgentId TEXT NOT NULL, + InstanceId TEXT NOT NULL DEFAULT '', + Tool TEXT NOT NULL, + Action TEXT NULL, + ArgumentsJson TEXT NOT NULL DEFAULT '', + Proposal TEXT NOT NULL DEFAULT '', + Status TEXT NOT NULL DEFAULT 'Pending', + CreatedAt TEXT NOT NULL, + DecidedAt TEXT NULL, + DecidedBy TEXT NULL, + ResultRef TEXT NULL, + RejectionReason TEXT NULL + ); + + CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending + ON StagedCalls (Status, Id); """; cmd.ExecuteNonQuery(); diff --git a/src/ClawdDotNet.Core/Storage/SystemShell.cs b/src/ClawdDotNet.Core/Storage/SystemShell.cs new file mode 100644 index 0000000..9d04577 --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/SystemShell.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; + +namespace ClawdDotNet.Core.Storage; + +/// +/// Öffnet Ordner und Dateien im Dateimanager des Systems. +/// +/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt +/// explorer.exe auf. Hier liegt das plattformneutral — und in Core statt in der +/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht. +/// +/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine +/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne +/// Sitzung), soll das den Aufrufer nicht beschäftigen. +/// +public static class SystemShell +{ + /// Öffnet ein Verzeichnis im Dateimanager. + public static bool OpenFolder(string path) + { + if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) + return false; + + return Open(path); + } + + /// + /// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor. + /// + /// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen + /// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der + /// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von + /// Sonderfällen je Desktop-Umgebung. + /// + public static bool RevealFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return false; + + var folder = Path.GetDirectoryName(Path.GetFullPath(path)); + if (string.IsNullOrEmpty(folder)) return false; + + try + { + if (OperatingSystem.IsWindows()) + return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]); + + if (OperatingSystem.IsMacOS()) + return Start("open", ["-R", Path.GetFullPath(path)]); + } + catch { /* siehe Klassenkommentar */ } + + return OpenFolder(folder); + } + + private static bool Open(string target) + { + var full = Path.GetFullPath(target); + + try + { + if (OperatingSystem.IsLinux()) + return Start("xdg-open", [full]); + + if (OperatingSystem.IsMacOS()) + return Start("open", [full]); + + // Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe + // festzuschreiben. + using var process = Process.Start(new ProcessStartInfo(full) + { + UseShellExecute = true + }); + return process is not null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + return false; + } + } + + /// + /// Startet mit ArgumentList statt einer Argumentzeichenkette — so muss nichts + /// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen + /// zusätzlichen Aufruf einschleusen. + /// + private static bool Start(string fileName, string[] arguments) + { + var info = new ProcessStartInfo(fileName) + { + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var argument in arguments) + info.ArgumentList.Add(argument); + + using var process = Process.Start(info); + return process is not null; + } +} diff --git a/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs b/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs new file mode 100644 index 0000000..2ae38ec --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/CoordinationMigration.cs @@ -0,0 +1,123 @@ +using System.Text.RegularExpressions; +using ClawdDotNet.Core.Storage; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Einmalige Überführung der improvisierten coordination/*.md-Dateien ins Taskboard +/// (A1, Schritt 4). +/// +/// Bewusst konservativ, weil die Altdateien kein Schema haben: Nur task_*.md gelten +/// als Aufgaben und werden als Backlog-Aufgaben mit Assignee @human +/// übernommen — ein Mensch ordnet sie zu, bevor irgendetwas läuft. status_*, +/// broadcast, Incident-Berichte und *.json-Artefakte sind keine Aufgaben und +/// bleiben unangetastet. Was sich nicht sicher deuten lässt, wird nicht verfälscht. +/// +/// Idempotent: Eine übernommene Datei wandert nach coordination/migrated/ — sie +/// wird nicht gelöscht (die Historie bleibt), aber ein zweiter Start findet sie nicht mehr. +/// +public sealed class CoordinationMigration +{ + private readonly TaskboardService _board; + private readonly string _coordinationDir; + private readonly ILogger _logger; + + private static readonly Regex TaskHeading = + new(@"^#\s*Task\b[^:]*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex AnyHeading = + new(@"^#\s+(.+)$", RegexOptions.Compiled); + + public CoordinationMigration( + TaskboardService board, string coordinationDirectory, ILoggerFactory loggerFactory) + { + _board = board; + _coordinationDir = coordinationDirectory; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Migration"); + } + + /// Führt die Migration aus. Gibt die Zahl der übernommenen Aufgaben zurück. + public async Task RunAsync(CancellationToken ct) + { + if (!Directory.Exists(_coordinationDir)) + return 0; + + var migratedDir = Path.Combine(_coordinationDir, "migrated"); + var count = 0; + + // Nur die oberste Ebene — der migrated/-Unterordner wird so nie erneut gelesen. + foreach (var path in Directory.EnumerateFiles(_coordinationDir, "task_*.md")) + { + string text; + try { text = AtomicFile.ReadAllText(path); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Migration: {File} nicht lesbar — übersprungen", path); + continue; + } + + var fileName = Path.GetFileName(path); + var title = ExtractTitle(text, fileName); + + var body = + $"_Übernommen aus coordination/{fileName} bei der Taskboard-Migration (A1). " + + "Assignee und Status bitte prüfen._\n\n" + text.Trim(); + + await _board.CreateAsync(new TaskItem + { + Title = title, + Body = body, + Status = TaskItemStatus.Backlog, // erst nach menschlicher Sichtung bereit + Assignee = TaskAssignee.Human, // sicherer Standard, bis jemand zuordnet + Type = TaskItemType.Work + }, ct); + + try + { + Directory.CreateDirectory(migratedDir); + File.Move(path, Path.Combine(migratedDir, fileName), overwrite: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Migration: {File} übernommen, konnte aber nicht verschoben werden", fileName); + } + + count++; + } + + if (count > 0) + _logger.LogInformation("Taskboard-Migration: {Count} coordination/task_*-Datei(en) übernommen", count); + + return count; + } + + private static string ExtractTitle(string text, string fileName) + { + foreach (var line in text.Replace("\r\n", "\n").Split('\n')) + { + var match = TaskHeading.Match(line.Trim()); + if (match.Success) + return Trim(match.Groups[1].Value); + } + + foreach (var line in text.Replace("\r\n", "\n").Split('\n')) + { + var match = AnyHeading.Match(line.Trim()); + if (match.Success) + return Trim(match.Groups[1].Value); + } + + // Kein Titel im Text: aus dem Dateinamen ableiten ("task_video_x" → "video x"). + var stem = Path.GetFileNameWithoutExtension(fileName); + if (stem.StartsWith("task_", StringComparison.OrdinalIgnoreCase)) + stem = stem[5..]; + return Trim(stem.Replace('_', ' ')); + } + + private static string Trim(string value) + { + var v = value.Trim(); + return v.Length > 120 ? v[..120].TrimEnd() : v; + } +} diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs new file mode 100644 index 0000000..cc539c6 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs @@ -0,0 +1,171 @@ +using System.Text; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Tools; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Verbindet den Scanner mit der Engine: übersetzt den Assignee einer fälligen Aufgabe in +/// einen konkreten Lauf. +/// +/// - @new / @new:<agent>: frischer +/// Lauf ohne Historie. +/// - @<agent>: der Agent mit seinem +/// bestehenden Kontext. Das Agent-Gate der Engine serialisiert solche Läufe (B2). +/// +/// Ist der Ziel-Agent nicht auflösbar (unbekannt, oder bloßes @new bei mehreren +/// Agenten), scheitert der Dispatch mit einer klaren Meldung, statt einen falschen Agenten +/// zu raten. +/// +public sealed class EngineTaskDispatcher : ITaskDispatcher +{ + private readonly AgentEngine _engine; + private readonly Func> _agents; + private readonly string _instanceId; + private readonly ToolRegistry _toolRegistry; + private readonly IStateStore _stateStore; + private readonly ILogger _logger; + + public EngineTaskDispatcher( + AgentEngine engine, + Func> agents, + string instanceId, + ToolRegistry toolRegistry, + IStateStore stateStore, + ILoggerFactory loggerFactory) + { + _engine = engine; + _agents = agents; + _instanceId = instanceId; + _toolRegistry = toolRegistry; + _stateStore = stateStore; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher"); + } + + public async Task DispatchAsync(TaskItem task, CancellationToken ct) + { + // Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten. + if (task.Type == TaskItemType.ToolJob) + return await DispatchToolJobAsync(task, ct); + + var agents = _agents(); + var message = BuildMessage(task); + var kind = TaskAssignee.KindOf(task.Assignee); + + AgentRunResult result; + switch (kind) + { + case TaskAssigneeKind.New: + { + var config = ResolveFreshAgent(agents, TaskAssignee.AgentId(task.Assignee)); + if (config is null) + { + _logger.LogWarning( + "Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)", + task.Id, task.Assignee); + return false; + } + result = await _engine.RunAsync( + config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id); + break; + } + case TaskAssigneeKind.Agent: + { + var agentId = TaskAssignee.AgentId(task.Assignee); + var config = agents.FirstOrDefault(a => a.AgentId == agentId); + if (config is null) + { + _logger.LogWarning( + "Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId); + return false; + } + result = await _engine.ChatAsync( + config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id); + break; + } + default: + // @human wird vom Scanner gar nicht erst angestoßen. + return false; + } + + return result.Status == AgentRunStatus.Completed; + } + + /// + /// Tickt einen (Poll) und weckt den Ziel-Agenten nur, + /// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts + /// zu tun war — ein Poll ohne Fund ist kein Fehler. + /// + private async Task DispatchToolJobAsync(TaskItem task, CancellationToken ct) + { + if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider) + { + _logger.LogWarning( + "Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert", + task.Id, task.ToolName); + return false; + } + + // Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace. + var agentId = TaskAssignee.AgentId(task.Assignee); + var config = _agents().FirstOrDefault(a => a.AgentId == agentId); + var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg) + ? (IReadOnlyDictionary)cfg.AsReadOnly() + : new Dictionary().AsReadOnly(); + + var result = await provider.ExecuteJobAsync( + task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath); + + if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage)) + return true; // Poll lief, nichts zu wecken + + if (config is null) + { + _logger.LogWarning( + "Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde", + task.Id, agentId); + return true; + } + + // Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos. + if (result.UseChatContext) + await _engine.ChatAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id); + else + await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id); + + return true; + } + + private static AgentConfig? ResolveFreshAgent(IReadOnlyList agents, string agentId) + { + if (!string.IsNullOrWhiteSpace(agentId)) + return agents.FirstOrDefault(a => a.AgentId == agentId); + + // Bloßes @new ohne Agent: nur eindeutig, wenn die Instanz genau einen Agenten hat. + return agents.Count == 1 ? agents[0] : null; + } + + private static string BuildMessage(TaskItem task) + { + var sb = new StringBuilder(); + sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}"); + + if (!string.IsNullOrWhiteSpace(task.Body)) + { + sb.AppendLine(); + sb.AppendLine(task.Body.Trim()); + } + + if (!string.IsNullOrWhiteSpace(task.Acceptance)) + { + sb.AppendLine(); + sb.AppendLine("Abnahmekriterien (das Ergebnis wird daran gemessen):"); + sb.AppendLine(task.Acceptance.Trim()); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs new file mode 100644 index 0000000..325706d --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/ITaskRepository.cs @@ -0,0 +1,65 @@ +namespace ClawdDotNet.Core.Tasks; + +/// +/// Der Ausführungszustand des Taskboards. Die Definition der Aufgaben lebt in +/// Markdown-Dateien; dieses Repository ist die DB-Seite, die das Claiming atomar macht +/// (was ein Dateisystem nicht verlässlich kann) und dem Scanner erlaubt, ohne +/// Dateizugriff zu entscheiden. +/// +public interface ITaskRepository +{ + /// + /// Legt eine Aufgabe an oder aktualisiert ihre Definition (Importer). Idempotent über + /// die — ein zweiter Import erzeugt keine Dublette. Der + /// Ausführungszustand (Marker, Claim) bleibt dabei unangetastet. + /// + Task UpsertAsync(TaskItem task, CancellationToken ct); + + Task GetAsync(string id, CancellationToken ct); + + Task> ListAsync(TaskQuery query, CancellationToken ct); + + /// + /// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt; + /// jeder weitere erhält false. Setzt den Marker () + /// sofort — ein Termin ist damit auch dann verbraucht, wenn der Lauf später scheitert + /// oder der Prozess abstürzt (at-most-once, kein Retry-Sturm). + /// + /// Sortierbarer ISO-UTC-Zeitstempel des Termins. + /// + /// Claims, die älter sind, gelten als verwaist (abgestürzter Lauf) und dürfen + /// überschrieben werden. + /// + Task TryClaimAsync( + string id, string occurrenceKey, string claimToken, + DateTime now, DateTime leaseCutoff, CancellationToken ct); + + /// + /// Schließt einen beanspruchten Lauf ab und setzt den Endstatus. Nur wirksam, solange + /// der Aufrufer den Claim noch hält ( passt) — ein Lauf, + /// der seine Lease verloren hat, überschreibt nichts mehr. Gibt zurück, ob der Claim + /// noch gehörte. + /// + Task CompleteClaimAsync( + string id, string claimToken, TaskItemStatus finalStatus, + DateTime now, CancellationToken ct); + + /// Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation). + Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct); + + /// + /// Reconciliation beim Start: verwaiste Claims (älter als ) + /// lösen und die betroffenen Aufgaben von zurück + /// auf stellen. Gibt die Zahl der zurückgesetzten + /// Aufgaben zurück. + /// + Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct); + + /// + /// Aufgaben, die auf warten — Grundlage für den + /// Auto-Dispatch, wenn der letzte Blocker fertig wird. + /// + Task> ListBlockedByAsync(string blockerId, CancellationToken ct); + + Task CountAsync(CancellationToken ct); +} diff --git a/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs b/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs new file mode 100644 index 0000000..88d78e3 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs @@ -0,0 +1,106 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Scheduling; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Überführt die alten Scheduler-Konfigurationen ins Taskboard — der letzte Schritt, um +/// die Alt-Scheduler abzulösen: alles Periodische ist danach ein Task. +/// +/// - Ein scheduler (Agent nach Cron) → ein Task mit when: cron und Assignee +/// @new:<agent> (frischer Lauf, wie der alte AgentScheduler). +/// - Jeder toolJobs-Eintrag (Poll) → ein Task vom Typ tool_job. +/// +/// Einmalig und nicht-destruktiv: Existiert der Task (stabile Id) schon, wird er +/// übersprungen — spätere Änderungen an der Task-Datei bleiben erhalten. +/// +public sealed class SchedulerTaskMigration +{ + private readonly TaskboardService _board; + private readonly ITaskRepository _repo; + private readonly ILogger _logger; + + public SchedulerTaskMigration(TaskboardService board, ITaskRepository repo, ILoggerFactory loggerFactory) + { + _board = board; + _repo = repo; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.SchedulerMigration"); + } + + public async Task RunAsync(IEnumerable agents, CancellationToken ct) + { + // IANA-Schreibweise, nicht TimeZoneInfo.Local.Id: Unter Windows lieferte das + // "W. Europe Standard Time", unter Linux "Europe/Berlin". Die erzeugten + // Task-Dateien wandern zwischen Rechnern — sie brauchen die Form, die überall + // gilt. + var localTz = TimeZones.LocalIanaId; + var created = 0; + + foreach (var agent in agents) + { + if (agent.Scheduler is { } scheduler && !string.IsNullOrWhiteSpace(scheduler.Cron)) + created += await MigrateSchedulerAsync(agent, scheduler, localTz, ct); + + foreach (var job in agent.ToolJobs) + created += await MigrateToolJobAsync(agent, job, localTz, ct); + } + + if (created > 0) + _logger.LogInformation("Scheduler-Migration: {Count} Task(s) aus Alt-Konfiguration angelegt", created); + + return created; + } + + private async Task MigrateSchedulerAsync( + AgentConfig agent, SchedulerConfig scheduler, string tz, CancellationToken ct) + { + var id = $"sched-{agent.AgentId}"; + if (await _repo.GetAsync(id, ct) is not null) + return 0; + + await _board.CreateAsync(new TaskItem + { + Id = id, + Title = $"Geplanter Lauf: {Display(agent)}", + Body = scheduler.TaskMessage, + Status = TaskItemStatus.Todo, + Type = TaskItemType.Work, + Assignee = $"@new:{agent.AgentId}", // frischer Lauf, wie der alte AgentScheduler + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = scheduler.Cron, TimeZone = tz } + }, ct); + + return 1; + } + + private async Task MigrateToolJobAsync( + AgentConfig agent, ToolJobConfig job, string tz, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(job.Cron) || string.IsNullOrWhiteSpace(job.ToolName)) + return 0; + + var id = $"tj-{agent.AgentId}-{job.JobId}"; + if (await _repo.GetAsync(id, ct) is not null) + return 0; + + await _board.CreateAsync(new TaskItem + { + Id = id, + Title = $"Poll: {job.ToolName}/{job.JobTypeId} → {Display(agent)}", + Body = $"Wiederkehrender {job.ToolName}-Poll ({job.JobTypeId}). Weckt {Display(agent)} bei einem Ereignis.", + // Deaktivierte Jobs kommen als backlog — der Scanner nimmt nur todo/backlog... + // backlog wird aber nicht geclaimt, also ruht der Poll, bis jemand ihn aktiviert. + Status = job.Enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog, + Type = TaskItemType.ToolJob, + ToolName = job.ToolName, + JobTypeId = job.JobTypeId, + Assignee = $"@{agent.AgentId}", // Zielagent zum Wecken + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = job.Cron, TimeZone = tz } + }, ct); + + return 1; + } + + private static string Display(AgentConfig agent) + => string.IsNullOrWhiteSpace(agent.DisplayName) ? agent.AgentId : agent.DisplayName; +} diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs new file mode 100644 index 0000000..5b2f48e --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs @@ -0,0 +1,318 @@ +using System.Text; +using ClawdDotNet.Core.Storage; +using Microsoft.Data.Sqlite; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Der Ausführungszustand des Taskboards in der Instanz-Datenbank. +/// +/// Der Kern ist : ein bedingtes UPDATE, das genau eine +/// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau +/// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht +/// bei den Task-Dateien. +/// +/// Zeitangaben werden als sortierbares ISO-UTC ("O") abgelegt, damit +/// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst. +/// +public sealed class SqliteTaskRepository : ITaskRepository +{ + private readonly SqliteStorage _storage; + + public SqliteTaskRepository(SqliteStorage storage) => _storage = storage; + + public Task UpsertAsync(TaskItem task, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + var now = DateTime.UtcNow; + var existing = await ReadAsync(conn, task.Id, ct); + + if (existing is null) + { + using var insert = conn.CreateCommand(); + insert.CommandText = """ + INSERT INTO Tasks + (Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, + RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, + ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt) + VALUES + (@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz, + @requireApproval, @acceptance, @blockedBy, @market, @body, @fileName, + @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt); + """; + BindDefinition(insert, task); + insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status)); + insert.Parameters.AddWithValue("@createdAt", Format(now)); + insert.Parameters.AddWithValue("@updatedAt", Format(now)); + await insert.ExecuteNonQueryAsync(ct); + } + else + { + // Nur die Definition aktualisieren. Status und Ausführungszustand + // (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden + // oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen + // laufen über SetStatusAsync bzw. den Scanner. + using var update = conn.CreateCommand(); + update.CommandText = """ + UPDATE Tasks + SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee, + WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz, + RequireApproval = @requireApproval, Acceptance = @acceptance, + BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body, + FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType, + UpdatedAt = @updatedAt + WHERE Id = @id + """; + BindDefinition(update, task); + update.Parameters.AddWithValue("@updatedAt", Format(now)); + await update.ExecuteNonQueryAsync(ct); + } + + return (await ReadAsync(conn, task.Id, ct))!; + }, ct); + + public async Task GetAsync(string id, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + return await ReadAsync(conn, id, ct); + } + + public async Task> ListAsync(TaskQuery query, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + + var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1"); + + if (query.Status is { } status) + { + sql.Append(" AND Status = @status"); + cmd.Parameters.AddWithValue("@status", TaskText.Of(status)); + } + else if (!query.IncludeArchived) + { + sql.Append(" AND Status <> 'archived'"); + } + + if (!string.IsNullOrWhiteSpace(query.Assignee)) + { + sql.Append(" AND Assignee = @assignee COLLATE NOCASE"); + cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim()); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE" + + " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)"); + cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%"); + } + + // Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält. + sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit"); + cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500)); + cmd.CommandText = sql.ToString(); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + + return results; + } + + public Task TryClaimAsync( + string id, string occurrenceKey, string claimToken, + DateTime now, DateTime leaseCutoff, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE Tasks + SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress', + LastOccurrence = @occ, UpdatedAt = @now + WHERE Id = @id + AND Status = 'todo' + AND (LastOccurrence IS NULL OR LastOccurrence < @occ) + AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff) + """; + cmd.Parameters.AddWithValue("@token", claimToken); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@occ", occurrenceKey); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff)); + + return await cmd.ExecuteNonQueryAsync(ct) == 1; + }, ct); + + public Task CompleteClaimAsync( + string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE Tasks + SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now + WHERE Id = @id AND ClaimToken = @token + """; + cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus)); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@token", claimToken); + + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + + public Task SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id"; + cmd.Parameters.AddWithValue("@status", TaskText.Of(status)); + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@id", id); + + return await cmd.ExecuteNonQueryAsync(ct) > 0; + }, ct); + + public Task ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct) + => _storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE Tasks + SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now + WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff + """; + cmd.Parameters.AddWithValue("@now", Format(now)); + cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff)); + + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + public async Task> ListBlockedByAsync(string blockerId, CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + Columns + + " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'"; + cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%"); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + results.Add(Read(reader)); + + return results; + } + + public async Task CountAsync(CancellationToken ct) + { + await using var conn = await _storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM Tasks"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); + } + + // ─── Hilfsfunktionen ─── + + private const string Columns = + "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " + + "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " + + "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt"; + + private static void BindDefinition(SqliteCommand cmd, TaskItem task) + { + cmd.Parameters.AddWithValue("@id", task.Id); + cmd.Parameters.AddWithValue("@title", task.Title); + cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type)); + cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5)); + cmd.Parameters.AddWithValue("@assignee", task.Assignee); + cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value); + cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value); + cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value); + cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0); + cmd.Parameters.AddWithValue("@acceptance", task.Acceptance); + cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy)); + cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0); + cmd.Parameters.AddWithValue("@body", task.Body); + cmd.Parameters.AddWithValue("@fileName", task.FileName); + cmd.Parameters.AddWithValue("@toolName", task.ToolName); + cmd.Parameters.AddWithValue("@jobType", task.JobTypeId); + } + + private static async Task ReadAsync(SqliteConnection conn, string id, CancellationToken ct) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1"; + cmd.Parameters.AddWithValue("@id", id); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + return await reader.ReadAsync(ct) ? Read(reader) : null; + } + + private static TaskItem Read(SqliteDataReader r) + { + TaskWhen? when = null; + if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind) + { + when = new TaskWhen + { + Kind = kind, + Value = r.IsDBNull(7) ? "" : r.GetString(7), + TimeZone = r.IsDBNull(8) ? "" : r.GetString(8) + }; + } + + return new TaskItem + { + Id = r.GetString(0), + Title = r.GetString(1), + Status = TaskText.Status(r.GetString(2)), + Type = TaskText.Type(r.GetString(3)), + Priority = r.GetInt32(4), + Assignee = r.GetString(5), + When = when, + RequireApproval = r.GetInt32(9) != 0, + Acceptance = r.GetString(10), + BlockedBy = DeserializeBlockedBy(r.GetString(11)), + OnlyWhenMarketOpen = r.GetInt32(12) != 0, + Body = r.GetString(13), + FileName = r.GetString(14), + ToolName = r.GetString(15), + JobTypeId = r.GetString(16), + LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17), + ClaimToken = r.IsDBNull(18) ? null : r.GetString(18), + ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)), + CreatedAt = Parse(r.GetString(20)), + UpdatedAt = Parse(r.GetString(21)) + }; + } + + /// Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids, + /// ohne dass t-1 auch t-10 trifft. + private static string SerializeBlockedBy(IReadOnlyList ids) + { + var cleaned = ids + .Select(x => x.Trim().Replace("|", "")) + .Where(x => x.Length > 0) + .Distinct() + .ToList(); + + return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|"; + } + + private static IReadOnlyList DeserializeBlockedBy(string raw) + => raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static string Escape(string value) => value + .Replace(@"\", @"\\") + .Replace("%", @"\%") + .Replace("_", @"\_"); + + private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); + + private static DateTime Parse(string value) + => DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) + ? dt + : DateTime.MinValue; +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs new file mode 100644 index 0000000..e854ced --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs @@ -0,0 +1,313 @@ +using System.Text; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen +/// ----Zeilen, gefolgt vom Rumpf. +/// +/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist +/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten when, einer +/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt +/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die +/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten. +/// +public static class TaskFrontmatter +{ + /// + /// Zerlegt eine Task-Datei in ihre Definition. Gibt false mit einer Meldung + /// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand + /// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB. + /// + public static bool TryParse(string text, out TaskItem task, out string? error) + { + task = new TaskItem(); + error = null; + + var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n"); + + // Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren). + var lines = normalized.Split('\n'); + var open = -1; + for (var i = 0; i < lines.Length; i++) + { + var t = lines[i].TrimStart('').Trim(); + if (t.Length == 0) continue; + if (t == "---") { open = i; break; } + break; // erste nicht-leere Zeile ist kein Fence + } + + if (open < 0) + { + error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile)."; + return false; + } + + var close = -1; + for (var i = open + 1; i < lines.Length; i++) + { + if (lines[i].Trim() == "---") { close = i; break; } + } + + if (close < 0) + { + error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt)."; + return false; + } + + var block = lines[(open + 1)..close]; + var body = string.Join('\n', lines[(close + 1)..]).Trim('\n'); + + var scalars = new Dictionary(StringComparer.OrdinalIgnoreCase); + var when = new Dictionary(StringComparer.OrdinalIgnoreCase); + var blockedBy = new List(); + string? acceptance = null; + + var j = 0; + while (j < block.Length) + { + var raw = block[j]; + var trimmed = raw.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; } + + // Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen + // gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen. + if (Indent(raw) > 0) { j++; continue; } + + var colon = trimmed.IndexOf(':'); + if (colon < 0) { j++; continue; } + + var key = trimmed[..colon].Trim(); + var rest = trimmed[(colon + 1)..].Trim(); + + if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0) + { + j = CollectChildren(block, j + 1, child => + { + var c = child.IndexOf(':'); + if (c < 0) return; + when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim()); + }); + continue; + } + + if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest)) + { + acceptance = ReadBlockScalar(block, ref j); + continue; + } + + if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase)) + { + if (rest.StartsWith('[')) + { + blockedBy.AddRange(ParseInlineList(rest)); + j++; + } + else if (rest.Length == 0) + { + // Block-Liste: nachfolgende "- item"-Zeilen. + j = CollectChildren(block, j + 1, child => + { + var item = child.TrimStart(); + if (item.StartsWith('-')) + blockedBy.Add(Unquote(item[1..].Trim())); + }); + } + else + { + blockedBy.AddRange(ParseInlineList(rest)); + j++; + } + continue; + } + + scalars[key] = Unquote(rest); + j++; + } + + TaskWhen? whenValue = null; + if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind) + { + whenValue = new TaskWhen + { + Kind = kind, + Value = when.GetValueOrDefault("value", ""), + TimeZone = when.GetValueOrDefault("tz", "") + }; + } + + task = new TaskItem + { + Id = scalars.GetValueOrDefault("id", ""), + Title = scalars.GetValueOrDefault("title", ""), + Status = TaskText.Status(scalars.GetValueOrDefault("status")), + Type = TaskText.Type(scalars.GetValueOrDefault("type")), + Priority = ParsePriority(scalars.GetValueOrDefault("priority")), + Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human), + When = whenValue, + RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")), + Acceptance = acceptance?.Trim() ?? "", + BlockedBy = blockedBy, + OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")), + ToolName = scalars.GetValueOrDefault("tool_name", ""), + JobTypeId = scalars.GetValueOrDefault("job_type", ""), + Body = body + }; + return true; + } + + /// Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand + /// bleibt außen vor — er gehört in die DB, nicht in die Datei. + public static string Serialize(TaskItem task) + { + var sb = new StringBuilder(); + sb.Append("---\n"); + sb.Append($"id: {Scalar(task.Id)}\n"); + sb.Append($"title: {Scalar(task.Title)}\n"); + sb.Append($"status: {TaskText.Of(task.Status)}\n"); + sb.Append($"type: {TaskText.Of(task.Type)}\n"); + sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n"); + sb.Append($"assignee: {Scalar(task.Assignee)}\n"); + + if (task.When is { } w) + { + sb.Append("when:\n"); + sb.Append($" kind: {TaskText.Of(w.Kind)}\n"); + sb.Append($" value: {Scalar(w.Value)}\n"); + sb.Append($" tz: {Scalar(w.TimeZone)}\n"); + } + + // Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird. + if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName)) + { + sb.Append($"tool_name: {Scalar(task.ToolName)}\n"); + sb.Append($"job_type: {Scalar(task.JobTypeId)}\n"); + } + + sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n"); + + if (!string.IsNullOrWhiteSpace(task.Acceptance)) + { + sb.Append("acceptance: |\n"); + foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n')) + sb.Append(" ").Append(line).Append('\n'); + } + + if (task.BlockedBy.Count > 0) + sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n"); + + sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n"); + sb.Append("---\n"); + + if (!string.IsNullOrWhiteSpace(task.Body)) + sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n'); + + return sb.ToString(); + } + + // ─── Hilfsfunktionen ─── + + private static int Indent(string line) + { + var n = 0; + while (n < line.Length && line[n] == ' ') n++; + return n; + } + + /// Ruft für jede eingerückte Folgezeile auf und + /// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück. + private static int CollectChildren(string[] block, int start, Action onChild) + { + var k = start; + while (k < block.Length) + { + var line = block[k]; + if (line.Trim().Length == 0) { k++; continue; } + if (Indent(line) == 0) break; + onChild(line.Trim()); + k++; + } + return k; + } + + private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-"; + + /// Liest einen eingerückten Block-Skalar und entfernt die gemeinsame + /// Einrückung. Setzt hinter den Block. + private static string ReadBlockScalar(string[] block, ref int j) + { + var collected = new List(); + var k = j + 1; + var commonIndent = int.MaxValue; + + while (k < block.Length) + { + var line = block[k]; + if (line.Trim().Length == 0) { collected.Add(""); k++; continue; } + if (Indent(line) == 0) break; + commonIndent = Math.Min(commonIndent, Indent(line)); + collected.Add(line); + k++; + } + + j = k; + if (commonIndent == int.MaxValue) commonIndent = 0; + + var sb = new StringBuilder(); + foreach (var line in collected) + sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n'); + + return sb.ToString().TrimEnd('\n'); + } + + private static IEnumerable ParseInlineList(string rest) + { + var inner = rest.Trim(); + if (inner.StartsWith('[')) inner = inner[1..]; + if (inner.EndsWith(']')) inner = inner[..^1]; + + return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Unquote) + .Where(s => s.Length > 0); + } + + private static string Unquote(string value) + { + var v = value.Trim(); + if (v.Length >= 2 && v[0] == '"' && v[^1] == '"') + return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\"); + if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'') + return v[1..^1].Replace("''", "'"); + return v; + } + + /// Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes + /// @ (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert + /// werden. + private static string Scalar(string? value) + { + var v = value ?? ""; + if (v.Length == 0) return "\"\""; + + var needsQuote = + v != v.Trim() + || "@-[]{}>|*&!%#`,\"'".Contains(v[0]) + || v.Contains(':') + || v.Contains('#') + || v.Contains('\n'); + + if (!needsQuote) return v; + + return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + + private static bool ParseBool(string? value) + => value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on"; + + private static int ParsePriority(string? value) + => int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3; + + private static string EmptyToDefault(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs new file mode 100644 index 0000000..31e390b --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs @@ -0,0 +1,233 @@ +namespace ClawdDotNet.Core.Tasks; + +/// +/// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt, +/// damit beide Seiten dieselbe, menschenlesbare Form teilen. +/// +public enum TaskItemStatus +{ + /// Angelegt, aber noch nicht zur Ausführung freigegeben. + Backlog, + + /// Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen. + Todo, + + /// Ein Lauf hat sie beansprucht und arbeitet daran. + InProgress, + + /// Erledigt, wartet aber auf Review (require_approval). + InReview, + + /// Abgeschlossen. + Done, + + /// Verworfen, ohne erledigt zu sein. + Canceled, + + /// Wartet auf einen offenen Blocker (blocked_by) oder auf eine Meldung. + Blocked, + + /// Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie. + Archived +} + +/// +/// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — approval +/// und human_input unterscheiden sich nur darin, dass ihr Ergebnis von einem +/// Menschen statt von einem Modell kommt. +/// +public enum TaskItemType +{ + Work, + Approval, + HumanInput, + + /// + /// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen + /// IToolJobProvider (siehe /) + /// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren + /// ToolJobScheduler — alles Periodische ist jetzt ein Task. + /// + ToolJob +} + +/// Wie ein Termin zu verstehen ist. +public enum TaskWhenKind +{ + /// Einmaliger Zeitpunkt (ISO 8601). + At, + + /// Wiederkehrendes Intervall (z. B. 30m, 2h). + Every, + + /// 5-Felder-Cron-Ausdruck. + Cron +} + +/// +/// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher +/// zonen-blind in Lokalzeit). Ohne Zone lässt sich 0 7 * * * nicht eindeutig +/// deuten. +/// +public sealed record TaskWhen +{ + public TaskWhenKind Kind { get; init; } + public string Value { get; init; } = ""; + + /// IANA-Zeitzone, etwa Europe/Berlin. + public string TimeZone { get; init; } = ""; +} + +/// +/// Wer eine Aufgabe ausführt. Löst das implizite UseChatContext-Flag (T7) durch +/// eine explizite Angabe am Auftrag ab. +/// +public enum TaskAssigneeKind +{ + /// @new — frischer Lauf ohne Historie (RunAsync). + New, + + /// @<agentId> — bestehender Agent mit seinem Kontext (ChatAsync). + Agent, + + /// @human — wartet auf einen Menschen; kein Modell-Lauf. + Human +} + +/// +/// Deutet den assignee-String, ohne ihn selbst zu speichern. Formen: +/// @human, @<agentId> (bestehender Kontext), @new bzw. +/// @new:<agentId> (frischer Lauf ohne Historie). Bei bloßem @new ohne +/// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben. +/// +public static class TaskAssignee +{ + public const string New = "@new"; + public const string Human = "@human"; + + public static TaskAssigneeKind KindOf(string? assignee) + { + var value = assignee?.Trim(); + if (string.Equals(value, Human, StringComparison.OrdinalIgnoreCase)) + return TaskAssigneeKind.Human; + if (string.Equals(value, New, StringComparison.OrdinalIgnoreCase) + || value?.StartsWith("@new:", StringComparison.OrdinalIgnoreCase) == true) + return TaskAssigneeKind.New; + return TaskAssigneeKind.Agent; + } + + /// + /// Die reine Agent-Id. Für @<agent> der Teil hinter dem @, für + /// @new:<agent> der Teil hinter dem Doppelpunkt. Leer bei @human + /// und bloßem @new. + /// + public static string AgentId(string? assignee) + { + var value = assignee?.Trim() ?? ""; + switch (KindOf(value)) + { + case TaskAssigneeKind.Agent: + return value.StartsWith('@') ? value[1..] : value; + case TaskAssigneeKind.New: + var colon = value.IndexOf(':'); + return colon >= 0 ? value[(colon + 1)..].Trim() : ""; + default: + return ""; + } + } +} + +/// +/// Eine Aufgabe — die Spiegelung der Frontmatter-Definition zusammen mit dem +/// Ausführungszustand. Die Definition ist in der Datei die Wahrheit, der +/// Ausführungszustand in der DB (siehe Taskboard-Konzept). Diese Zeile hält beides, +/// damit der Scanner ohne Dateizugriff entscheiden kann. +/// +public sealed record TaskItem +{ + // ─── Definition (aus dem Frontmatter gespiegelt) ─── + + /// Stabile Identität. Überlebt das Umbenennen der Datei. + public string Id { get; init; } = ""; + + public string Title { get; init; } = ""; + public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo; + public TaskItemType Type { get; init; } = TaskItemType.Work; + + /// 1 (niedrig) .. 5 (hoch). + public int Priority { get; init; } = 3; + + public string Assignee { get; init; } = TaskAssignee.Human; + + /// Termin; null = einmalige, sofort fällige Aufgabe. + public TaskWhen? When { get; init; } + + /// Gilt erst nach Review als . + public bool RequireApproval { get; init; } + + /// Abnahmekriterien, gegen die das Ergebnis geprüft wird. + public string Acceptance { get; init; } = ""; + + /// Ids der Aufgaben, die erst erledigt sein müssen. + public IReadOnlyList BlockedBy { get; init; } = []; + + /// C1: Termin nur auslösen, wenn der Markt offen ist. + public bool OnlyWhenMarketOpen { get; init; } + + /// Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten. + public string Body { get; init; } = ""; + + /// Dateiname relativ zu tasks/. Dient dem Rückschreiben. + public string FileName { get; init; } = ""; + + // ─── Nur für Typ tool_job ─── + + /// Das zu tickende Tool (z. B. Telegram) — nur bei tool_job. + public string ToolName { get; init; } = ""; + + /// Die Job-Art des Tools (z. B. telegram_poll) — nur bei tool_job. + public string JobTypeId { get; init; } = ""; + + // ─── Ausführungszustand (nur das Board schreibt) ─── + + /// + /// Occurrence-Key des zuletzt behandelten Termins (sortierbares ISO-UTC). Ein neuer + /// Termin gilt nur als fällig, wenn er hierüber liegt — so wird ein Termin höchstens + /// einmal ausgelöst. + /// + public string? LastOccurrence { get; init; } + + /// Gesetzt, solange ein Lauf die Aufgabe beansprucht. + public string? ClaimToken { get; init; } + + public DateTime? ClaimedAt { get; init; } + + public DateTime CreatedAt { get; init; } + public DateTime UpdatedAt { get; init; } + + /// + /// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf todo zurück statt auf + /// done — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein + /// tool_job ist immer ein Poll und damit wiederkehrend. + /// + public bool IsRecurring + => Type == TaskItemType.ToolJob + || When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every }; +} + +/// Suchkriterien für . +public sealed record TaskQuery +{ + public TaskItemStatus? Status { get; init; } + + /// Filtert auf einen Assignee (roher String, z. B. @crawler). + public string? Assignee { get; init; } + + /// Freitext über Titel und Beschreibung. + public string? Search { get; init; } + + /// Archivierte werden standardmäßig ausgeblendet. + public bool IncludeArchived { get; init; } + + public int Limit { get; init; } = 50; +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs new file mode 100644 index 0000000..5fe5ac7 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs @@ -0,0 +1,262 @@ +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der +/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar. +/// +public interface ITaskDispatcher +{ + /// Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss. + Task DispatchAsync(TaskItem task, CancellationToken ct); +} + +/// +/// Entscheidet, ob ein marktabhängiger Termin () +/// jetzt laufen darf. Platzhalter, bis C1 (Marktkalender) den echten Kalender liefert. +/// +public interface IMarketCalendar +{ + bool IsOpen(DateTime nowUtc); +} + +/// Bis C1 kommt: der Markt gilt als immer offen. +public sealed class AlwaysOpenMarketCalendar : IMarketCalendar +{ + public bool IsOpen(DateTime nowUtc) => true; +} + +/// +/// Der Taktgeber des Taskboards (A1). Ein einziger Takt prüft, was fällig ist — +/// keine langen Delays (B6). Persistiert wird nur der Last-Fired-Marker; ein +/// fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin (kein Retry-Sturm). +/// +/// Der heikle Kern ist streng gegen drei Invarianten gebaut, die die Repository-Tests +/// und die Scanner-Tests absichern: +/// 1. Nie zwei Läufe auf denselben Termin — das atomare . +/// 2. Kein Dispatch bei offenem Blocker — blockierte Aufgaben sind nicht claimbar. +/// 3. Doppelter Takt = ein Lauf — konkurrierende Claims um denselben Occurrence-Key. +/// +public sealed class TaskScanner : IAsyncDisposable +{ + private readonly ITaskRepository _repo; + private readonly ITaskDispatcher _dispatcher; + private readonly IMarketCalendar _market; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + private readonly TimeSpan _tick; + private readonly TimeSpan _lease; + + /// Aufgaben, deren unauflösbare Zeitzone bereits gemeldet wurde — der Takt + /// läuft jede Minute, die Meldung soll nicht mitlaufen. + private readonly HashSet _warnedTimeZones = []; + + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + + public TaskScanner( + ITaskRepository repo, + ITaskDispatcher dispatcher, + ILoggerFactory loggerFactory, + IMarketCalendar? market = null, + TimeProvider? clock = null, + TimeSpan? tick = null, + TimeSpan? lease = null) + { + _repo = repo; + _dispatcher = dispatcher; + _market = market ?? new AlwaysOpenMarketCalendar(); + _clock = clock ?? TimeProvider.System; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner"); + _tick = tick ?? TimeSpan.FromSeconds(60); + _lease = lease ?? TimeSpan.FromMinutes(15); + } + + public void Start() + { + _loop ??= RunLoopAsync(_cts.Token); + } + + /// Läuft die Taktschleife gerade? + public bool IsRunning => _loop is { IsCompleted: false }; + + /// + /// Die Schleife lief und ist beendet — anders als „noch nicht gestartet". Der + /// Unterschied zählt für die Zustandsmeldung an den Watchdog: Ein Scanner, der noch + /// auf die Startabgleichung wartet, ist in Ordnung; einer, dessen Schleife + /// ausgestiegen ist, bedeutet, dass keine Aufgabe mehr läuft. + /// + public bool HasStopped => _loop is { IsCompleted: true }; + + private async Task RunLoopAsync(CancellationToken ct) + { + // PeriodicTimer über den TimeProvider — im Test steuerbar, im Betrieb driftfrei. + using var timer = new PeriodicTimer(_tick, _clock); + while (await timer.WaitForNextTickAsync(ct)) + { + try + { + await ScanOnceAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Ein Scanner-Takt ist gescheitert"); + } + } + } + + /// + /// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der + /// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt + /// deterministisch auslösen können. + /// + public async Task ScanOnceAsync(CancellationToken ct) + { + var now = _clock.GetUtcNow().UtcDateTime; + var leaseCutoff = now - _lease; + + var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct); + + var claimed = new List<(TaskItem Task, string Token)>(); + foreach (var task in candidates) + { + if (task.Status != TaskItemStatus.Todo) + continue; // backlog (Halte-Status), blockiert, laufend, erledigt bleiben außen vor + + // Ein Mensch ist kein Modell-Lauf — solche Aufgaben rührt der Scanner nicht an. + if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human) + continue; + + // Eine Aufgabe mit unauflösbarer Zeitzone feuert nie. Das einmal melden, + // sonst sucht man den Fehler bei der Aufgabe statt beim System. + if (TaskSchedule.UnresolvableTimeZone(task) is { } badZone + && _warnedTimeZones.Add(task.Id)) + { + _logger.LogWarning( + "Aufgabe {TaskId} ({Title}) hat die Zeitzone '{TimeZone}', die auf diesem " + + "System nicht auflösbar ist — sie wird nicht ausgeführt. Zeitzone in " + + "IANA-Schreibweise eintragen (z.B. 'Europe/Berlin') und sicherstellen, " + + "dass tzdata und ICU vorhanden sind.", + task.Id, task.Title, badZone); + } + + var occ = TaskSchedule.DueOccurrence(task, now); + if (occ is null) + continue; + + if (task.OnlyWhenMarketOpen && !_market.IsOpen(now)) + continue; + + var token = Guid.NewGuid().ToString("N"); + if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct)) + claimed.Add((task, token)); + } + + // Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das + // Agent-Gate der Engine; verschiedene Agenten laufen echt parallel. + await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct))); + + return claimed.Count; + } + + /// + /// Führt einen Task sofort aus (manueller „Jetzt ausführen"-Knopf), unabhängig vom + /// Termin. Beansprucht ihn atomar wie ein Takt — läuft er schon oder ist er kein + /// todo, gibt die Methode false zurück, statt ihn doppelt anzustoßen. + /// + public async Task RunTaskNowAsync(string taskId, CancellationToken ct) + { + var task = await _repo.GetAsync(taskId, ct); + if (task is null) + return false; + + var now = _clock.GetUtcNow().UtcDateTime; + var token = Guid.NewGuid().ToString("N"); + if (!await _repo.TryClaimAsync(task.Id, now.ToString("O"), token, now, now - _lease, ct)) + return false; + + await ProcessAsync(task, token, ct); + return true; + } + + private async Task ProcessAsync(TaskItem task, string token, CancellationToken ct) + { + bool ok; + try + { + ok = await _dispatcher.DispatchAsync(task, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id); + ok = false; + } + + var now = _clock.GetUtcNow().UtcDateTime; + + // Endstatus: + // - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin + // wird nicht wiederholt). + // - Wiederkehrend (Cron/every/tool_job) → zurück auf todo, damit der nächste Termin + // feuern kann. Sonst liefe ein Cron-Task nur ein einziges Mal. + // - Einmalig → done bzw. in_review (A2). + var final = !ok || task.IsRecurring + ? TaskItemStatus.Todo + : task.RequireApproval + ? TaskItemStatus.InReview + : TaskItemStatus.Done; + + await _repo.CompleteClaimAsync(task.Id, token, final, now, ct); + + if (ok && final == TaskItemStatus.Done) + await UnblockDependentsAsync(task.Id, now, ct); + } + + /// + /// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben + /// freigegeben, sobald alle ihre Blocker erledigt sind. + /// + private async Task UnblockDependentsAsync(string completedId, DateTime now, CancellationToken ct) + { + var dependents = await _repo.ListBlockedByAsync(completedId, ct); + foreach (var dependent in dependents) + { + if (dependent.Status != TaskItemStatus.Blocked) + continue; + + var allDone = true; + foreach (var blockerId in dependent.BlockedBy) + { + var blocker = await _repo.GetAsync(blockerId, ct); + if (blocker is null || blocker.Status != TaskItemStatus.Done) + { + allDone = false; + break; + } + } + + if (allDone) + { + await _repo.SetStatusAsync(dependent.Id, TaskItemStatus.Todo, now, ct); + _logger.LogInformation( + "Aufgabe {TaskId} freigegeben — alle Blocker erledigt", dependent.Id); + } + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { } + } + _cts.Dispose(); + } +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs new file mode 100644 index 0000000..8a3958f --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskSchedule.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using ClawdDotNet.Core.Scheduling; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen +/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit. +/// +/// Das ist die Antwort auf B6 und B7: Statt eines langen Task.Delay bis zum +/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es +/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner +/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen. +/// +/// Höchstens ein Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils +/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart +/// eine Welle aus. +/// +public static class TaskSchedule +{ + /// + /// Der Occurrence-Key des fälligen Termins, oder null, wenn nichts ansteht. + /// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker + /// () liegt. + /// + public static string? DueOccurrence(TaskItem task, DateTime nowUtc) + { + nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc); + + if (task.When is null) + { + // Einmalig, ohne Termin: fällig, solange noch nie gelaufen. + if (task.LastOccurrence is not null) return null; + var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime(); + return Iso(basis); + } + + return task.When.Kind switch + { + TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc), + TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc), + TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc), + _ => null + }; + } + + /// + /// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst + /// null. + /// + /// Ein solcher Termin feuert nie (siehe ResolveTimeZone). Damit das nicht + /// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe. + /// Der häufigste Fall: eine Task-Datei mit Windows-Kennung + /// (W. Europe Standard Time) auf einem System ohne ICU-Daten. + /// + public static string? UnresolvableTimeZone(TaskItem task) + => task.When is { } when + && !string.IsNullOrWhiteSpace(when.TimeZone) + && !TimeZones.IsKnown(when.TimeZone) + ? when.TimeZone + : null; + + private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc) + { + if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal + if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null; + if (nowUtc < targetUtc) return null; + return Iso(targetUtc); + } + + private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc) + { + if (!TryParseInterval(task.When!.Value, out var interval)) return null; + + var anchor = marker is not null + ? ParseIso(marker) + : (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime()); + + return nowUtc < anchor + interval ? null : Iso(nowUtc); + } + + private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc) + { + var when = task.When!; + CronExpression cron; + try { cron = CronExpression.Parse(when.Value); } + catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen + + var tz = ResolveTimeZone(when.TimeZone); + if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit + + var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz)); + + // Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst + // ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz + // nach. Ohne Anlagezeit zählt nur die aktuelle Minute. + var lowerUtc = marker is not null ? ParseIso(marker) + : task.CreatedAt != default ? task.CreatedAt.ToUniversalTime() + : nowUtc; + var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz)); + + var limit = lowerLocal; + DateTime? matchLocal = null; + for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1)) + { + if (cron.Matches(candidate)) { matchLocal = candidate; break; } + } + + if (matchLocal is null) return null; + + // Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen. + var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified); + if (tz.IsInvalidTime(unspecified)) return null; + + var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz); + var occ = Iso(occUtc); + + // Nur fällig, wenn der Termin echt über dem Marker liegt. + return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ; + } + + // ─── Hilfsfunktionen ─── + + /// Deutet einen at-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der + /// angegebenen Zeitzone. + private static bool TryResolveInstant(string value, string timeZone, out DateTime utc) + { + utc = default; + var v = value.Trim(); + if (v.Length == 0) return false; + + if (v.EndsWith('Z') || v.EndsWith('z')) + { + if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, out var dto)) + { + utc = dto.UtcDateTime; + return true; + } + return false; + } + + if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) + { + var tz = ResolveTimeZone(timeZone); + if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar + + var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); + if (tz.IsInvalidTime(unspecified)) return false; + utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz); + return true; + } + + return false; + } + + /// Ein Intervall wie 30s, 15m, 2h, 1d. + private static bool TryParseInterval(string value, out TimeSpan interval) + { + interval = default; + var v = value.Trim().ToLowerInvariant(); + if (v.Length < 2) return false; + + var unit = v[^1]; + if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0) + return false; + + interval = unit switch + { + 's' => TimeSpan.FromSeconds(n), + 'm' => TimeSpan.FromMinutes(n), + 'h' => TimeSpan.FromHours(n), + 'd' => TimeSpan.FromDays(n), + _ => TimeSpan.Zero + }; + return interval > TimeSpan.Zero; + } + + /// + /// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC. + /// + /// Eine unbekannte Zone gibt null zurück — und der Aufrufer behandelt + /// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück; + /// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo + /// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf. + /// + /// Sichtbar wird er beim Import — weist eine Aufgabe + /// mit unbekannter Zone mit Meldung ab. + /// + private static TimeZoneInfo? ResolveTimeZone(string id) + { + if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc; + return TimeZones.TryResolve(id); + } + + private static DateTime Truncate(DateTime value) + => new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind); + + private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O"); + + private static DateTime ParseIso(string value) + => DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) + ? dt.ToUniversalTime() + : DateTime.MinValue; +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskText.cs b/src/ClawdDotNet.Core/Tasks/TaskText.cs new file mode 100644 index 0000000..b870e54 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskText.cs @@ -0,0 +1,70 @@ +namespace ClawdDotNet.Core.Tasks; + +/// +/// Übersetzt die Aufzählungen in ihre menschenlesbare Form und zurück. Dieselbe +/// Schreibweise wird im Frontmatter wie in der DB verwendet, damit beide Seiten sich +/// nicht auseinanderentwickeln. Unbekannte Eingaben fallen auf einen sicheren Standard +/// zurück, statt zu werfen — eine von Hand editierte Datei soll das Board nicht kippen. +/// +public static class TaskText +{ + public static string Of(TaskItemStatus status) => status switch + { + TaskItemStatus.Backlog => "backlog", + TaskItemStatus.Todo => "todo", + TaskItemStatus.InProgress => "in_progress", + TaskItemStatus.InReview => "in_review", + TaskItemStatus.Done => "done", + TaskItemStatus.Canceled => "canceled", + TaskItemStatus.Blocked => "blocked", + TaskItemStatus.Archived => "archived", + _ => "todo" + }; + + public static TaskItemStatus Status(string? value) => value?.Trim().ToLowerInvariant() switch + { + "backlog" => TaskItemStatus.Backlog, + "todo" => TaskItemStatus.Todo, + "in_progress" => TaskItemStatus.InProgress, + "in_review" => TaskItemStatus.InReview, + "done" => TaskItemStatus.Done, + "canceled" or "cancelled" => TaskItemStatus.Canceled, + "blocked" => TaskItemStatus.Blocked, + "archived" => TaskItemStatus.Archived, + _ => TaskItemStatus.Todo + }; + + public static string Of(TaskItemType type) => type switch + { + TaskItemType.Work => "work", + TaskItemType.Approval => "approval", + TaskItemType.HumanInput => "human_input", + TaskItemType.ToolJob => "tool_job", + _ => "work" + }; + + public static TaskItemType Type(string? value) => value?.Trim().ToLowerInvariant() switch + { + "approval" => TaskItemType.Approval, + "human_input" => TaskItemType.HumanInput, + "tool_job" => TaskItemType.ToolJob, + _ => TaskItemType.Work + }; + + public static string Of(TaskWhenKind kind) => kind switch + { + TaskWhenKind.At => "at", + TaskWhenKind.Every => "every", + TaskWhenKind.Cron => "cron", + _ => "cron" + }; + + /// Gibt null zurück, wenn die Angabe keine bekannte Terminart ist. + public static TaskWhenKind? WhenKind(string? value) => value?.Trim().ToLowerInvariant() switch + { + "at" => TaskWhenKind.At, + "every" => TaskWhenKind.Every, + "cron" => TaskWhenKind.Cron, + _ => null + }; +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs new file mode 100644 index 0000000..b0612f3 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs @@ -0,0 +1,186 @@ +using ClawdDotNet.Core.Storage; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter SharedWorkspace/tasks/ +/// halten die Definition, die DB () den Ausführungszustand. +/// Dieser Dienst hält beide im Gleichschritt und wird von drei Seiten genutzt — dem +/// Agenten-Tool, dem Scanner und dem Start (Reconciliation). +/// +/// Bewusst zustandslos über den Aufrufen: nur Repository und Verzeichnis, keine +/// gepufferten Aufgaben. So kann jede Seite ihn nach Bedarf erzeugen. +/// +public sealed class TaskboardService +{ + private readonly ITaskRepository _repo; + private readonly string _tasksDir; + + public TaskboardService(ITaskRepository repo, string tasksDirectory) + { + _repo = repo; + _tasksDir = tasksDirectory; + } + + public string TasksDirectory => _tasksDir; + + // ─── Lesen (direkt aus der DB) ─── + + public Task GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct); + + public Task> ListAsync(TaskQuery query, CancellationToken ct) + => _repo.ListAsync(query, ct); + + // ─── Anlegen ─── + + /// + /// Legt eine Aufgabe an: vergibt (falls nötig) eine stabile Id, schreibt die Datei und + /// spiegelt sie in die DB. Datei und DB entstehen in einem Zug. + /// + public async Task CreateAsync(TaskItem definition, CancellationToken ct) + { + var id = string.IsNullOrWhiteSpace(definition.Id) ? await NewIdAsync(ct) : definition.Id.Trim(); + var fileName = string.IsNullOrWhiteSpace(definition.FileName) + ? BuildFileName(definition.Title, id) + : definition.FileName; + + var toWrite = definition with { Id = id, FileName = fileName }; + WriteFile(toWrite); + return await _repo.UpsertAsync(toWrite, ct); + } + + // ─── Ändern ─── + + /// + /// Ändert die Definition einer Aufgabe (Tool-Aktion). Schreibt Datei und DB. Eine + /// Status-Änderung wird zusätzlich explizit gesetzt — der Import spiegelt nur die + /// Definition und lässt den Ausführungszustand absichtlich unangetastet, damit ein + /// blinder Re-Import einen laufenden Zustand nicht zurücksetzt. + /// + public async Task UpdateAsync( + string id, Func mutate, CancellationToken ct) + { + var current = await _repo.GetAsync(id, ct); + if (current is null) return null; + + var updated = mutate(current) with { Id = id, FileName = current.FileName }; + WriteFile(updated); + await _repo.UpsertAsync(updated, ct); + + if (updated.Status != current.Status) + await _repo.SetStatusAsync(id, updated.Status, DateTime.UtcNow, ct); + + return await _repo.GetAsync(id, ct); + } + + /// Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an. + public Task AddCommentAsync(string id, string author, string text, CancellationToken ct) + => UpdateAsync(id, current => + { + var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm"); + var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}"; + var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}"; + return current with { Body = body }; + }, ct); + + // ─── Import (Datei → DB) ─── + + /// + /// Liest alle Task-Dateien und spiegelt sie in die DB. DB-Zeilen, deren Datei + /// verschwunden ist, werden archiviert (nicht gelöscht — die Historie bleibt). Gibt + /// die Zahl der importierten Dateien zurück. + /// + public async Task ImportAllAsync(CancellationToken ct) + { + if (!Directory.Exists(_tasksDir)) + return 0; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var imported = 0; + + foreach (var path in Directory.EnumerateFiles(_tasksDir, "*.md")) + { + var task = await ImportFileAsync(path, ct); + if (task is not null) { seen.Add(task.Id); imported++; } + } + + // Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch; + // wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her. + var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct); + foreach (var task in known) + if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id)) + await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct); + + return imported; + } + + /// Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne + /// id bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben. + public async Task ImportFileAsync(string path, CancellationToken ct) + { + string text; + try { text = AtomicFile.ReadAllText(path); } + catch { return null; } + + if (!TaskFrontmatter.TryParse(text, out var definition, out _)) + return null; + + var fileName = Path.GetFileName(path); + + if (string.IsNullOrWhiteSpace(definition.Id)) + { + definition = definition with { Id = await NewIdAsync(ct), FileName = fileName }; + WriteFile(definition); // Id festschreiben, damit sie den nächsten Start überlebt + } + else + { + definition = definition with { FileName = fileName }; + } + + return await _repo.UpsertAsync(definition, ct); + } + + // ─── Hilfsfunktionen ─── + + private void WriteFile(TaskItem definition) + { + Directory.CreateDirectory(_tasksDir); + var path = Path.Combine(_tasksDir, definition.FileName); + AtomicFile.WriteAllText(path, TaskFrontmatter.Serialize(definition)); + } + + private async Task NewIdAsync(CancellationToken ct) + { + for (var i = 0; i < 5; i++) + { + var id = "t-" + Guid.NewGuid().ToString("N")[..6]; + if (await _repo.GetAsync(id, ct) is null) + return id; + } + return "t-" + Guid.NewGuid().ToString("N")[..12]; + } + + private static string BuildFileName(string title, string id) + { + var slug = Slug(title); + var suffix = id.StartsWith("t-", StringComparison.Ordinal) ? id[2..] : id; + return (slug.Length == 0 ? "task" : slug) + "-" + suffix + ".md"; + } + + private static string Slug(string title) + { + var chars = title.Trim().ToLowerInvariant() + .Select(c => char.IsLetterOrDigit(c) ? c : '-') + .ToArray(); + var slug = new string(chars); + + while (slug.Contains("--")) + slug = slug.Replace("--", "-"); + slug = slug.Trim('-'); + + if (slug.Length > 40) + slug = slug[..40].Trim('-'); + + return slug; + } +} diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index bf33aff..16445f7 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -1,5 +1,6 @@ using ClawdDotNet.Core.Memory; using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Tools; @@ -14,5 +15,6 @@ public sealed record AgentToolContext( string? WorkspacePath = null, string? SharedWorkspacePath = null, IAgentMessageRouter? MessageRouter = null, - IMemoryRepository? Memory = null + IMemoryRepository? Memory = null, + ITaskRepository? Tasks = null ); diff --git a/src/ClawdDotNet.Desktop/App.axaml b/src/ClawdDotNet.Desktop/App.axaml new file mode 100644 index 0000000..8a284dc --- /dev/null +++ b/src/ClawdDotNet.Desktop/App.axaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs new file mode 100644 index 0000000..9a9675c --- /dev/null +++ b/src/ClawdDotNet.Desktop/App.axaml.cs @@ -0,0 +1,213 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.Desktop.Services; +using ClawdDotNet.Desktop.ViewModels; +using ClawdDotNet.Desktop.Views; + +namespace ClawdDotNet.Desktop; + +public partial class App : Application +{ + private AppHost? _host; + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster + // ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung + // in genau dieser Lücke aussteigen. + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + desktop.ShutdownRequested += async (_, _) => + { + if (_host is not null) await _host.DisposeAsync(); + }; + + // Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren, + // damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf + // dem die Fenster des Startvorgangs überhaupt erscheinen könnten. + _ = StartAsync(desktop); + } + + base.OnFrameworkInitializationCompleted(); + } + + private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) + { + var result = await AppHost.StartAsync(new AppHost.Callbacks + { + SelectInstance = SelectInstanceAsync, + License = new AvaloniaLicensePrompt(), + TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt), + Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:") + }); + + if (result.Error is { } error) + { + await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error); + desktop.Shutdown(1); + return; + } + + if (result.Host is null) + { + // Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits + // erklärt worden, hier kommt keine weitere Meldung hinterher. + desktop.Shutdown(); + return; + } + + _host = result.Host; + + HookErrorReporting(_host); + HookLicenseWatch(_host, desktop); + + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(_host) + }; + + desktop.MainWindow.Show(); + desktop.MainWindow.Closed += (_, _) => desktop.Shutdown(); + + await ShowUpdateNoticeAsync(_host); + } + + /// + /// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. + /// + /// Erst hier verdrahtet, nicht in Main: Vor dem Aufbau gibt es weder + /// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf. + /// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz während des + /// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll. + /// + private static void HookErrorReporting(AppHost host) + { + AppDomain.CurrentDomain.UnhandledException += (_, args) => + { + if (args.ExceptionObject is Exception ex) + { + // Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen. + host.Errors.ReportAsync(ex, fatal: args.IsTerminating) + .Wait(TimeSpan.FromSeconds(3)); + } + }; + + TaskScheduler.UnobservedTaskException += (_, args) => + { + _ = host.Errors.ReportAsync(args.Exception, fatal: false); + + // Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen + // den Prozess mit — und das wäre eine Nebenwirkung des Meldens. + args.SetObserved(); + }; + + Dispatcher.UIThread.UnhandledException += (_, args) => + { + _ = host.Errors.ReportAsync(args.Exception, fatal: false); + + // Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden. + args.Handled = true; + }; + } + + /// Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage. + private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop) + { + if (host.LicenseWatch is null) return; + + host.LicenseWatch.Revoked += async message => + { + await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2)); + }; + } + + /// + /// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert + /// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst + /// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht. + /// + private static async Task ShowUpdateNoticeAsync(AppHost host) + { + if (host.Deploymentcenter is null) return; + + // Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben. + for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++) + await Task.Delay(TimeSpan.FromSeconds(1)); + + if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return; + + await new AvaloniaLicensePrompt().ShowInfoAsync( + update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update", + $"Version {update.LatestVersion} ist verfügbar." + + (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}")); + } + + private static async Task SelectInstanceAsync(InstanceDirectoryManager directories) + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var viewModel = new InstancePickerViewModel(directories); + var window = new InstancePickerWindow { DataContext = viewModel }; + + // Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete + // der Start für immer auf eine Auswahl, die nie kommt. + window.Closed += (_, _) => viewModel.CancelCommand.Execute(null); + + window.Show(); + + var path = await viewModel.Result; + window.Close(); + + return path; + }); + + /// + /// Einzeilige Abfrage — ersetzt Microsoft.VisualBasic.Interaction.InputBox, + /// das die Telegram-Anmeldung an Windows band. + /// + private static async Task AskAsync(string title, string prompt) + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var completion = new TaskCompletionSource(); + + var input = new TextBox(); + var ok = new Button { Content = "OK", IsDefault = true }; + + var window = new Window + { + Title = title, + Width = 420, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 12, + Children = + { + new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + input, + ok + } + } + }; + + ok.Click += (_, _) => window.Close(); + window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? ""); + + window.Show(); + input.Focus(); + + return await completion.Task; + }); +} diff --git a/src/ClawdDotNet.Desktop/Assets/app.ico b/src/ClawdDotNet.Desktop/Assets/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..0dd160569504cb3323c47ce5fa8cc7777a0d7434 GIT binary patch literal 177470 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≦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≶{s2a`4(S4{n1q%3u;eXebH{HN^A zlk=r~7nyc&huvPt;oeP44+(GAVYe6CGbSA$!lT!5O!(q+Hu6AV%U_H=s~9zG#| zVNT!g_OPd$;r2V<*S>v^$6ePZI6s=-ewTaOn&s2sGgM@)+L)EKWJy+5CB0W%88cq- XtHC5r!o!Az++A5&lNJQaOWpqm*AGRk literal 0 HcmV?d00001 diff --git a/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj new file mode 100644 index 0000000..28cb1d2 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj @@ -0,0 +1,38 @@ + + + + WinExe + net10.0 + enable + enable + ClawdDotNet.Desktop + ClawdDotNet + Assets\app.ico + + + true + + + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs b/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs new file mode 100644 index 0000000..2e93ae4 --- /dev/null +++ b/src/ClawdDotNet.Desktop/Converters/ChatBubbleBrushConverter.cs @@ -0,0 +1,65 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Layout; +using Avalonia.Media; + +namespace ClawdDotNet.Desktop.Converters; + +/// +/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten. +/// +public sealed class ChatBubbleBrushConverter : IValueConverter +{ + public static readonly ChatBubbleBrushConverter Instance = new(); + + private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4)); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isUser && isUser) + return UserBubble; + + return null; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException("Nur zur Anzeige."); +} + +/// +/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten. +/// +public sealed class ChatAlignmentConverter : IValueConverter +{ + public static readonly ChatAlignmentConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool isUser && isUser) + return HorizontalAlignment.Right; + + return HorizontalAlignment.Left; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException("Nur zur Anzeige."); +} + +/// +/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt. +/// +public sealed class ToolMatchConverter : IValueConverter +{ + public static readonly ToolMatchConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string toolName && parameter is string targetTool) + return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase); + + return false; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException("Nur zur Anzeige."); +} diff --git a/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs b/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs new file mode 100644 index 0000000..ec59592 --- /dev/null +++ b/src/ClawdDotNet.Desktop/Converters/LogLevelBrushConverter.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ClawdDotNet.Desktop.Converters; + +/// +/// Färbt eine Logzeile nach ihrer Stufe. +/// +/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht +/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die +/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel. +/// +public sealed class LogLevelBrushConverter : IValueConverter +{ + public static readonly LogLevelBrushConverter Instance = new(); + + private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B)); + private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A)); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value as string switch + { + "ERR" or "FTL" => Error, + "WRN" => Warn, + // null heißt: Bindung greift nicht, das Steuerelement behält seine eigene + // Farbe aus dem Thema. + _ => null + }; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException("Nur zur Anzeige."); +} diff --git a/src/ClawdDotNet.Desktop/Program.cs b/src/ClawdDotNet.Desktop/Program.cs new file mode 100644 index 0000000..4e55ef5 --- /dev/null +++ b/src/ClawdDotNet.Desktop/Program.cs @@ -0,0 +1,33 @@ +using Avalonia; + +namespace ClawdDotNet.Desktop; + +internal static class Program +{ + /// + /// Einstiegspunkt der Oberfläche. + /// + /// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche — + /// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut + /// über die Anwendungsschicht auf, damit derselbe Aufbau später + /// auch ohne Fenster laufen kann. + /// + /// Kein [STAThread] mehr: Das war eine COM-Anforderung von WinForms. Avalonia + /// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung. + /// + public static int Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + /// + /// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und + /// getrennt von . + /// + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + // Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen: + // Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die + // Oberfläche leer. + .WithInterFont() + .LogToTrace(); +} diff --git a/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs new file mode 100644 index 0000000..b483e7c --- /dev/null +++ b/src/ClawdDotNet.Desktop/Services/AvaloniaLicensePrompt.cs @@ -0,0 +1,85 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using ClawdDotNet.App.Services; +using ClawdDotNet.Desktop.ViewModels; +using ClawdDotNet.Desktop.Views; + +namespace ClawdDotNet.Desktop.Services; + +/// +/// Lizenzabfrage über Fenster. +/// +/// Der Aufbau läuft auf einem Hintergrundfaden — Avalonia besteht aber darauf, dass +/// Fenster auf dem Oberflächenfaden entstehen. Deshalb geht hier alles über +/// . Das ist der Grund, warum +/// durchgehend asynchron ist: Die WinForms-Fassung konnte +/// ShowDialog einfach blockierend aufrufen, weil sie ohnehin auf dem +/// Oberflächenfaden lief. +/// +public sealed class AvaloniaLicensePrompt : ILicensePrompt +{ + public async Task RequestKeyAsync(string hardwareId, string? problem, string? currentKey) + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var viewModel = new LicenseViewModel(hardwareId, problem, currentKey); + var window = new LicenseWindow { DataContext = viewModel }; + + window.Show(); + + var key = await viewModel.Result; + window.Close(); + + return key; + }); + + public Task ShowInfoAsync(string title, string message) + => ShowMessageAsync(title, message); + + public Task ShowErrorAsync(string title, string message) + => ShowMessageAsync(title, message); + + /// + /// Ein Meldungsfenster. + /// + /// Avalonia bringt kein MessageBox mit — bewusst, weil es auf allen + /// Plattformen anders aussähe. Ein schlichtes Fenster ist hier ausreichend und + /// erspart uns eine weitere Abhängigkeit für drei Aufrufstellen. + /// + private static async Task ShowMessageAsync(string title, string message) + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var completion = new TaskCompletionSource(); + + var button = new Button + { + Content = "OK", + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + IsDefault = true + }; + + var window = new Window + { + Title = title, + Width = 460, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + button + } + } + }; + + button.Click += (_, _) => window.Close(); + window.Closed += (_, _) => completion.TrySetResult(); + + window.Show(); + await completion.Task; + }); +} diff --git a/src/ClawdDotNet.Desktop/Styles/Shell.axaml b/src/ClawdDotNet.Desktop/Styles/Shell.axaml new file mode 100644 index 0000000..490b4b9 --- /dev/null +++ b/src/ClawdDotNet.Desktop/Styles/Shell.axaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ClawdDotNet.Desktop/ViewLocator.cs b/src/ClawdDotNet.Desktop/ViewLocator.cs new file mode 100644 index 0000000..9693908 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewLocator.cs @@ -0,0 +1,40 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using ClawdDotNet.Desktop.ViewModels; + +namespace ClawdDotNet.Desktop; + +/// +/// Findet zu einem Ansichtsmodell die passende Ansicht über die Namenskonvention +/// …ViewModels.FooViewModel…Views.FooView. +/// +/// Das erspart es, jede Zuordnung von Hand als DataTemplate einzutragen — bei +/// den sieben Bereichen und ihren Unteransichten wären das schnell dreißig Einträge, +/// die man beim Umbenennen übersieht. +/// +/// Passt nichts, erscheint der gesuchte Typname im Fenster statt einer leeren Fläche. +/// Beim schrittweisen Umbau ist eine sichtbare Fehlstelle mehr wert als ein stiller +/// weißer Bereich. +/// +public sealed class ViewLocator : IDataTemplate +{ + public Control Build(object? data) + { + if (data is null) + return new TextBlock { Text = "(kein Inhalt)" }; + + var viewModelName = data.GetType().FullName!; + var viewName = viewModelName + .Replace(".ViewModels.", ".Views.", StringComparison.Ordinal) + .Replace("ViewModel", "View", StringComparison.Ordinal); + + var type = Type.GetType(viewName); + + if (type is null) + return new TextBlock { Text = $"Ansicht nicht gefunden: {viewName}" }; + + return (Control)Activator.CreateInstance(type)!; + } + + public bool Match(object? data) => data is ViewModelBase; +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs new file mode 100644 index 0000000..5b016ed --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/AddAgentViewModel.cs @@ -0,0 +1,76 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record AddAgentResultData( + string AgentId, + string DisplayName, + string Model, + string Description +); + +public sealed partial class AddAgentViewModel : ViewModelBase +{ + [ObservableProperty] + private string _agentId = ""; + + [ObservableProperty] + private string _displayName = ""; + + [ObservableProperty] + private string _selectedModel = "anthropic/claude-3-5-sonnet"; + + [ObservableProperty] + private string _description = ""; + + [ObservableProperty] + private string _statusMessage = ""; + + public List AvailableModels { get; } = + [ + "anthropic/claude-3-5-sonnet", + "anthropic/claude-3-opus", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "deepseek/deepseek-r1" + ]; + + public AddAgentResultData? Result { get; private set; } + + public event Action? CloseRequested; + + [RelayCommand] + private void Confirm() + { + if (string.IsNullOrWhiteSpace(AgentId)) + { + StatusMessage = "Bitte eine Agenten-ID eingeben."; + return; + } + + if (string.IsNullOrWhiteSpace(DisplayName)) + { + StatusMessage = "Bitte einen Anzeigenamen eingeben."; + return; + } + + Result = new AddAgentResultData( + AgentId.Trim(), + DisplayName.Trim(), + SelectedModel, + Description.Trim() + ); + + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Cancel() + { + Result = null; + CloseRequested?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs new file mode 100644 index 0000000..516f0fa --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/AddJobViewModel.cs @@ -0,0 +1,111 @@ +using ClawdDotNet.Core.Config; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record AddJobResultData( + string AgentId, + bool IsToolJob, + string ToolName, + string JobTypeId, + string CronExpression, + string TaskMessage, + bool RunOnStart +); + +public sealed partial class AddJobViewModel : ViewModelBase +{ + public List AvailableAgents { get; } + + [ObservableProperty] + private AgentConfig? _selectedAgent; + + [ObservableProperty] + private bool _isToolJob; + + [ObservableProperty] + private bool _isAgentWakeup = true; + + [ObservableProperty] + private string _selectedToolName = "FileRW"; + + [ObservableProperty] + private string _jobTypeId = "PollJob"; + + [ObservableProperty] + private string _cronExpression = "0 * * * *"; + + [ObservableProperty] + private string _taskMessage = "Führe deine zugewiesenen Aufgaben aus."; + + [ObservableProperty] + private bool _runOnStart; + + [ObservableProperty] + private string _statusMessage = ""; + + public List AvailableTools { get; } = + [ + "FileRW", "Telegram", "Mail", "Database", "FTP", + "DirectAPI", "WebFetch", "WebMonitor", "AgentComm", + "SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard" + ]; + + public AddJobResultData? Result { get; private set; } + + public event Action? CloseRequested; + + public AddJobViewModel() : this([]) { } + + public AddJobViewModel(List agents) + { + AvailableAgents = agents; + SelectedAgent = AvailableAgents.FirstOrDefault(); + } + + partial void OnIsToolJobChanged(bool value) + { + IsAgentWakeup = !value; + } + + partial void OnIsAgentWakeupChanged(bool value) + { + IsToolJob = !value; + } + + [RelayCommand] + private void Confirm() + { + if (SelectedAgent is null) + { + StatusMessage = "Bitte einen Agenten auswählen."; + return; + } + + if (string.IsNullOrWhiteSpace(CronExpression)) + { + StatusMessage = "Bitte einen Cron-Ausdruck angeben."; + return; + } + + Result = new AddJobResultData( + SelectedAgent.AgentId, + IsToolJob, + SelectedToolName, + JobTypeId, + CronExpression.Trim(), + TaskMessage.Trim(), + RunOnStart + ); + + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Cancel() + { + Result = null; + CloseRequested?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs new file mode 100644 index 0000000..6ec6fd7 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/AddServiceViewModel.cs @@ -0,0 +1,67 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record AddServiceResultData( + string ServiceName, + string ServiceType, + int ServicePort, + string ServiceDescription +); + +public sealed partial class AddServiceViewModel : ViewModelBase +{ + [ObservableProperty] + private string _serviceName = ""; + + [ObservableProperty] + private string _serviceType = "HTTP"; + + [ObservableProperty] + private int _servicePort = 8080; + + [ObservableProperty] + private string _serviceDescription = ""; + + [ObservableProperty] + private string _statusMessage = ""; + + public List AvailableServiceTypes { get; } = ["HTTP", "TCP", "gRPC", "WebSocket", "Custom"]; + + public AddServiceResultData? Result { get; private set; } + + public event Action? CloseRequested; + + [RelayCommand] + private void Confirm() + { + if (string.IsNullOrWhiteSpace(ServiceName)) + { + StatusMessage = "Bitte einen Dienstnamen eingeben."; + return; + } + + if (ServicePort <= 0 || ServicePort > 65535) + { + StatusMessage = "Gültigen Port zwischen 1 und 65535 eingeben."; + return; + } + + Result = new AddServiceResultData( + ServiceName.Trim(), + ServiceType, + ServicePort, + ServiceDescription.Trim() + ); + + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Cancel() + { + Result = null; + CloseRequested?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs new file mode 100644 index 0000000..d79c7fd --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/AgentsPageViewModel.cs @@ -0,0 +1,280 @@ +using System.Collections.ObjectModel; +using ClawdDotNet.App; +using ClawdDotNet.Core.Config; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed partial class ToolItemViewModel : ObservableObject +{ + public string Name { get; } + + [ObservableProperty] + private bool _isEnabled; + + public ToolItemViewModel(string name, bool isEnabled) + { + Name = name; + _isEnabled = isEnabled; + } +} + +public sealed partial class AgentItemViewModel : ObservableObject +{ + public AgentConfig Config { get; } + + public string AgentId => Config.AgentId; + + [ObservableProperty] + private string _displayName; + + [ObservableProperty] + private string _model; + + [ObservableProperty] + private string _systemPrompt; + + [ObservableProperty] + private string _identity; + + [ObservableProperty] + private string _soul; + + [ObservableProperty] + private string _description; + + [ObservableProperty] + private string _promptCaching; + + [ObservableProperty] + private int _maxToolResultChars; + + [ObservableProperty] + private int _maxSteps; + + [ObservableProperty] + private int _maxContextTokens; + + [ObservableProperty] + private int _timeoutSeconds; + + [ObservableProperty] + private decimal _dailyCostUsd; + + [ObservableProperty] + private long _dailyTokens; + + public ObservableCollection AvailableTools { get; } = []; + + public static readonly List KnownTools = + [ + "FileRW", "Telegram", "Mail", "Database", "FTP", + "DirectAPI", "WebFetch", "WebMonitor", "AgentComm", + "SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard" + ]; + + public AgentItemViewModel(AgentConfig config) + { + Config = config; + _displayName = config.DisplayName; + _model = config.Model; + _systemPrompt = config.SystemPrompt; + _identity = config.Identity; + _soul = config.Soul; + _description = config.Description; + _promptCaching = config.PromptCaching; + _maxToolResultChars = config.MaxToolResultChars; + _maxSteps = config.LoopGuard.MaxSteps; + _maxContextTokens = config.LoopGuard.MaxContextTokens; + _timeoutSeconds = config.LoopGuard.TimeoutSeconds; + _dailyCostUsd = config.Budget.DailyCostUsd; + _dailyTokens = config.Budget.DailyTokens; + + foreach (var tool in KnownTools) + { + var isEnabled = config.Tools.ContainsKey(tool); + var item = new ToolItemViewModel(tool, isEnabled); + item.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled)) + { + if (item.IsEnabled && !Config.Tools.ContainsKey(tool)) + Config.Tools[tool] = new Dictionary(); + else if (!item.IsEnabled) + Config.Tools.Remove(tool); + } + }; + AvailableTools.Add(item); + } + } + + public void ApplyChanges() + { + Config.DisplayName = DisplayName; + Config.Model = Model; + Config.SystemPrompt = SystemPrompt; + Config.Identity = Identity; + Config.Soul = Soul; + Config.Description = Description; + Config.PromptCaching = PromptCaching; + Config.MaxToolResultChars = MaxToolResultChars; + Config.LoopGuard.MaxSteps = MaxSteps; + Config.LoopGuard.MaxContextTokens = MaxContextTokens; + Config.LoopGuard.TimeoutSeconds = TimeoutSeconds; + Config.Budget.DailyCostUsd = DailyCostUsd; + Config.Budget.DailyTokens = DailyTokens; + } +} + +/// +/// Ansichtsmodell für die Agenten-Verwaltungsseite. +/// +public sealed partial class AgentsPageViewModel : PageViewModel +{ + private readonly AppHost? _host; + private readonly ILogger? _logger; + + public ObservableCollection Agents { get; } = []; + + [ObservableProperty] + private AgentItemViewModel? _selectedAgent; + + [ObservableProperty] + private string _statusText = "Bereit"; + + public List KnownModels { get; } = + [ + "anthropic/claude-3-5-sonnet", + "anthropic/claude-3-opus", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "google/gemini-2.5-flash", + "google/gemini-2.5-pro", + "deepseek/deepseek-r1" + ]; + + public List CachingOptions { get; } = ["auto", "on", "off"]; + + public event Func>? RequestAddAgentDialog; + public event Func?>>? RequestToolSettingsDialog; + + public AgentsPageViewModel() : this(null) { } + + public AgentsPageViewModel(AppHost? host) : base("Agenten") + { + _host = host; + + if (host is not null) + { + _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents"); + ReloadAgents(); + } + else + { + StatusText = "Entwurfsmodus"; + } + } + + [RelayCommand] + private void ReloadAgents() + { + Agents.Clear(); + if (_host is null) return; + + foreach (var agent in _host.Instance.Agents) + { + Agents.Add(new AgentItemViewModel(agent)); + } + + SelectedAgent = Agents.FirstOrDefault(); + } + + [RelayCommand] + private async Task AddAgentAsync() + { + if (_host is null || RequestAddAgentDialog is null) return; + + var vm = new AddAgentViewModel(); + var result = await RequestAddAgentDialog(vm); + if (result is null) return; + + if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase))) + { + StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits."; + return; + } + + var newConfig = new AgentConfig + { + AgentId = result.AgentId, + DisplayName = result.DisplayName, + Model = result.Model, + Description = result.Description + }; + + _host.Instance.Agents.Add(newConfig); + SaveAgents(); + + var item = new AgentItemViewModel(newConfig); + Agents.Add(item); + SelectedAgent = item; + + StatusText = $"Agent '{result.DisplayName}' angelegt."; + } + + [RelayCommand] + private void RemoveAgent() + { + if (_host is null || SelectedAgent is null) return; + + var agentId = SelectedAgent.AgentId; + _host.Instance.Agents.RemoveAll(a => a.AgentId == agentId); + + SaveAgents(); + Agents.Remove(SelectedAgent); + SelectedAgent = Agents.FirstOrDefault(); + + StatusText = $"Agent '{agentId}' entfernt."; + } + + [RelayCommand] + private async Task ConfigureToolAsync(ToolItemViewModel? toolItem) + { + if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return; + + var toolName = toolItem.Name; + if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config)) + { + config = new Dictionary(); + SelectedAgent.Config.Tools[toolName] = config; + toolItem.IsEnabled = true; + } + + var vm = new ToolSettingsViewModel(toolName, config); + var newConfig = await RequestToolSettingsDialog(vm); + + if (newConfig is not null) + { + SelectedAgent.Config.Tools[toolName] = newConfig; + SaveAgents(); + StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert."; + } + } + + [RelayCommand] + private void SaveAgents() + { + if (_host is null) return; + + foreach (var agentVm in Agents) + { + agentVm.ApplyChanges(); + } + + _host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance); + _logger?.LogInformation("Agenten-Konfiguration gespeichert."); + StatusText = "Agenten-Konfiguration gespeichert."; + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs new file mode 100644 index 0000000..c7383a9 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs @@ -0,0 +1,397 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using ClawdDotNet.App; +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record BackupItemViewModel( + string FileName, + DateTime LastWriteTime, + string InstanceName, + long SizeBytes, + string SizeFormatted, + bool HasSecrets, + string SecretsInfo, + string FullPath, + bool IsReadable +); + +/// +/// Ansichtsmodell für die Sicherungs- und Wiederherstellungsseite. +/// +public sealed partial class BackupPageViewModel : PageViewModel +{ + private readonly BackupService _service = new(); + private readonly AppHost? _host; + private readonly SettingsManager? _settingsManager; + private readonly ILogger? _logger; + private readonly string _instanceDir; + private readonly string _instanceName; + private bool _loading; + + [ObservableProperty] + private string _targetFolder = ""; + + [ObservableProperty] + private bool _secretsExclude = true; + + [ObservableProperty] + private bool _secretsPassphrase; + + [ObservableProperty] + private string _passphrase = ""; + + [ObservableProperty] + private string _passphraseRepeat = ""; + + [ObservableProperty] + private bool _includeLogs; + + [ObservableProperty] + private bool _includeChatHistory = true; + + [ObservableProperty] + private bool _autoBackupEnabled; + + [ObservableProperty] + private string _autoBackupTime = "03:00"; + + [ObservableProperty] + private int _keepCount = 10; + + [ObservableProperty] + private string _statusText = "Bereit"; + + [ObservableProperty] + private bool _isBusy; + + [ObservableProperty] + private BackupItemViewModel? _selectedBackup; + + public ObservableCollection Backups { get; } = []; + + public event Func>? RequestRestoreDialog; + + public BackupPageViewModel() : this(null) { } + + public BackupPageViewModel(AppHost? host) : base("Sicherung") + { + _host = host; + + if (host is not null) + { + _instanceDir = host.InstancePath; + _instanceName = host.Instance.InstanceName; + _settingsManager = host.Settings; + _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup"); + + LoadSettings(); + RefreshBackupList(); + } + else + { + _instanceDir = ""; + _instanceName = "Entwurf"; + TargetFolder = @"C:\ClawdDotNet\Backups"; + } + } + + partial void OnAutoBackupEnabledChanged(bool value) => SaveSettings(); + partial void OnAutoBackupTimeChanged(string value) => SaveSettings(); + partial void OnKeepCountChanged(int value) => SaveSettings(); + + private void LoadSettings() + { + if (_settingsManager is null) return; + + _loading = true; + try + { + var settings = _settingsManager.AppSettings; + TargetFolder = Path.GetFullPath(settings.BackupDirectory); + AutoBackupEnabled = settings.AutoBackupEnabled; + KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999); + + AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time) + ? time.ToString(@"hh\:mm") + : "03:00"; + } + finally + { + _loading = false; + } + } + + public void SaveSettings() + { + if (_loading || _settingsManager is null) return; + + var settings = _settingsManager.AppSettings; + settings.BackupDirectory = TargetFolder.Trim(); + settings.AutoBackupEnabled = AutoBackupEnabled; + settings.AutoBackupTime = AutoBackupTime.Trim(); + settings.BackupKeepCount = KeepCount; + + _settingsManager.Save(); + } + + [RelayCommand] + private void RefreshBackupList() + { + Backups.Clear(); + var folder = TargetFolder.Trim(); + + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) + return; + + Task.Run(async () => + { + var files = new DirectoryInfo(folder) + .GetFiles("*.zip") + .OrderByDescending(f => f.LastWriteTime) + .ToList(); + + var list = new List(); + + foreach (var file in files) + { + try + { + var manifest = await _service.InspectAsync(file.FullName); + var secretsInfo = manifest.HasSecrets + ? $"ja ({manifest.SecretCount}, Passphrase)" + : "nein"; + + list.Add(new BackupItemViewModel( + file.Name, file.LastWriteTime, manifest.InstanceName, + file.Length, FormatSize(file.Length), manifest.HasSecrets, secretsInfo, + file.FullName, true)); + } + catch + { + list.Add(new BackupItemViewModel( + file.Name, file.LastWriteTime, "—", + file.Length, FormatSize(file.Length), false, "unlesbar", + file.FullName, false)); + } + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + Backups.Clear(); + foreach (var item in list) + Backups.Add(item); + }); + }); + } + + [RelayCommand] + private async Task CreateBackupAsync() + { + if (string.IsNullOrWhiteSpace(_instanceDir)) + { + StatusText = "Keine gültige Instanz geladen."; + return; + } + + var folder = TargetFolder.Trim(); + if (string.IsNullOrWhiteSpace(folder)) + { + StatusText = "Bitte einen Zielordner angeben."; + return; + } + + BackupOptions options; + if (SecretsPassphrase) + { + if (string.IsNullOrEmpty(Passphrase)) + { + StatusText = "Bitte eine Passphrase eingeben."; + return; + } + + if (Passphrase != PassphraseRepeat) + { + StatusText = "Die Passphrasen stimmen nicht überein."; + return; + } + + options = new BackupOptions + { + Secrets = SecretMode.Passphrase, + Passphrase = Passphrase, + IncludeLogs = IncludeLogs, + IncludeChatHistory = IncludeChatHistory + }; + } + else + { + options = new BackupOptions + { + Secrets = SecretMode.Exclude, + IncludeLogs = IncludeLogs, + IncludeChatHistory = IncludeChatHistory + }; + } + + Directory.CreateDirectory(folder); + var fileName = BuildFileName(); + var zipPath = Path.Combine(folder, fileName); + + IsBusy = true; + StatusText = "Sicherung läuft…"; + + try + { + var result = await Task.Run(() => _service.CreateAsync(_instanceDir, zipPath, options)); + + StatusText = $"Fertig: {Path.GetFileName(result.ZipPath)} ({FormatSize(result.SizeBytes)})"; + _logger?.LogInformation("Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes); + + Passphrase = ""; + PassphraseRepeat = ""; + + ApplyRotation(folder); + RefreshBackupList(); + } + catch (Exception ex) + { + StatusText = $"Fehler: {ex.Message}"; + _logger?.LogError(ex, "Sicherung fehlgeschlagen"); + } + finally + { + IsBusy = false; + } + } + + [RelayCommand] + private void ShowSelectedInFolder() + { + if (SelectedBackup is null) return; + SystemShell.RevealFile(SelectedBackup.FullPath); + } + + [RelayCommand] + private void DeleteSelected() + { + if (SelectedBackup is null) return; + + try + { + File.Delete(SelectedBackup.FullPath); + RefreshBackupList(); + StatusText = $"Gelöscht: {SelectedBackup.FileName}"; + } + catch (Exception ex) + { + StatusText = $"Konnte nicht gelöscht werden: {ex.Message}"; + } + } + + [RelayCommand] + private async Task RestoreSelectedAsync() + { + if (SelectedBackup is null || RequestRestoreDialog is null) return; + + BackupManifest manifest; + try + { + manifest = await _service.InspectAsync(SelectedBackup.FullPath); + } + catch (Exception ex) + { + StatusText = $"Sicherung unlesbar: {ex.Message}"; + return; + } + + var suggestedTarget = SuggestRestoreTarget(manifest); + var vm = new RestoreBackupViewModel(manifest, suggestedTarget); + + var resultData = await RequestRestoreDialog(vm); + if (resultData is null) return; + + IsBusy = true; + StatusText = "Wiederherstellung läuft…"; + + try + { + var restoreOptions = new RestoreOptions + { + Passphrase = resultData.Passphrase, + Overwrite = resultData.Overwrite + }; + + var result = await Task.Run(() => _service.RestoreAsync( + SelectedBackup.FullPath, resultData.TargetDirectory, restoreOptions)); + + StatusText = $"{result.Written.Count} Datei(en) wiederhergestellt nach {resultData.TargetDirectory}"; + _logger?.LogInformation("Wiederherstellung: {Written} geschrieben nach {Target}", + result.Written.Count, resultData.TargetDirectory); + } + catch (Exception ex) + { + StatusText = $"Wiederherstellung fehlgeschlagen: {ex.Message}"; + _logger?.LogError(ex, "Wiederherstellung fehlgeschlagen"); + } + finally + { + IsBusy = false; + } + } + + private string BuildFileName() + { + var name = string.IsNullOrWhiteSpace(_instanceName) ? "Instanz" : _instanceName; + var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + return $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip"; + } + + private string SuggestRestoreTarget(BackupManifest manifest) + { + var parent = Directory.GetParent(_instanceDir)?.FullName ?? _instanceDir; + var name = string.IsNullOrWhiteSpace(manifest.InstanceName) ? "Instanz" : manifest.InstanceName; + var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + return Path.Combine(parent, $"Instance-{safeName}_wiederhergestellt_{DateTime.Now:yyyyMMdd_HHmm}"); + } + + private void ApplyRotation(string folder) + { + if (KeepCount <= 0 || !Directory.Exists(folder)) return; + + try + { + var prefix = $"backup_{_instanceName}_"; + var obsolete = new DirectoryInfo(folder) + .GetFiles("*.zip") + .Where(f => f.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(f => f.LastWriteTime) + .Skip(KeepCount) + .ToList(); + + foreach (var file in obsolete) + { + file.Delete(); + _logger?.LogInformation("Alte Sicherung rotiert: {Name}", file.Name); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen"); + } + } + + private static string FormatSize(long bytes) => bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + < 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:F1} MB", + _ => $"{bytes / 1024.0 / 1024 / 1024:F2} GB" + }; +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs new file mode 100644 index 0000000..cb8a3d6 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/ChatPageViewModel.cs @@ -0,0 +1,158 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using ClawdDotNet.App; +using ClawdDotNet.Core.Config; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record ChatEntryItemViewModel( + string Role, + string Sender, + string Text, + DateTime Timestamp, + bool IsUser +); + +/// +/// Ansichtsmodell für die native Chat-Seite (Avalonia UI statt WebView2). +/// +public sealed partial class ChatPageViewModel : PageViewModel +{ + private readonly AppHost? _host; + + public ObservableCollection AvailableAgents { get; } = []; + public ObservableCollection ChatEntries { get; } = []; + + [ObservableProperty] + private AgentConfig? _selectedAgent; + + [ObservableProperty] + private string _inputPrompt = ""; + + [ObservableProperty] + private bool _isSending; + + [ObservableProperty] + private string _statusText = "Bereit"; + + public ChatPageViewModel() : this(null) { } + + public ChatPageViewModel(AppHost? host) : base("Chat") + { + _host = host; + + if (host is not null) + { + foreach (var agent in host.Instance.Agents) + AvailableAgents.Add(agent); + + SelectedAgent = AvailableAgents.FirstOrDefault(); + + if (host.Engine is not null) + { + host.Engine.OnChatEntryAdded += OnEngineChatEntryAdded; + } + } + else + { + StatusText = "Entwurfsmodus"; + } + } + + partial void OnSelectedAgentChanged(AgentConfig? value) + { + LoadChatHistory(); + } + + private void LoadChatHistory() + { + ChatEntries.Clear(); + if (_host?.Engine is null || SelectedAgent is null) return; + + var history = _host.Engine.GetChatHistory(SelectedAgent.AgentId); + foreach (var entry in history) + { + var isUser = entry.Role.Equals("user", StringComparison.OrdinalIgnoreCase); + ChatEntries.Add(new ChatEntryItemViewModel( + entry.Role, + isUser ? "Du" : SelectedAgent.DisplayName, + entry.Content, + entry.Timestamp, + isUser + )); + } + + StatusText = $"Chat-Verlauf für '{SelectedAgent.DisplayName}' geladen ({ChatEntries.Count} Nachrichten)."; + } + + private void OnEngineChatEntryAdded(string agentId, string role, string text, string? toolName) + { + if (SelectedAgent is null || SelectedAgent.AgentId != agentId) return; + + Dispatcher.UIThread.Post(() => + { + var isUser = role.Equals("user", StringComparison.OrdinalIgnoreCase); + var sender = isUser ? "Du" : (string.IsNullOrWhiteSpace(toolName) ? SelectedAgent.DisplayName : $"Tool: {toolName}"); + + ChatEntries.Add(new ChatEntryItemViewModel( + role, sender, text, DateTime.Now, isUser + )); + }); + } + + [RelayCommand] + private async Task SendMessageAsync() + { + if (_host?.Engine is null || SelectedAgent is null) + { + StatusText = "Engine nicht aktiv oder kein Agent ausgewählt."; + return; + } + + var text = InputPrompt.Trim(); + if (string.IsNullOrWhiteSpace(text)) return; + + InputPrompt = ""; + IsSending = true; + StatusText = $"{SelectedAgent.DisplayName} denkt nach…"; + + try + { + var agent = SelectedAgent; + var instanceId = _host.Instance.InstanceId; + + var result = await Task.Run(() => + _host.Engine.ChatAsync(agent, text, instanceId, CancellationToken.None)); + + StatusText = $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens)."; + } + catch (Exception ex) + { + StatusText = $"Fehler: {ex.Message}"; + } + finally + { + IsSending = false; + } + } + + [RelayCommand] + private void Abort() + { + if (_host?.Engine is null || SelectedAgent is null) return; + _host.Engine.AbortChat(SelectedAgent.AgentId); + StatusText = "Chat abgebrochen."; + IsSending = false; + } + + [RelayCommand] + private void ClearHistory() + { + if (_host?.Engine is null || SelectedAgent is null) return; + _host.Engine.ClearChatHistory(SelectedAgent.AgentId); + ChatEntries.Clear(); + StatusText = "Verlauf geleert."; + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs new file mode 100644 index 0000000..85a8283 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/InfoPageViewModel.cs @@ -0,0 +1,89 @@ +using System.Collections.ObjectModel; +using System.Reflection; +using ClawdDotNet.App; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record AssemblyInfoItem(string ShortName, int BuildNumber, string BuildDate, string Changes); + +/// +/// Ansichtsmodell für die Info-Seite (Version, Build-Informationen, Instanz-Details). +/// +public sealed partial class InfoPageViewModel : PageViewModel +{ + [ObservableProperty] + private string _appVersion = "—"; + + [ObservableProperty] + private string _buildSummary = "—"; + + [ObservableProperty] + private string _instanceName = "—"; + + [ObservableProperty] + private string _instanceId = "—"; + + [ObservableProperty] + private string _instancePath = "—"; + + [ObservableProperty] + private string _logDirectory = "—"; + + [ObservableProperty] + private string _openRouterStatus = "—"; + + public ObservableCollection Assemblies { get; } = []; + + public InfoPageViewModel() : this(null) { } + + public InfoPageViewModel(AppHost? host) : base("Info") + { + AppVersion = AppHost.AppVersion; + BuildSummary = AppHost.BuildSummary; + + if (host is not null) + { + InstanceName = host.Instance.InstanceName; + InstanceId = host.Instance.InstanceId; + InstancePath = host.InstancePath; + LogDirectory = host.LogDirectory; + OpenRouterStatus = host.Engine is null + ? "Deaktiviert (kein API-Key hinterlegt)" + : "Aktiv (AgentEngine bereit)"; + } + else + { + InstanceName = "Entwurfsmodus"; + InstanceId = "00000000-0000-0000-0000-000000000000"; + InstancePath = @"C:\ClawdDotNet\Instances\Default"; + LogDirectory = @"C:\ClawdDotNet\Logs"; + OpenRouterStatus = "Entwurf"; + } + + LoadAssemblyBuildInfos(); + } + + private void LoadAssemblyBuildInfos() + { + Assemblies.Clear(); + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name?.StartsWith("ClawdDotNet") == true) + .OrderBy(a => a.GetName().Name)) + { + var name = asm.GetName().Name!; + var shortName = name.Replace("ClawdDotNet.", "").Replace("Tools.", ""); + + var buildDate = asm.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false) + .OfType() + .FirstOrDefault(a => a.Key == "BuildDate")?.Value ?? "?"; + + var buildInfoType = asm.GetType(asm.GetName().Name + ".BuildInfo"); + var buildNum = buildInfoType?.GetField("Build")?.GetValue(null) as int? ?? 0; + var changes = buildInfoType?.GetField("Changes")?.GetValue(null) as string ?? "—"; + + Assemblies.Add(new AssemblyInfoItem(shortName, buildNum, buildDate, changes)); + } + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs new file mode 100644 index 0000000..b7db4c1 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/InstancePickerViewModel.cs @@ -0,0 +1,103 @@ +using System.Collections.ObjectModel; +using ClawdDotNet.App.Models; +using ClawdDotNet.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// +/// Auswahl der Instanz beim Start — der erste Rückruf, den AppHost erfragt. +/// +/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in +/// einem Formular. Hier steht nur, was geschieht; das wie es aussieht +/// liegt in der Ansicht, und Rückfragen laufen über statt über +/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar. +/// +public sealed partial class InstancePickerViewModel : ViewModelBase +{ + private readonly InstanceDirectoryManager _directories; + private readonly TaskCompletionSource _completion = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(StartCommand))] + private InstanceInfo? _selected; + + [ObservableProperty] + private string _newInstanceName = ""; + + /// Fehlertext für die Ansicht; leer heißt: alles in Ordnung. + [ObservableProperty] + private string _errorText = ""; + + public ObservableCollection Instances { get; } = []; + + /// Der gewählte Pfad, oder null bei Abbruch. + public Task Result => _completion.Task; + + public string InstancesDirectory => _directories.InstancesDirectory; + + public InstancePickerViewModel(InstanceDirectoryManager directories) + { + _directories = directories; + Refresh(); + } + + [RelayCommand] + private void Refresh() + { + Instances.Clear(); + + foreach (var instance in _directories.ListInstances()) + Instances.Add(instance); + + // Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick + // weniger bei jedem Start. + Selected ??= Instances.FirstOrDefault(); + } + + private bool CanStart => Selected is not null; + + [RelayCommand(CanExecute = nameof(CanStart))] + private void Start() => _completion.TrySetResult(Selected!.FolderPath); + + /// Abbruch — der Aufrufer beendet die Anwendung. + [RelayCommand] + private void Cancel() => _completion.TrySetResult(null); + + [RelayCommand] + private void Create() + { + var name = NewInstanceName.Trim(); + + if (string.IsNullOrWhiteSpace(name)) + { + ErrorText = "Bitte einen Instanznamen eingeben."; + return; + } + + try + { + _directories.CreateInstance(name); + NewInstanceName = ""; + ErrorText = ""; + Refresh(); + + // Die frisch angelegte Instanz vorwählen — wer sie erstellt, will sie starten. + Selected = Instances.FirstOrDefault(i => + string.Equals(i.InstanceName, name, StringComparison.Ordinal)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or ArgumentException) + { + ErrorText = $"Instanz konnte nicht angelegt werden: {ex.Message}"; + } + } + + /// + /// Wird gesetzt, wenn ein Fehler von außen kommt (etwa eine unlesbare + /// Instanzkonfiguration), damit das Fenster offen bleiben und eine andere Wahl + /// zulassen kann. + /// + public void ConfirmError(string message) => ErrorText = message; +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs new file mode 100644 index 0000000..5cdfad1 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/LicenseViewModel.cs @@ -0,0 +1,46 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// Eingabe eines Lizenzschlüssels. Siehe AvaloniaLicensePrompt. +public sealed partial class LicenseViewModel : ViewModelBase +{ + private readonly TaskCompletionSource _completion = new(); + + [ObservableProperty] + private string _key; + + public string HardwareId { get; } + + /// Warum der bisherige Schlüssel nicht taugt; leer beim ersten Fragen. + public string Problem { get; } + + /// Der eingegebene Schlüssel, oder null bei Abbruch. + public Task Result => _completion.Task; + + public LicenseViewModel(string hardwareId, string? problem, string? currentKey) + { + HardwareId = hardwareId; + Problem = problem ?? ""; + _key = currentKey ?? ""; + } + + [RelayCommand] + private void Confirm() + { + var value = Key.Trim(); + + // Leere Eingabe ist kein Abbruch — sonst schließt sich das Fenster, wenn jemand + // versehentlich die Eingabetaste drückt. + if (value.Length == 0) return; + + _completion.TrySetResult(value); + } + + [RelayCommand] + private void Cancel() => _completion.TrySetResult(null); + + /// Schließt der Benutzer das Fenster, gilt das als Abbruch. + public void CancelIfPending() => _completion.TrySetResult(null); +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs new file mode 100644 index 0000000..b573dd6 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs @@ -0,0 +1,112 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// +/// Live-Ansicht der Logdateien. +/// +/// Der Takt läuft über — das Avalonia-Gegenstück zum +/// System.Windows.Forms.Timer, also auf dem Oberflächenfaden. Für eine Ansicht +/// ist das genau richtig: Das Lesen selbst dauert Millisekunden, und so entfällt jedes +/// Marshalling. Alles, was ohne Fenster laufen muss, liegt dagegen in +/// und taktet dort über PeriodicTimer. +/// +public sealed partial class LogPageViewModel : PageViewModel +{ + /// + /// Obergrenze der angezeigten Zeilen. + /// + /// Ohne Deckel wächst die Liste unbegrenzt — ein Agent, der eine Nacht durchläuft, + /// hinterlässt sonst Hunderttausende Einträge im Speicher, und das Scrollen wird zäh. + /// + private const int MaxLines = 2000; + + private readonly LogTail? _tail; + private readonly DispatcherTimer? _timer; + private readonly string _logDirectory; + + public ObservableCollection Lines { get; } = []; + + public ObservableCollection Modules { get; } = ["Alle"]; + + public IReadOnlyList Levels { get; } = ["Alle", "Info", "Warn", "Error"]; + + [ObservableProperty] + private string _selectedModule = "Alle"; + + [ObservableProperty] + private string _selectedLevel = "Alle"; + + /// + /// Hängt die Ansicht am unteren Ende? Sobald jemand nach oben scrollt, soll nicht + /// weitergesprungen werden — sonst kann man nichts lesen, während etwas läuft. + /// + [ObservableProperty] + private bool _followTail = true; + + public LogPageViewModel(AppHost? host) : base("Logs") + { + _logDirectory = host?.LogDirectory ?? ""; + + if (host is null) return; // Entwurfsmodus + + _tail = new LogTail(_logDirectory); + RefreshModules(); + + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _timer.Tick += (_, _) => Poll(); + _timer.Start(); + } + + private void Poll() + { + if (_tail is null) return; + + var module = SelectedModule == "Alle" ? null : SelectedModule; + var level = SelectedLevel switch + { + "Info" => LogLevelFilter.Info, + "Warn" => LogLevelFilter.Warn, + "Error" => LogLevelFilter.Error, + _ => LogLevelFilter.All + }; + + foreach (var line in _tail.ReadNew(module, level)) + { + Lines.Add(line); + + // Von vorn kürzen statt am Ende zu deckeln: Das Neueste soll bleiben. + while (Lines.Count > MaxLines) + Lines.RemoveAt(0); + } + } + + [RelayCommand] + private void Clear() => Lines.Clear(); + + [RelayCommand] + private void RefreshModules() + { + if (_tail is null) return; + + var current = SelectedModule; + + Modules.Clear(); + Modules.Add("Alle"); + + foreach (var module in _tail.AvailableModules()) + Modules.Add(module); + + // Auswahl halten, wenn es das Modul noch gibt. + SelectedModule = Modules.Contains(current) ? current : "Alle"; + } + + [RelayCommand] + private void OpenFolder() => SystemShell.OpenFolder(_logDirectory); +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..ef743cf --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,102 @@ +using System.Collections.ObjectModel; +using ClawdDotNet.App; +using ClawdDotNet.Core.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// +/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist. +/// +public sealed partial class MainWindowViewModel : ViewModelBase +{ + private readonly AppHost? _host; + + [ObservableProperty] + private PageViewModel? _selectedPage; + + public ObservableCollection Pages { get; } + + /// Text der Statusleiste am unteren Rand. + [ObservableProperty] + private string _statusText = "Bereit"; + + public event Action? RequestExit; + public event Action? RequestInstanceManagerDialog; + + /// Für den Entwurfsmodus des Editors — ohne laufenden Aufbau. + public MainWindowViewModel() : this(null) { } + + public MainWindowViewModel(AppHost? host) + { + _host = host; + + Pages = + [ + new ChatPageViewModel(host), + new LogPageViewModel(host), + new AgentsPageViewModel(host), + new SettingsPageViewModel(host), + new TasksPageViewModel(host), + new BackupPageViewModel(host), + new InfoPageViewModel(host) + ]; + + SelectedPage = Pages[0]; + + StatusText = host is null + ? "Entwurfsmodus" + : $"Instanz: {host.Instance.InstanceName} · " + + (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit"); + } + + [RelayCommand] + private void OpenInstanceFolder() + { + if (_host is null) return; + SystemShell.OpenFolder(_host.InstancePath); + } + + [RelayCommand] + private void OpenLogFolder() + { + if (_host is null) return; + SystemShell.OpenFolder(_host.LogDirectory); + } + + [RelayCommand] + private void OpenInstanceManager() + { + RequestInstanceManagerDialog?.Invoke(); + } + + [RelayCommand] + private void SelectPageByTitle(string title) + { + var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase)); + if (target is not null) + SelectedPage = target; + } + + [RelayCommand] + private void Exit() + { + RequestExit?.Invoke(); + } +} + +/// Ein Bereich des Hauptfensters. +public abstract partial class PageViewModel(string title) : ViewModelBase +{ + public string Title { get; } = title; +} + +/// +/// Platzhalter, solange der Bereich noch nicht portiert ist. +/// +public sealed partial class PlaceholderPageViewModel(string title, string note) + : PageViewModel(title) +{ + public string Note { get; } = note; +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs new file mode 100644 index 0000000..d72a847 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/RestoreBackupViewModel.cs @@ -0,0 +1,77 @@ +using ClawdDotNet.Core.Backup; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record RestoreResultData(string TargetDirectory, string? Passphrase, bool Overwrite); + +public sealed partial class RestoreBackupViewModel : ViewModelBase +{ + private readonly BackupManifest _manifest; + + public string InstanceName => _manifest.InstanceName; + public DateTime CreatedAt => _manifest.CreatedAt; + public int FileCount => _manifest.Files.Count; + public bool NeedsPassphrase => _manifest.HasSecrets; + + public string SecretsDescription => _manifest.HasSecrets + ? $"{_manifest.SecretCount}, mit Passphrase geschützt" + : _manifest.SecretCount > 0 + ? $"{_manifest.SecretCount} entfernt" + : "keine"; + + [ObservableProperty] + private string _targetDirectory = ""; + + [ObservableProperty] + private string _passphrase = ""; + + [ObservableProperty] + private bool _overwrite; + + [ObservableProperty] + private string _statusMessage = ""; + + public RestoreResultData? Result { get; private set; } + + public event Action? CloseRequested; + + public RestoreBackupViewModel() : this(new BackupManifest(), @"C:\Instance_Restored") { } + + public RestoreBackupViewModel(BackupManifest manifest, string suggestedTarget) + { + _manifest = manifest; + TargetDirectory = suggestedTarget; + } + + [RelayCommand] + private void Confirm() + { + if (string.IsNullOrWhiteSpace(TargetDirectory)) + { + StatusMessage = "Bitte einen Zielordner angeben."; + return; + } + + if (NeedsPassphrase && string.IsNullOrEmpty(Passphrase)) + { + StatusMessage = "Bitte Passphrase für geschützte Zugangsdaten eingeben."; + return; + } + + Result = new RestoreResultData( + TargetDirectory.Trim(), + NeedsPassphrase ? Passphrase : null, + Overwrite); + + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Cancel() + { + Result = null; + CloseRequested?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs new file mode 100644 index 0000000..576557f --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs @@ -0,0 +1,183 @@ +using ClawdDotNet.App; +using ClawdDotNet.App.Settings; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// +/// Ansichtsmodell für die Einstellungen-Seite (Anwendungs- und Instanz-Einstellungen). +/// +public sealed partial class SettingsPageViewModel : PageViewModel +{ + private readonly AppHost? _host; + private readonly ILogger? _logger; + + // ─── Allgemein & Pfade ─── + [ObservableProperty] + private string _logDirectory = ""; + + [ObservableProperty] + private string _instancesDirectory = ""; + + [ObservableProperty] + private string _minimumLogLevel = "Info"; + + [ObservableProperty] + private int _maxLogLinesInUi = 2000; + + [ObservableProperty] + private int _logRefreshIntervalMs = 500; + + // ─── API & OpenRouter ─── + [ObservableProperty] + private string _openRouterBaseUrl = "https://openrouter.ai/api/v1/"; + + [ObservableProperty] + private int _statusCheckIntervalSeconds = 60; + + // ─── Backup ─── + [ObservableProperty] + private string _backupDirectory = ""; + + [ObservableProperty] + private bool _autoBackupEnabled; + + [ObservableProperty] + private string _autoBackupTime = "03:00"; + + [ObservableProperty] + private int _backupKeepCount = 14; + + // ─── Deploymentcenter ─── + [ObservableProperty] + private string _deploymentcenterUrl = "https://dc.mhdf.de"; + + [ObservableProperty] + private string _deploymentcenterToken = ""; + + [ObservableProperty] + private string _deploymentcenterEnvironment = "production"; + + [ObservableProperty] + private bool _errorReportingEnabled = true; + + [ObservableProperty] + private bool _updateCheckEnabled = true; + + [ObservableProperty] + private string _updateChannel = "prod"; + + // ─── Lizenz ─── + [ObservableProperty] + private string _licenseKey = ""; + + // ─── Instanz-Einstellungen ─── + [ObservableProperty] + private string _instanceName = ""; + + [ObservableProperty] + private string _openRouterApiKey = ""; + + [ObservableProperty] + private decimal _instanceDailyCostUsd; + + [ObservableProperty] + private long _instanceDailyTokens; + + [ObservableProperty] + private string _statusText = "Bereit"; + + public List LogLevels { get; } = ["Debug", "Info", "Warn", "Error"]; + public List UpdateChannels { get; } = ["prod", "beta", "dev"]; + public List Environments { get; } = ["production", "development", "staging"]; + + public SettingsPageViewModel() : this(null) { } + + public SettingsPageViewModel(AppHost? host) : base("Einstellungen") + { + _host = host; + + if (host is not null) + { + _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Settings"); + LoadFromHost(); + } + else + { + StatusText = "Entwurfsmodus"; + } + } + + [RelayCommand] + private void LoadFromHost() + { + if (_host is null) return; + + var s = _host.Settings.AppSettings; + LogDirectory = s.LogDirectory; + InstancesDirectory = s.InstancesDirectory; + MinimumLogLevel = s.MinimumLogLevel; + MaxLogLinesInUi = s.MaxLogLinesInUi; + LogRefreshIntervalMs = s.LogRefreshIntervalMs; + OpenRouterBaseUrl = s.OpenRouterBaseUrl; + StatusCheckIntervalSeconds = s.StatusCheckIntervalSeconds; + BackupDirectory = s.BackupDirectory; + AutoBackupEnabled = s.AutoBackupEnabled; + AutoBackupTime = s.AutoBackupTime; + BackupKeepCount = s.BackupKeepCount; + DeploymentcenterUrl = s.DeploymentcenterUrl; + DeploymentcenterToken = s.DeploymentcenterToken; + DeploymentcenterEnvironment = s.DeploymentcenterEnvironment; + ErrorReportingEnabled = s.ErrorReportingEnabled; + UpdateCheckEnabled = s.UpdateCheckEnabled; + UpdateChannel = s.UpdateChannel; + LicenseKey = s.LicenseKey; + + var inst = _host.Instance; + InstanceName = inst.InstanceName; + OpenRouterApiKey = inst.OpenRouterApiKey; + InstanceDailyCostUsd = inst.Budget.DailyCostUsd; + InstanceDailyTokens = inst.Budget.DailyTokens; + } + + [RelayCommand] + private void SaveSettings() + { + if (_host is null) return; + + var s = _host.Settings.AppSettings; + s.LogDirectory = LogDirectory.Trim(); + s.InstancesDirectory = InstancesDirectory.Trim(); + s.MinimumLogLevel = MinimumLogLevel; + s.MaxLogLinesInUi = MaxLogLinesInUi; + s.LogRefreshIntervalMs = LogRefreshIntervalMs; + s.OpenRouterBaseUrl = OpenRouterBaseUrl.Trim(); + s.StatusCheckIntervalSeconds = StatusCheckIntervalSeconds; + s.BackupDirectory = BackupDirectory.Trim(); + s.AutoBackupEnabled = AutoBackupEnabled; + s.AutoBackupTime = AutoBackupTime.Trim(); + s.BackupKeepCount = BackupKeepCount; + s.DeploymentcenterUrl = DeploymentcenterUrl.Trim(); + s.DeploymentcenterToken = DeploymentcenterToken.Trim(); + s.DeploymentcenterEnvironment = DeploymentcenterEnvironment.Trim(); + s.ErrorReportingEnabled = ErrorReportingEnabled; + s.UpdateCheckEnabled = UpdateCheckEnabled; + s.UpdateChannel = UpdateChannel; + s.LicenseKey = LicenseKey.Trim(); + + _host.Settings.Save(); + + var inst = _host.Instance; + inst.InstanceName = InstanceName.Trim(); + inst.OpenRouterApiKey = OpenRouterApiKey.Trim(); + inst.Budget.DailyCostUsd = InstanceDailyCostUsd; + inst.Budget.DailyTokens = InstanceDailyTokens; + + _host.Directories.SaveInstanceConfig(_host.InstancePath, inst); + + _logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert."); + StatusText = "Einstellungen erfolgreich gespeichert."; + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs new file mode 100644 index 0000000..44ce6a3 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/TasksPageViewModel.cs @@ -0,0 +1,446 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using ClawdDotNet.App; +using ClawdDotNet.App.Models; +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ClawdDotNet.Core.Scheduling; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed record JobDisplayItemViewModel( + string JobType, + string JobId, + string AgentId, + string AgentName, + string ToolName, + string CronExpression, + string TaskMessage, + bool RunOnStart, + string NextRun, + string LastRun, + string LastStatus, + string Status +); + +public sealed record ServiceDisplayItemViewModel( + string ServiceId, + string Name, + string Type, + int Port, + string Status, + string Description +); + +/// +/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf). +/// +public sealed partial class TasksPageViewModel : PageViewModel +{ + private readonly AppHost? _host; + private readonly JobHistoryService? _jobHistoryService; + private readonly ILogger? _logger; + private readonly Dictionary _jobLastRunTimes = new(); + + public ObservableCollection JobEntries { get; } = []; + public ObservableCollection ServiceEntries { get; } = []; + public ObservableCollection JobHistoryEntries { get; } = []; + + [ObservableProperty] + private JobDisplayItemViewModel? _selectedJob; + + [ObservableProperty] + private ServiceDisplayItemViewModel? _selectedService; + + [ObservableProperty] + private string _statusText = "Bereit"; + + [ObservableProperty] + private bool _isRunningJob; + + public event Func>? RequestAddJobDialog; + public event Func>? RequestAddServiceDialog; + + public TasksPageViewModel() : this(null) { } + + public TasksPageViewModel(AppHost? host) : base("Aufgaben") + { + _host = host; + + if (host is not null) + { + _jobHistoryService = new JobHistoryService(host.InstancePath); + _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks"); + + EnsureBuiltInServices(); + RefreshJobList(); + RefreshServiceList(); + RefreshJobHistory(); + } + else + { + StatusText = "Entwurfsmodus"; + } + } + + private void SaveInstanceConfig() + { + if (_host is null) return; + _host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance); + } + + private void EnsureBuiltInServices() + { + if (_host is null) return; + + var defaults = BuiltInServices.CreateDefaults(); + var changed = false; + + foreach (var def in defaults) + { + if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId)) + continue; + + _host.Instance.Services.Insert(0, def); + changed = true; + } + + if (changed) + SaveInstanceConfig(); + } + + [RelayCommand] + private void RefreshJobList() + { + JobEntries.Clear(); + if (_host is null) return; + + foreach (var agent in _host.Instance.Agents) + { + // Agent Wakeup Jobs + if (agent.Scheduler is not null) + { + var nextRun = CalculateNextRun(agent.Scheduler.Cron); + var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime) + ? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss") + : "—"; + + JobEntries.Add(new JobDisplayItemViewModel( + "Agent Wakeup", + $"agent_{agent.AgentId}", + agent.AgentId, + agent.DisplayName, + "—", + agent.Scheduler.Cron, + agent.Scheduler.TaskMessage, + agent.Scheduler.RunOnStart, + nextRun, + agentLastRun, + "—", + _host.Scanner is not null ? "Aktiv" : "Inaktiv" + )); + } + + // Tool Jobs + foreach (var toolJob in agent.ToolJobs) + { + var nextRun = CalculateNextRun(toolJob.Cron); + var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime) + ? lastTime.ToString("yyyy-MM-dd HH:mm:ss") + : "—"; + + JobEntries.Add(new JobDisplayItemViewModel( + "Tool Job", + toolJob.JobId, + agent.AgentId, + agent.DisplayName, + toolJob.ToolName, + toolJob.Cron, + toolJob.JobTypeId, + toolJob.RunOnStart, + nextRun, + toolLastRun, + "—", + toolJob.Enabled ? "Aktiv" : "Deaktiviert" + )); + } + } + } + + [RelayCommand] + private void RefreshServiceList() + { + ServiceEntries.Clear(); + if (_host is null) return; + + foreach (var svc in _host.Instance.Services) + { + ServiceEntries.Add(new ServiceDisplayItemViewModel( + svc.ServiceId, + svc.Name, + svc.Type, + svc.Port, + svc.Enabled ? "Bereit" : "Deaktiviert", + svc.Description + )); + } + } + + [RelayCommand] + private void RefreshJobHistory() + { + JobHistoryEntries.Clear(); + if (_jobHistoryService is null) return; + + foreach (var entry in _jobHistoryService.GetAll().Take(200)) + JobHistoryEntries.Add(entry); + } + + [RelayCommand] + private async Task AddJobAsync() + { + if (_host is null || RequestAddJobDialog is null) return; + + var vm = new AddJobViewModel(_host.Instance.Agents); + var result = await RequestAddJobDialog(vm); + if (result is null) return; + + var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId); + if (agent is null) return; + + if (result.IsToolJob) + { + agent.ToolJobs.Add(new ToolJobConfig + { + ToolName = result.ToolName, + JobTypeId = result.JobTypeId, + Cron = result.CronExpression, + RunOnStart = result.RunOnStart, + Enabled = true + }); + + _logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}", + agent.DisplayName, result.ToolName, result.CronExpression); + } + else + { + agent.Scheduler = new SchedulerConfig + { + Cron = result.CronExpression, + RunOnStart = result.RunOnStart, + TaskMessage = result.TaskMessage + }; + + _logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}", + agent.DisplayName, result.CronExpression); + } + + SaveInstanceConfig(); + RefreshJobList(); + } + + [RelayCommand] + private async Task EditJobAsync() + { + if (_host is null || SelectedJob is null || RequestAddJobDialog is null) return; + + var entry = SelectedJob; + var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId); + if (agent is null) return; + + var isToolJob = entry.JobType == "Tool Job"; + var vm = new AddJobViewModel(_host.Instance.Agents) + { + SelectedAgent = agent, + IsToolJob = isToolJob, + SelectedToolName = isToolJob ? entry.ToolName : "FileRW", + JobTypeId = isToolJob ? entry.TaskMessage : "PollJob", + CronExpression = entry.CronExpression, + TaskMessage = isToolJob ? "" : entry.TaskMessage, + RunOnStart = entry.RunOnStart + }; + + var result = await RequestAddJobDialog(vm); + if (result is null) return; + + if (isToolJob) + { + var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId); + if (toolJob is not null) + { + toolJob.Cron = result.CronExpression; + toolJob.RunOnStart = result.RunOnStart; + toolJob.JobTypeId = result.JobTypeId; + toolJob.ToolName = result.ToolName; + } + } + else + { + if (agent.Scheduler is not null) + { + agent.Scheduler.Cron = result.CronExpression; + agent.Scheduler.TaskMessage = result.TaskMessage; + agent.Scheduler.RunOnStart = result.RunOnStart; + } + } + + SaveInstanceConfig(); + RefreshJobList(); + } + + [RelayCommand] + private void RemoveJob() + { + if (_host is null || SelectedJob is null) return; + + var entry = SelectedJob; + var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId); + if (agent is null) return; + + if (entry.JobType == "Tool Job") + { + agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId); + } + else + { + agent.Scheduler = null; + } + + SaveInstanceConfig(); + RefreshJobList(); + } + + [RelayCommand] + private async Task RunJobNowAsync() + { + if (_host is null || SelectedJob is null) return; + + var entry = SelectedJob; + var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId); + if (agent is null) return; + + IsRunningJob = true; + StatusText = $"Führe Job für '{agent.DisplayName}' aus…"; + + try + { + if (entry.JobType == "Tool Job") + { + if (_host.Scanner is null) + { + StatusText = "Scanner ist nicht aktiv."; + return; + } + + var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId); + if (jobConfig is null) return; + + var ran = await _host.Scanner.RunTaskNowAsync( + $"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None); + + _jobLastRunTimes[jobConfig.JobId] = DateTime.Now; + var info = ran ? "Ausgeführt" : "Nicht bereit"; + + _jobHistoryService?.Add(new JobHistoryEntry + { + JobName = jobConfig.JobTypeId, + Agent = agent.DisplayName, + Time = DateTime.Now, + JobDescription = $"Tool Job: {jobConfig.ToolName}", + Info = info, + Status = "Manual" + }); + + StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info})."; + } + else + { + if (_host.Engine is null) + { + StatusText = "Agent-Engine ist nicht aktiv (kein API-Key)."; + return; + } + + var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus."; + var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None); + + _jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now; + + _jobHistoryService?.Add(new JobHistoryEntry + { + JobName = "Agent Wakeup", + Agent = agent.DisplayName, + Time = DateTime.Now, + JobDescription = taskMessage, + Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)", + Status = "Manual" + }); + + StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens)."; + } + + RefreshJobList(); + RefreshJobHistory(); + } + catch (Exception ex) + { + StatusText = $"Fehler beim Ausführen: {ex.Message}"; + _logger?.LogError(ex, "Job-Ausführung fehlgeschlagen"); + } + finally + { + IsRunningJob = false; + } + } + + [RelayCommand] + private async Task AddServiceAsync() + { + if (_host is null || RequestAddServiceDialog is null) return; + + var vm = new AddServiceViewModel(); + var result = await RequestAddServiceDialog(vm); + if (result is null) return; + + var svcConfig = new ServiceConfig + { + Name = result.ServiceName, + Type = result.ServiceType, + Port = result.ServicePort, + Enabled = true, + Description = result.ServiceDescription + }; + + _host.Instance.Services.Add(svcConfig); + SaveInstanceConfig(); + RefreshServiceList(); + } + + [RelayCommand] + private void RemoveService() + { + if (_host is null || SelectedService is null) return; + + _host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId); + SaveInstanceConfig(); + RefreshServiceList(); + } + + private static string CalculateNextRun(string? cronExpr) + { + if (string.IsNullOrWhiteSpace(cronExpr)) return "—"; + try + { + var cron = CronExpression.Parse(cronExpr); + var next = cron.GetNextOccurrence(DateTime.UtcNow); + return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—"; + } + catch + { + return "Ungültig"; + } + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs new file mode 100644 index 0000000..0260670 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/ToolSettingsViewModel.cs @@ -0,0 +1,221 @@ +using ClawdDotNet.Models; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace ClawdDotNet.Desktop.ViewModels; + +public sealed partial class ToolSettingsViewModel : ViewModelBase +{ + public string ToolName { get; } + public object? SettingsObject { get; } + + // FTP + [ObservableProperty] private string _ftpHost = ""; + [ObservableProperty] private int _ftpPort = 21; + [ObservableProperty] private string _ftpUsername = ""; + [ObservableProperty] private string _ftpPassword = ""; + [ObservableProperty] private string _ftpRootPath = "./data/"; + + // Mail + [ObservableProperty] private string _mailUsername = ""; + [ObservableProperty] private string _mailPassword = ""; + [ObservableProperty] private string _mailImapHost = ""; + [ObservableProperty] private int _mailImapPort = 993; + [ObservableProperty] private string _mailSmtpHost = ""; + [ObservableProperty] private int _mailSmtpPort = 587; + [ObservableProperty] private string _mailAllowedRecipients = ""; + + // Database + [ObservableProperty] private DatabaseType _dbType = DatabaseType.MySql; + [ObservableProperty] private string _dbConnectionString = ""; + [ObservableProperty] private DatabaseAccessLevel _dbAccessLevel = DatabaseAccessLevel.ReadOnly; + [ObservableProperty] private string _dbAllowedTables = ""; + + // Telegram + [ObservableProperty] private string _telegramBotToken = ""; + [ObservableProperty] private string _telegramDefaultChatId = ""; + [ObservableProperty] private string _telegramAllowedChatIds = ""; + + // DirectAPI + [ObservableProperty] private string _directApiDefaultProvider = "twelvedata"; + [ObservableProperty] private int _directApiCacheTtlSeconds = 60; + [ObservableProperty] private string _directApiTwelveDataKey = ""; + [ObservableProperty] private string _directApiAlphaVantageKey = ""; + + // WebFetch + [ObservableProperty] private string _webFetchAllowedDomains = ""; + [ObservableProperty] private int _webFetchMaxResponseKb = 512; + [ObservableProperty] private string _webFetchUserAgent = "ClawdDotNet-Agent/1.0"; + + // SocialMediaManager + [ObservableProperty] private string _smmXApiKey = ""; + [ObservableProperty] private string _smmXWatchAccounts = ""; + [ObservableProperty] private string _smmRedditWatchSubreddits = ""; + [ObservableProperty] private int _smmRedditPostLimit = 15; + [ObservableProperty] private string _smmOpenRouterApiKey = ""; + [ObservableProperty] private string _smmSttModel = "openai/whisper-1"; + [ObservableProperty] private string _smmYoutubeChannels = ""; + + // FileRW + [ObservableProperty] private string _fileRwPersonalAllowedExtensions = ".txt,.json,.md,.html,.js,.css"; + [ObservableProperty] private FileRWAccessLevel _fileRwSharedAccessLevel = FileRWAccessLevel.Denied; + [ObservableProperty] private string _fileRwSharedAllowedExtensions = ".txt,.json,.md"; + [ObservableProperty] private string _fileRwProtectedPaths = "stocks/"; + + public List DbTypes { get; } = [DatabaseType.MySql, DatabaseType.Postgres, DatabaseType.MsSql, DatabaseType.MongoDb]; + public List DbAccessLevels { get; } = [DatabaseAccessLevel.ReadOnly, DatabaseAccessLevel.ReadWrite, DatabaseAccessLevel.Admin]; + public List FileRwAccessLevels { get; } = [FileRWAccessLevel.Denied, FileRWAccessLevel.Read, FileRWAccessLevel.ReadWrite, FileRWAccessLevel.Admin]; + + public Dictionary? Result { get; private set; } + public event Action? CloseRequested; + + public ToolSettingsViewModel() : this("FTP", new()) { } + + public ToolSettingsViewModel(string toolName, Dictionary config) + { + ToolName = toolName; + SettingsObject = ToolSettingsFactory.CreateViewModel(toolName, config); + + switch (SettingsObject) + { + case FTPToolSettings ftp: + FtpHost = ftp.Host; + FtpPort = ftp.Port; + FtpUsername = ftp.Username; + FtpPassword = ftp.Password; + FtpRootPath = ftp.RootPath; + break; + + case MailToolSettings mail: + MailUsername = mail.Username; + MailPassword = mail.Password; + MailImapHost = mail.ImapHost; + MailImapPort = mail.ImapPort; + MailSmtpHost = mail.SmtpHost; + MailSmtpPort = mail.SmtpPort; + MailAllowedRecipients = mail.AllowedRecipients; + break; + + case DatabaseToolSettings db: + DbType = db.Type; + DbConnectionString = db.ConnectionString; + DbAccessLevel = db.AccessLevel; + DbAllowedTables = db.AllowedTables; + break; + + case TelegramToolSettings tg: + TelegramBotToken = tg.BotToken; + TelegramDefaultChatId = tg.DefaultChatId; + TelegramAllowedChatIds = tg.AllowedChatIds; + break; + + case DirectAPIToolSettings dapi: + DirectApiDefaultProvider = dapi.DefaultProvider; + DirectApiCacheTtlSeconds = dapi.CacheTtlSeconds; + DirectApiTwelveDataKey = dapi.TwelveDataKey; + DirectApiAlphaVantageKey = dapi.AlphaVantageKey; + break; + + case WebFetchToolSettings wf: + WebFetchAllowedDomains = wf.AllowedDomains; + WebFetchMaxResponseKb = wf.MaxResponseKb; + WebFetchUserAgent = wf.UserAgent; + break; + + case SocialMediaManagerToolSettings smm: + SmmXApiKey = smm.XApiKey; + SmmXWatchAccounts = smm.XWatchAccounts; + SmmRedditWatchSubreddits = smm.RedditWatchSubreddits; + SmmRedditPostLimit = smm.RedditPostLimit; + SmmOpenRouterApiKey = smm.OpenRouterApiKey; + SmmSttModel = smm.STTModel; + SmmYoutubeChannels = smm.YoutubeChannels; + break; + + case FileRWToolSettings frw: + FileRwPersonalAllowedExtensions = frw.PersonalAllowedExtensions; + FileRwSharedAccessLevel = frw.SharedAccessLevel; + FileRwSharedAllowedExtensions = frw.SharedAllowedExtensions; + FileRwProtectedPaths = frw.ProtectedPaths; + break; + } + } + + [RelayCommand] + private void Confirm() + { + switch (SettingsObject) + { + case FTPToolSettings ftp: + ftp.Host = FtpHost; + ftp.Port = FtpPort; + ftp.Username = FtpUsername; + ftp.Password = FtpPassword; + ftp.RootPath = FtpRootPath; + break; + + case MailToolSettings mail: + mail.Username = MailUsername; + mail.Password = MailPassword; + mail.ImapHost = MailImapHost; + mail.ImapPort = MailImapPort; + mail.SmtpHost = MailSmtpHost; + mail.SmtpPort = MailSmtpPort; + mail.AllowedRecipients = MailAllowedRecipients; + break; + + case DatabaseToolSettings db: + db.Type = DbType; + db.ConnectionString = DbConnectionString; + db.AccessLevel = DbAccessLevel; + db.AllowedTables = DbAllowedTables; + break; + + case TelegramToolSettings tg: + tg.BotToken = TelegramBotToken; + tg.DefaultChatId = TelegramDefaultChatId; + tg.AllowedChatIds = TelegramAllowedChatIds; + break; + + case DirectAPIToolSettings dapi: + dapi.DefaultProvider = DirectApiDefaultProvider; + dapi.CacheTtlSeconds = DirectApiCacheTtlSeconds; + dapi.TwelveDataKey = DirectApiTwelveDataKey; + dapi.AlphaVantageKey = DirectApiAlphaVantageKey; + break; + + case WebFetchToolSettings wf: + wf.AllowedDomains = WebFetchAllowedDomains; + wf.MaxResponseKb = WebFetchMaxResponseKb; + wf.UserAgent = WebFetchUserAgent; + break; + + case SocialMediaManagerToolSettings smm: + smm.XApiKey = SmmXApiKey; + smm.XWatchAccounts = SmmXWatchAccounts; + smm.RedditWatchSubreddits = SmmRedditWatchSubreddits; + smm.RedditPostLimit = SmmRedditPostLimit; + smm.OpenRouterApiKey = SmmOpenRouterApiKey; + smm.STTModel = SmmSttModel; + smm.YoutubeChannels = SmmYoutubeChannels; + break; + + case FileRWToolSettings frw: + frw.PersonalAllowedExtensions = FileRwPersonalAllowedExtensions; + frw.SharedAccessLevel = FileRwSharedAccessLevel; + frw.SharedAllowedExtensions = FileRwSharedAllowedExtensions; + frw.ProtectedPaths = FileRwProtectedPaths; + break; + } + + Result = ToolSettingsFactory.ToConfig(ToolName, SettingsObject) ?? new(); + CloseRequested?.Invoke(); + } + + [RelayCommand] + private void Cancel() + { + Result = null; + CloseRequested?.Invoke(); + } +} diff --git a/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs b/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..b453a19 --- /dev/null +++ b/src/ClawdDotNet.Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace ClawdDotNet.Desktop.ViewModels; + +/// +/// Grundlage aller Ansichtsmodelle. +/// +/// aus dem CommunityToolkit bringt +/// INotifyPropertyChanged samt Quelltextgenerator mit — aus +/// [ObservableProperty] private string _name; wird die vollständige Eigenschaft +/// mit Benachrichtigung. Das spart gegenüber der WinForms-Fassung, in der die +/// Oberfläche bei jeder Änderung von Hand nachgezogen wurde +/// (38 Stellen mit Invoke/BeginInvoke). +/// +public abstract class ViewModelBase : ObservableObject; diff --git a/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml new file mode 100644 index 0000000..8106bec --- /dev/null +++ b/src/ClawdDotNet.Desktop/Views/AddAgentWindow.axaml @@ -0,0 +1,51 @@ + + + + + + + -public sealed class SecretProtectorTests +[Collection(SecretKeyCollection.Name)] +public sealed class SecretProtectorTests : IDisposable { + private readonly string _keyDir; + + public SecretProtectorTests() + { + _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + SecretKeyStore.UseDirectory(_keyDir); + } + + public void Dispose() + { + SecretKeyStore.UseDirectory(null); + try { Directory.Delete(_keyDir, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + [Fact] public void Ein_verschluesselter_Wert_laesst_sich_wieder_lesen() { @@ -75,6 +96,9 @@ public sealed class SecretProtectorTests // Klartext auszugeben würde einen unbrauchbaren Schlüssel an die API schicken. Should.Throw( () => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block")); + + Should.Throw( + () => SecretProtector.Unprotect("enc:v2:das-ist-kein-gueltiger-block")); } [Fact] @@ -84,10 +108,120 @@ public sealed class SecretProtectorTests SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret); } + + // ═══════════════════════════════════════════════════════════ + // Plattformübergreifendes Format (enc:v2) + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void Neue_Werte_werden_im_plattformuebergreifenden_Format_geschrieben() + { + // Der Kern der Portierung: Unter Linux gab die vorige Fassung hier den Klartext + // zurück. Auf einem Server, der gesichert wird, war das schlechter als nichts. + var value = SecretProtector.Protect("sk-or-v1-geheim"); + + value.ShouldStartWith("enc:v2:"); + } + + [Fact] + public void Dieselbe_Eingabe_ergibt_zweimal_verschiedene_Ausgaben() + { + // AES-GCM mit zufälligem Nonce: Aus gleichen Werten dürfen keine gleichen + // Blöcke werden, sonst verrät die Konfigurationsdatei, wo dasselbe Passwort + // mehrfach benutzt wird. + var a = SecretProtector.Protect("dasselbe-passwort"); + var b = SecretProtector.Protect("dasselbe-passwort"); + + a.ShouldNotBe(b); + SecretProtector.Unprotect(a).ShouldBe("dasselbe-passwort"); + SecretProtector.Unprotect(b).ShouldBe("dasselbe-passwort"); + } + + [Fact] + public void Ein_veraenderter_Block_wird_erkannt_und_nicht_entschluesselt() + { + // Der Zweck der Authentifizierung in AES-GCM: Ein manipulierter Block darf + // keinen halb geratenen Klartext ergeben. + var value = SecretProtector.Protect("sk-or-v1-geheim")!; + var payload = Convert.FromBase64String(value["enc:v2:".Length..]); + payload[^1] ^= 0xFF; + var tampered = "enc:v2:" + Convert.ToBase64String(payload); + + Should.Throw(() => SecretProtector.Unprotect(tampered)); + } + + [Fact] + public void Ein_fremder_Schluessel_kann_den_Wert_nicht_lesen() + { + // Genau die Eigenschaft, die den Schutz ausmacht: Die Konfigurationsdatei allein + // nützt auf einem anderen Rechner nichts, weil der Schlüssel nicht mitreist. + var value = SecretProtector.Protect("sk-or-v1-geheim"); + + var fremd = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + try + { + SecretKeyStore.UseDirectory(fremd); + Should.Throw(() => SecretProtector.Unprotect(value)); + } + finally + { + SecretKeyStore.UseDirectory(_keyDir); + try { Directory.Delete(fremd, recursive: true); } catch { } + } + } + + [Fact] + public void Der_Schluessel_wird_einmal_angelegt_und_dann_wiederverwendet() + { + var first = SecretProtector.Protect("geheim"); + var keyBytes = File.ReadAllBytes(SecretKeyStore.KeyFilePath); + + var second = SecretProtector.Protect("noch-geheimer"); + + File.ReadAllBytes(SecretKeyStore.KeyFilePath).ShouldBe(keyBytes); + SecretProtector.Unprotect(first).ShouldBe("geheim"); + SecretProtector.Unprotect(second).ShouldBe("noch-geheimer"); + } + + [LinuxFact] + public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar() + { + SecretProtector.Protect("geheim"); + + File.GetUnixFileMode(SecretKeyStore.KeyFilePath) + .ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + [LinuxFact] + public void Ein_DPAPI_Wert_aus_einer_Windows_Instanz_wird_mit_Begruendung_abgewiesen() + { + // Der Umzugsfall. Ihn als Klartext durchzureichen würde einen unbrauchbaren + // Schlüssel an die API schicken — die Meldung muss sagen, was zu tun ist. + var ex = Should.Throw( + () => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4]))); + + ex.Message.ShouldContain("neu eingetragen"); + } } -public sealed class ConfigSecretsTests +[Collection(SecretKeyCollection.Name)] +public sealed class ConfigSecretsTests : IDisposable { + private readonly string _keyDir; + + public ConfigSecretsTests() + { + _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + SecretKeyStore.UseDirectory(_keyDir); + } + + public void Dispose() + { + SecretKeyStore.UseDirectory(null); + try { Directory.Delete(_keyDir, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + [Theory] [InlineData("password", true)] [InlineData("apiKey", true)] diff --git a/tests/ClawdDotNet.Core.Tests/Staging/EngineStagingTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/EngineStagingTests.cs new file mode 100644 index 0000000..2934a80 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Staging/EngineStagingTests.cs @@ -0,0 +1,96 @@ +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Tests.Infrastructure; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Staging; + +/// +/// A2 an der Engine — die eigentliche Sicherheitswirkung: Eine approve-Aktion wird +/// vorgeschlagen statt ausgeführt. Eine Prompt-Injection kann damit nur einen +/// Vorschlag erzeugen, keine Ausführung. +/// +public sealed class EngineStagingTests +{ + private static StagingGate Gate(InMemoryStagingRepository repo, StagingDecision decision) + => new(new StagingPolicy(new Dictionary { ["TestTool"] = decision }), repo); + + [Fact] + public async Task Eine_freigabepflichtige_Aktion_wird_vorgeschlagen_statt_ausgefuehrt() + { + var staging = new InMemoryStagingRepository(); + var tool = FakeTool.Returning("ausgeführt"); + var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Approve)).WithTool(tool); + var agent = fixture.AddAgent("agent-a", "TestTool"); + fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); + + await fixture.Engine.RunAsync(agent, "sende die Mail", "test-instance", default); + + tool.Invocations.ShouldBeEmpty("die Aktion darf nicht ausgeführt worden sein"); + (await staging.CountPendingAsync(default)).ShouldBe(1, "sie liegt als Vorschlag vor"); + } + + [Fact] + public async Task Der_Vorschlag_wird_als_Staged_ins_Audit_Log_geschrieben() + { + var staging = new InMemoryStagingRepository(); + var audit = new InMemoryAuditRepository(); + var fixture = new EngineFixture(audit, Gate(staging, StagingDecision.Approve)) + .WithTool(FakeTool.Returning("x")); + var agent = fixture.AddAgent("agent-a", "TestTool"); + fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); + + await fixture.Engine.RunAsync(agent, "los", "test-instance", default); + + audit.Entries.Single().Status.ShouldBe(AuditStatus.Staged); + } + + [Fact] + public async Task Eine_gesperrte_Aktion_wird_abgelehnt_und_nicht_ausgefuehrt() + { + var staging = new InMemoryStagingRepository(); + var audit = new InMemoryAuditRepository(); + var tool = FakeTool.Returning("x"); + var fixture = new EngineFixture(audit, Gate(staging, StagingDecision.Deny)).WithTool(tool); + var agent = fixture.AddAgent("agent-a", "TestTool"); + fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); + + await fixture.Engine.RunAsync(agent, "los", "test-instance", default); + + tool.Invocations.ShouldBeEmpty(); + (await staging.CountPendingAsync(default)).ShouldBe(0, "deny legt keinen Vorschlag an"); + audit.Entries.Single().Status.ShouldBe(AuditStatus.Denied); + } + + [Fact] + public async Task Eine_auto_Aktion_laeuft_unveraendert_durch() + { + var staging = new InMemoryStagingRepository(); + var tool = FakeTool.Returning("ausgeführt"); + var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Auto)).WithTool(tool); + var agent = fixture.AddAgent("agent-a", "TestTool"); + fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("fertig"); + + await fixture.Engine.RunAsync(agent, "los", "test-instance", default); + + tool.Invocations.Count.ShouldBe(1, "auto führt normal aus"); + (await staging.CountPendingAsync(default)).ShouldBe(0); + } + + [Fact] + public async Task Ein_freigegebener_Aufruf_wird_direkt_ausgefuehrt_ohne_erneute_Pruefung() + { + // ExecuteApprovedCallAsync umgeht das Gate — genau der eingefrorene Aufruf läuft. + var staging = new InMemoryStagingRepository(); + var tool = FakeTool.Returning("gesendet"); + var fixture = new EngineFixture(staging: Gate(staging, StagingDecision.Approve)).WithTool(tool); + fixture.AddAgent("agent-a", "TestTool"); + + var result = await fixture.Engine.ExecuteApprovedCallAsync( + "agent-a", "TestTool", "{\"action\":\"send\"}", "run-x", default); + + result.ShouldBe("gesendet"); + tool.Invocations.Count.ShouldBe(1); + (await staging.CountPendingAsync(default)).ShouldBe(0, "ein freigegebener Aufruf staged nicht erneut"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Staging/StagingPolicyTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/StagingPolicyTests.cs new file mode 100644 index 0000000..ae49aff --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Staging/StagingPolicyTests.cs @@ -0,0 +1,49 @@ +using ClawdDotNet.Core.Staging; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Staging; + +public sealed class StagingPolicyTests +{ + [Theory] + [InlineData("Mail", "send")] + [InlineData("Telegram", "send_message")] + [InlineData("Database", "insert")] + [InlineData("Database", "upsert")] + [InlineData("FileRW", "delete")] + [InlineData("FTP", "upload")] + [InlineData("FTP", "delete")] + public void Irreversible_Aktionen_brauchen_standardmaessig_eine_Freigabe(string tool, string action) + => new StagingPolicy().Decide(tool, action).ShouldBe(StagingDecision.Approve); + + [Theory] + [InlineData("Mail", "read_inbox")] + [InlineData("FileRW", "read")] + [InlineData("Database", "query")] + [InlineData("FTP", "download")] + public void Lesende_Aktionen_laufen_ohne_Freigabe(string tool, string action) + => new StagingPolicy().Decide(tool, action).ShouldBe(StagingDecision.Auto); + + [Fact] + public void Die_speziellere_Regel_gewinnt() + { + var policy = new StagingPolicy(new Dictionary + { + ["FileRW"] = StagingDecision.Approve, // ganzes Tool + ["FileRW.read"] = StagingDecision.Auto // aber Lesen frei + }); + + policy.Decide("FileRW", "read").ShouldBe(StagingDecision.Auto); + policy.Decide("FileRW", "write").ShouldBe(StagingDecision.Approve); + } + + [Fact] + public void Ohne_passende_Regel_gilt_der_Standard() + { + new StagingPolicy(new Dictionary()) + .Decide("Irgendein", "ding").ShouldBe(StagingDecision.Auto); + + new StagingPolicy(new Dictionary(), StagingDecision.Deny) + .Decide("Irgendein", "ding").ShouldBe(StagingDecision.Deny); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs new file mode 100644 index 0000000..c8fa26e --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Staging/StagingRepositoryTests.cs @@ -0,0 +1,96 @@ +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Staging; + +/// +/// Der bedingte Statuswechsel ist der Kern: zwei Reviewer dürfen denselben Vorschlag nicht +/// doppelt entscheiden. Getestet gegen echte SQLite. +/// +public sealed class StagingRepositoryTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteStagingRepository _repo; + + public StagingRepositoryTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteStagingRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private Task Stage(string tool = "Mail", string action = "send") + => _repo.AppendAsync(new StagedCall + { + RunId = "run-1", + AgentId = "agent-a", + Tool = tool, + Action = action, + ArgumentsJson = "{\"action\":\"send\",\"to\":\"x@y.de\"}", + Proposal = $"{tool}.{action}" + }, default); + + [Fact] + public async Task Ein_Vorschlag_wird_angelegt_und_erscheint_als_offen() + { + var id = await Stage(); + + var pending = await _repo.ListPendingAsync(default); + pending.Count.ShouldBe(1); + pending[0].Id.ShouldBe(id); + pending[0].Status.ShouldBe(StagingStatus.Pending); + (await _repo.CountPendingAsync(default)).ShouldBe(1); + } + + [Fact] + public async Task Der_eingefrorene_Argument_JSON_bleibt_erhalten() + { + var id = await Stage(); + (await _repo.GetAsync(id, default))!.ArgumentsJson.ShouldContain("x@y.de"); + } + + [Fact] + public async Task Nur_ein_Reviewer_kann_denselben_Vorschlag_entscheiden() + { + var id = await Stage(); + + var results = await Task.WhenAll( + Enumerable.Range(0, 16).Select(i => + _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, + $"user-{i}", null, DateTime.UtcNow, default))); + + results.Count(won => won).ShouldBe(1, "genau ein Reviewer gewinnt den Übergang"); + } + + [Fact] + public async Task Ein_bereits_entschiedener_Vorschlag_laesst_sich_nicht_erneut_wechseln() + { + var id = await Stage(); + (await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, "u", "nein", DateTime.UtcNow, default)).ShouldBeTrue(); + (await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, "u2", null, DateTime.UtcNow, default)) + .ShouldBeFalse("er ist nicht mehr pending"); + + (await _repo.ListPendingAsync(default)).ShouldBeEmpty(); + } + + [Fact] + public async Task Finalize_setzt_Endstatus_und_Ergebnis() + { + var id = await Stage(); + await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, "u", null, DateTime.UtcNow, default); + await _repo.FinalizeAsync(id, StagingStatus.Executed, "gesendet", DateTime.UtcNow, default); + + var call = await _repo.GetAsync(id, default); + call!.Status.ShouldBe(StagingStatus.Executed); + call.ResultRef.ShouldBe("gesendet"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs new file mode 100644 index 0000000..8c36a5d --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Staging/StagingServiceTests.cs @@ -0,0 +1,156 @@ +using ClawdDotNet.Core.Staging; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Staging; + +/// +/// Der Freigabe-/Ablehnungs-Fluss (A2). Kernpunkte: der eingefrorene Aufruf wird bei +/// Freigabe direkt ausgeführt, der Agent wird über einen Folge-Task (A1) geweckt, und jede +/// Entscheidung landet als Approval-Record im Audit-Log (A3). +/// +public sealed class StagingServiceTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteStagingRepository _staging; + private readonly SqliteTaskRepository _tasks; + private readonly TaskboardService _board; + private readonly InMemoryAuditRepository _audit = new(); + private readonly FakeExecutor _executor = new(); + private readonly StagingService _service; + + public StagingServiceTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _staging = new SqliteStagingRepository(_storage); + _tasks = new SqliteTaskRepository(_storage); + _board = new TaskboardService(_tasks, Path.Combine(_directory, "SharedWorkspace", "tasks")); + _service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance, _audit); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private Task Stage(string args = "{\"action\":\"send\",\"to\":\"x@y.de\"}") + => _staging.AppendAsync(new StagedCall + { + RunId = "run-1", + AgentId = "agent-a", + Tool = "Mail", + Action = "send", + ArgumentsJson = args, + Proposal = "Mail.send" + }, default); + + [Fact] + public async Task Freigabe_fuehrt_genau_den_eingefrorenen_Aufruf_aus() + { + var id = await Stage("{\"action\":\"send\",\"to\":\"chef@firma.de\"}"); + + var result = await _service.ApproveAsync(id, "richard", default); + + result.Kind.ShouldBe(StagingResultKind.Executed); + _executor.Calls.Count.ShouldBe(1); + _executor.Calls[0].Tool.ShouldBe("Mail"); + _executor.Calls[0].Args.ShouldContain("chef@firma.de", Case.Sensitive); + (await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Executed); + } + + [Fact] + public async Task Freigabe_weckt_den_Agenten_ueber_einen_Folge_Task() + { + var id = await Stage(); + + await _service.ApproveAsync(id, "richard", default); + + var tasks = await _tasks.ListAsync(new TaskQuery(), default); + tasks.Count.ShouldBe(1); + tasks[0].Assignee.ShouldBe("@agent-a", "der vorschlagende Agent wird mit seinem Kontext geweckt"); + tasks[0].Status.ShouldBe(TaskItemStatus.Todo); + tasks[0].Body.ShouldContain("freigegeben"); + } + + [Fact] + public async Task Jede_Freigabe_wird_als_Approval_Record_protokolliert() + { + var id = await Stage(); + + await _service.ApproveAsync(id, "richard", default); + + var entry = _audit.Entries.Single(); + entry.Source.ShouldBe("approval"); + entry.Tool.ShouldBe("Mail"); + entry.Summary.ShouldContain("Freigegeben von richard"); + } + + [Fact] + public async Task Ablehnung_fuehrt_nichts_aus_und_weckt_den_Agenten_mit_dem_Grund() + { + var id = await Stage(); + + var result = await _service.RejectAsync(id, "richard", "zu riskant", default); + + result.Kind.ShouldBe(StagingResultKind.Rejected); + _executor.Calls.ShouldBeEmpty("eine Ablehnung führt nichts aus"); + (await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Rejected); + + var tasks = await _tasks.ListAsync(new TaskQuery(), default); + tasks[0].Body.ShouldContain("abgelehnt"); + tasks[0].Body.ShouldContain("zu riskant"); + _audit.Entries.Single().Summary.ShouldContain("Abgelehnt von richard"); + } + + [Fact] + public async Task Ein_zweiter_Freigabeklick_laeuft_ins_Leere_und_fuehrt_nicht_erneut_aus() + { + var id = await Stage(); + + var first = await _service.ApproveAsync(id, "a", default); + var second = await _service.ApproveAsync(id, "b", default); + + first.Kind.ShouldBe(StagingResultKind.Executed); + second.Kind.ShouldBe(StagingResultKind.AlreadyDecided); + _executor.Calls.Count.ShouldBe(1, "genau einmal ausgeführt"); + } + + [Fact] + public async Task Ein_unbekannter_Vorschlag_wird_gemeldet() + => (await _service.ApproveAsync(999, "x", default)).Kind.ShouldBe(StagingResultKind.NotFound); + + [Fact] + public async Task Scheitert_die_Ausfuehrung_wird_der_Vorschlag_als_fehlgeschlagen_markiert() + { + _executor.Throw = true; + var id = await Stage(); + + var result = await _service.ApproveAsync(id, "richard", default); + + result.Kind.ShouldBe(StagingResultKind.Failed); + (await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Failed); + // Der Agent wird trotzdem geweckt (mit der Fehlermeldung). + (await _tasks.ListAsync(new TaskQuery(), default)).Count.ShouldBe(1); + } + + private sealed class FakeExecutor : IFrozenCallExecutor + { + public List<(string Agent, string Tool, string Args)> Calls { get; } = []; + public bool Throw { get; set; } + + public Task ExecuteApprovedCallAsync( + string agentId, string tool, string argumentsJson, string runId, CancellationToken ct) + { + if (Throw) throw new InvalidOperationException("Tool kaputt"); + Calls.Add((agentId, tool, argumentsJson)); + return Task.FromResult("ausgeführt"); + } + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Storage/PathBoundaryTests.cs b/tests/ClawdDotNet.Core.Tests/Storage/PathBoundaryTests.cs new file mode 100644 index 0000000..4caa746 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Storage/PathBoundaryTests.cs @@ -0,0 +1,152 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tests.Infrastructure; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Storage; + +/// +/// Die Einschließungsprüfung verglich bis zur Linux-Portierung mit +/// OrdinalIgnoreCase — die Windows-Annahme, dass Groß- und Kleinschreibung +/// zusammenfallen. Unter Linux sind Workspace und workspace zwei +/// Verzeichnisse, und eine symbolische Verknüpfung kann aus dem Wurzelverzeichnis +/// hinauszeigen, ohne dass die reine Pfadrechnung das bemerkt. +/// +/// Das Dateisystem wird hier bewusst nicht abstrahiert: Genau die Semantik, um die es +/// geht, würde eine Abstraktion verstecken. +/// +public sealed class PathBoundaryTests : IDisposable +{ + private readonly string _base; + private readonly string _root; + + public PathBoundaryTests() + { + _base = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _root = Path.Combine(_base, "Workspace"); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_base, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + // ═══════════════════════════════════════════════════════════ + // Verzeichnisgrenzen (auf beiden Plattformen gleich) + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void Ein_Nachbarverzeichnis_mit_gleichem_Praefix_liegt_ausserhalb() + { + PathBoundary.IsInside(Path.Combine(_root + "-Backup", "geheim.txt"), _root) + .ShouldBeFalse(); + } + + [Fact] + public void Das_Wurzelverzeichnis_selbst_liegt_innerhalb() + { + PathBoundary.IsInside(_root, _root).ShouldBeTrue(); + PathBoundary.IsInside(_root + Path.DirectorySeparatorChar, _root).ShouldBeTrue(); + } + + [Fact] + public void Ein_Unterverzeichnis_liegt_innerhalb() + { + PathBoundary.IsInside(Path.Combine(_root, "a", "b", "c.txt"), _root).ShouldBeTrue(); + } + + [Fact] + public void Punkt_Punkt_fuehrt_hinaus() + { + PathBoundary.IsInside(Path.Combine(_root, "..", "evil.txt"), _root).ShouldBeFalse(); + } + + // ═══════════════════════════════════════════════════════════ + // Groß-/Kleinschreibung — hier gehen die Plattformen auseinander + // ═══════════════════════════════════════════════════════════ + + [LinuxFact] + public void Unter_Linux_ist_ein_Verzeichnis_mit_anderer_Schreibweise_ein_anderes() + { + // "workspace" neben "Workspace": zwei echte, getrennte Verzeichnisse. + var lower = Path.Combine(_base, "workspace"); + Directory.CreateDirectory(lower); + + PathBoundary.IsInside(Path.Combine(lower, "geheim.txt"), _root).ShouldBeFalse(); + } + + [WindowsFact] + public void Unter_Windows_meint_eine_andere_Schreibweise_dasselbe_Verzeichnis() + { + var lower = Path.Combine(_base, "workspace"); + + PathBoundary.IsInside(Path.Combine(lower, "datei.txt"), _root).ShouldBeTrue(); + } + + [Fact] + public void Der_Schluessel_fuer_Sperren_folgt_der_Plattform() + { + var upper = Path.Combine(_base, "Config.json"); + var lower = Path.Combine(_base, "config.json"); + + var gleich = PathBoundary.CanonicalKey(upper) == PathBoundary.CanonicalKey(lower); + + // Unter Windows dieselbe Datei — dieselbe Sperre. Unter Linux zwei Dateien, + // die sich keine teilen dürfen. + gleich.ShouldBe(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); + } + + // ═══════════════════════════════════════════════════════════ + // Symbolische Verknüpfungen + // ═══════════════════════════════════════════════════════════ + + [LinuxFact] + public void Eine_Verknuepfung_aus_dem_Wurzelverzeichnis_heraus_wird_erkannt() + { + // Der Fall, den die reine Pfadrechnung nicht sieht: "Workspace/raus" zeigt + // nach draußen, "Workspace/raus/beute.txt" liegt lexikalisch aber innerhalb. + var draussen = Path.Combine(_base, "Draussen"); + Directory.CreateDirectory(draussen); + Directory.CreateSymbolicLink(Path.Combine(_root, "raus"), draussen); + + PathBoundary.IsInside(Path.Combine(_root, "raus", "beute.txt"), _root) + .ShouldBeFalse(); + } + + [LinuxFact] + public void Eine_Verknuepfung_innerhalb_des_Wurzelverzeichnisses_bleibt_erlaubt() + { + // Gegenprobe: Wer innerhalb bleibt, wird nicht ausgesperrt. + var ziel = Path.Combine(_root, "echte-daten"); + Directory.CreateDirectory(ziel); + Directory.CreateSymbolicLink(Path.Combine(_root, "abkuerzung"), ziel); + + PathBoundary.IsInside(Path.Combine(_root, "abkuerzung", "bericht.md"), _root) + .ShouldBeTrue(); + } + + [LinuxFact] + public void Ein_Wurzelverzeichnis_hinter_einer_Verknuepfung_sperrt_sich_nicht_selbst_aus() + { + // Liegt der Workspace selbst hinter einer Verknüpfung (üblich bei /var → /private/var + // oder gemounteten Datenverzeichnissen), muss die Auflösung auf beiden Seiten + // greifen — sonst passt nach dem Auflösen nichts mehr zusammen. + var echt = Path.Combine(_base, "EchterOrt"); + Directory.CreateDirectory(echt); + var ueber = Path.Combine(_base, "Verknuepft"); + Directory.CreateSymbolicLink(ueber, echt); + + PathBoundary.IsInside(Path.Combine(ueber, "datei.txt"), ueber).ShouldBeTrue(); + PathBoundary.IsInside(Path.Combine(echt, "datei.txt"), ueber).ShouldBeTrue(); + } + + [Fact] + public void Nicht_existierende_Pfade_bleiben_pruefbar() + { + // Beim Schreiben gibt es die Datei noch nicht. Was nicht existiert, kann keine + // Verknüpfung sein — die Auflösung darf daran nicht scheitern. + PathBoundary.IsInside(Path.Combine(_root, "gibt", "es", "noch", "nicht.txt"), _root) + .ShouldBeTrue(); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs new file mode 100644 index 0000000..6289b39 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/CoordinationMigrationTests.cs @@ -0,0 +1,118 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Schritt 4: die Übernahme der alten coordination/*.md-Dateien. Geprüft an den +/// tatsächlichen Altformaten (freies Markdown, kein Frontmatter). Kernanspruch: nur echte +/// Aufgaben (task_*) wandern ins Board, alles andere bleibt unangetastet, und die +/// Migration ist idempotent. +/// +public sealed class CoordinationMigrationTests : IDisposable +{ + private readonly string _directory; + private readonly string _coordinationDir; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly TaskboardService _board; + private readonly CoordinationMigration _migration; + + public CoordinationMigrationTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + var shared = Path.Combine(_directory, "SharedWorkspace"); + _coordinationDir = Path.Combine(shared, "coordination"); + Directory.CreateDirectory(_coordinationDir); + + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + _board = new TaskboardService(_repo, Path.Combine(shared, "tasks")); + _migration = new CoordinationMigration(_board, _coordinationDir, NullLoggerFactory.Instance); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private void Write(string name, string content) + => File.WriteAllText(Path.Combine(_coordinationDir, name), content); + + [Fact] + public async Task Nur_task_Dateien_werden_uebernommen() + { + Write("task_video.md", "# Task: Video-Zusammenfassung\n## Status: ASSIGNED\nAnalysiere das Video."); + Write("status_senior.md", "# Senior Developer - Status\n## Status: ONLINE"); + Write("broadcast.md", "# Broadcast\nBitte meldet euren Status."); + Write("incident_x.md", "# Incident Report\nProblem-Chain ..."); + Write("data.json", "{ \"foo\": 1 }"); + + var migrated = await _migration.RunAsync(default); + + migrated.ShouldBe(1); + var tasks = await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default); + tasks.Count.ShouldBe(1); + tasks[0].Title.ShouldBe("Video-Zusammenfassung"); + tasks[0].Status.ShouldBe(TaskItemStatus.Backlog, "erst nach menschlicher Sichtung bereit"); + tasks[0].Assignee.ShouldBe(TaskAssignee.Human); + tasks[0].Body.ShouldContain("Analysiere das Video."); + } + + [Fact] + public async Task Die_uebernommene_Datei_wandert_nach_migrated_und_bleibt_erhalten() + { + Write("task_a.md", "# Task: Etwas\nInhalt."); + + await _migration.RunAsync(default); + + File.Exists(Path.Combine(_coordinationDir, "task_a.md")).ShouldBeFalse("aus der obersten Ebene entfernt"); + File.Exists(Path.Combine(_coordinationDir, "migrated", "task_a.md")).ShouldBeTrue("aber nicht gelöscht"); + } + + [Fact] + public async Task Nicht_Aufgaben_bleiben_unangetastet() + { + Write("status_x.md", "# Status\nONLINE"); + Write("report.json", "{}"); + + await _migration.RunAsync(default); + + File.Exists(Path.Combine(_coordinationDir, "status_x.md")).ShouldBeTrue(); + File.Exists(Path.Combine(_coordinationDir, "report.json")).ShouldBeTrue(); + (await _repo.CountAsync(default)).ShouldBe(0); + } + + [Fact] + public async Task Die_Migration_ist_idempotent() + { + Write("task_a.md", "# Task: Einmalig\nInhalt."); + + (await _migration.RunAsync(default)).ShouldBe(1); + (await _migration.RunAsync(default)).ShouldBe(0, "zweiter Lauf findet keine task_*-Datei mehr"); + + (await _repo.CountAsync(default)).ShouldBe(1, "keine Dublette"); + } + + [Fact] + public async Task Ohne_Task_Ueberschrift_kommt_der_Titel_aus_dem_Dateinamen() + { + Write("task_video_analysis_progress.md", "## Status: IN PROGRESS\nOhne echte Überschrift."); + + await _migration.RunAsync(default); + + var tasks = await _repo.ListAsync(new TaskQuery(), default); + tasks[0].Title.ShouldBe("video analysis progress"); + } + + [Fact] + public async Task Ohne_coordination_Verzeichnis_passiert_nichts() + { + Directory.Delete(_coordinationDir, recursive: true); + (await _migration.RunAsync(default)).ShouldBe(0); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskAssigneeTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskAssigneeTests.cs new file mode 100644 index 0000000..3d38fb6 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskAssigneeTests.cs @@ -0,0 +1,27 @@ +using ClawdDotNet.Core.Tasks; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Der Assignee bestimmt, wie eine fällige Aufgabe zu einem Lauf wird (T7). Die Deutung +/// muss eindeutig sein — davon hängt ab, ob ein Agent frisch oder mit Kontext läuft. +/// +public sealed class TaskAssigneeTests +{ + [Theory] + [InlineData("@human", TaskAssigneeKind.Human)] + [InlineData("@new", TaskAssigneeKind.New)] + [InlineData("@new:crawler", TaskAssigneeKind.New)] + [InlineData("@crawler", TaskAssigneeKind.Agent)] + public void Die_Art_wird_richtig_erkannt(string assignee, TaskAssigneeKind expected) + => TaskAssignee.KindOf(assignee).ShouldBe(expected); + + [Theory] + [InlineData("@crawler", "crawler")] + [InlineData("@new:crawler", "crawler")] + [InlineData("@new", "")] // frischer Lauf ohne benannten Agenten + [InlineData("@human", "")] + public void Die_Agent_Id_wird_richtig_herausgezogen(string assignee, string expected) + => TaskAssignee.AgentId(assignee).ShouldBe(expected); +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskFrontmatterTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskFrontmatterTests.cs new file mode 100644 index 0000000..b6b5317 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskFrontmatterTests.cs @@ -0,0 +1,188 @@ +using ClawdDotNet.Core.Tasks; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Der Frontmatter-Parser ist die Brücke zwischen der menschen-/agentenlesbaren +/// Task-Datei und dem DB-Zustand. Was er falsch liest, importiert das Board falsch — +/// deshalb steht hier der Rundlauf im Mittelpunkt: Serialisieren und wieder Einlesen +/// muss dieselbe Definition ergeben. +/// +public sealed class TaskFrontmatterTests +{ + [Fact] + public void Ein_vollstaendiges_Frontmatter_wird_gelesen() + { + var text = """ + --- + id: t-8f3a2c + title: NVDA Earnings recherchieren + status: todo + type: work + priority: 4 + assignee: "@crawler" + when: + kind: cron + value: "0 7 * * 1-5" + tz: Europe/Berlin + require_approval: true + acceptance: | + Aktuelle Zahlen mit Datum und Quelle. + In SharedWorkspace/data/nvda.json abgelegt. + blocked_by: [t-4b1e, t-9a2c] + onlyWhenMarketOpen: false + --- + + Recherchiere die neuesten Quartalszahlen. + """; + + TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error); + + task.Id.ShouldBe("t-8f3a2c"); + task.Title.ShouldBe("NVDA Earnings recherchieren"); + task.Status.ShouldBe(TaskItemStatus.Todo); + task.Type.ShouldBe(TaskItemType.Work); + task.Priority.ShouldBe(4); + task.Assignee.ShouldBe("@crawler"); + task.When.ShouldNotBeNull(); + task.When!.Kind.ShouldBe(TaskWhenKind.Cron); + task.When.Value.ShouldBe("0 7 * * 1-5"); + task.When.TimeZone.ShouldBe("Europe/Berlin"); + task.RequireApproval.ShouldBeTrue(); + task.Acceptance.ShouldContain("nvda.json"); + task.Acceptance.ShouldContain("Aktuelle Zahlen"); + task.BlockedBy.ShouldBe(["t-4b1e", "t-9a2c"]); + task.OnlyWhenMarketOpen.ShouldBeFalse(); + task.Body.ShouldBe("Recherchiere die neuesten Quartalszahlen."); + } + + [Fact] + public void Serialisieren_und_wieder_Einlesen_ergibt_dieselbe_Definition() + { + var original = new TaskItem + { + Id = "t-abc123", + Title = "Titel mit: Doppelpunkt und # Raute", + Status = TaskItemStatus.InReview, + Type = TaskItemType.Approval, + Priority = 5, + Assignee = "@human", + When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" }, + RequireApproval = true, + Acceptance = "Zeile eins\nZeile zwei", + BlockedBy = ["t-1", "t-2"], + OnlyWhenMarketOpen = true, + Body = "Mehrzeiliger\nRumpf mit Umlauten äöü und 🦀." + }; + + var text = TaskFrontmatter.Serialize(original); + TaskFrontmatter.TryParse(text, out var round, out var error).ShouldBeTrue(error); + + round.Id.ShouldBe(original.Id); + round.Title.ShouldBe(original.Title); + round.Status.ShouldBe(original.Status); + round.Type.ShouldBe(original.Type); + round.Priority.ShouldBe(original.Priority); + round.Assignee.ShouldBe(original.Assignee); + round.When!.Kind.ShouldBe(TaskWhenKind.Every); + round.When.Value.ShouldBe("30m"); + round.When.TimeZone.ShouldBe("UTC"); + round.RequireApproval.ShouldBeTrue(); + round.Acceptance.ShouldBe(original.Acceptance); + round.BlockedBy.ShouldBe(["t-1", "t-2"]); + round.OnlyWhenMarketOpen.ShouldBeTrue(); + round.Body.ShouldBe(original.Body); + } + + [Fact] + public void Ein_Assignee_mit_At_ueberlebt_den_Rundlauf() + { + // In YAML ist ein führendes '@' reserviert — ohne Quotierung ginge es verloren. + var task = new TaskItem { Id = "t-1", Title = "x", Assignee = "@new" }; + + var text = TaskFrontmatter.Serialize(task); + TaskFrontmatter.TryParse(text, out var round, out _).ShouldBeTrue(); + + round.Assignee.ShouldBe("@new"); + TaskAssignee.KindOf(round.Assignee).ShouldBe(TaskAssigneeKind.New); + } + + [Fact] + public void Ohne_when_ist_es_eine_einmalige_Aufgabe() + { + var text = """ + --- + id: t-1 + title: Einmalig + assignee: "@new" + --- + Mach das einmal. + """; + + TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue(); + task.When.ShouldBeNull(); + } + + [Fact] + public void Fehlende_Felder_fallen_auf_sichere_Standards_zurueck() + { + var text = """ + --- + id: t-1 + title: Minimal + --- + """; + + TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue(); + + task.Status.ShouldBe(TaskItemStatus.Todo); + task.Type.ShouldBe(TaskItemType.Work); + task.Priority.ShouldBe(3); + task.Assignee.ShouldBe(TaskAssignee.Human, "ohne Zuordnung wartet die Aufgabe auf einen Menschen"); + task.RequireApproval.ShouldBeFalse(); + task.BlockedBy.ShouldBeEmpty(); + } + + [Fact] + public void Eine_unbekannte_Terminart_wird_verworfen_statt_geraten() + { + var text = """ + --- + id: t-1 + title: x + when: + kind: phantasie + value: irgendwas + tz: UTC + --- + """; + + TaskFrontmatter.TryParse(text, out var task, out _).ShouldBeTrue(); + task.When.ShouldBeNull(); + } + + [Fact] + public void Fehlt_der_Frontmatter_Block_meldet_der_Parser_das() + { + TaskFrontmatter.TryParse("Nur Text, kein Frontmatter.", out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + } + + [Fact] + public void Ein_nicht_geschlossener_Block_wird_gemeldet() + { + var text = "---\nid: t-1\ntitle: x\n"; + TaskFrontmatter.TryParse(text, out _, out var error).ShouldBeFalse(); + error.ShouldNotBeNull(); + } + + [Fact] + public void Windows_Zeilenenden_stoeren_nicht() + { + var text = "---\r\nid: t-1\r\ntitle: Mit CRLF\r\n---\r\nRumpf\r\n"; + TaskFrontmatter.TryParse(text, out var task, out var error).ShouldBeTrue(error); + task.Title.ShouldBe("Mit CRLF"); + task.Body.ShouldBe("Rumpf"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs new file mode 100644 index 0000000..5671112 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskRepositoryTests.cs @@ -0,0 +1,310 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// A1 Taskboard: der Ausführungszustand. Getestet gegen eine echte SQLite-Datei, weil es +/// gerade um Schema, Sperrverhalten und atomares Claiming geht — eine Attrappe würde +/// genau das verstecken, worauf es ankommt. +/// +/// Die drei Invarianten aus dem Taskboard-Konzept stehen im Mittelpunkt: nie zwei Claims +/// auf einen Termin, kein Dispatch bei offenem Blocker, doppelter Tick = ein Lauf. +/// +public sealed class TaskRepositoryTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + + public TaskRepositoryTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private Task Seed( + string id, TaskItemStatus status = TaskItemStatus.Todo, + string assignee = "@new", int priority = 3, + string title = "Aufgabe", IReadOnlyList? blockedBy = null) + => _repo.UpsertAsync(new TaskItem + { + Id = id, + Title = title, + Status = status, + Assignee = assignee, + Priority = priority, + BlockedBy = blockedBy ?? [] + }, default); + + private static string Occ(DateTime t) => t.ToUniversalTime().ToString("O"); + private static readonly DateTime T0 = new(2026, 7, 31, 7, 0, 0, DateTimeKind.Utc); + private static DateTime Now => DateTime.UtcNow; + private static DateTime NoStaleClaims => Now.AddHours(-1); // Lease-Cutoff weit in der Vergangenheit + + // ═══════════════════════════════════════════════════════════ + // Grundfunktionen: Import (Upsert), Lesen, Auflisten + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Eine_Aufgabe_wird_angelegt_und_wiedergefunden() + { + await Seed("t-1", title: "Recherche"); + + var found = await _repo.GetAsync("t-1", default); + + found.ShouldNotBeNull(); + found!.Title.ShouldBe("Recherche"); + found.Status.ShouldBe(TaskItemStatus.Todo); + } + + [Fact] + public async Task Der_Import_ist_idempotent_und_erzeugt_keine_Dublette() + { + await Seed("t-1", title: "Erste Fassung"); + await Seed("t-1", title: "Zweite Fassung"); + + (await _repo.CountAsync(default)).ShouldBe(1); + (await _repo.GetAsync("t-1", default))!.Title.ShouldBe("Zweite Fassung"); + } + + [Fact] + public async Task Ein_Re_Import_laesst_den_Ausfuehrungszustand_unangetastet() + { + // Der springende Punkt der Wahrheitsaufteilung: Die Datei ist Wahrheit über die + // Definition, die DB über den Ausführungszustand. Ein erneuter Import darf einen + // laufenden oder abgeschlossenen Zustand nicht zurücksetzen. + await Seed("t-1"); + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); + await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Done, Now, default); + + // Datei wird erneut importiert (Titel geändert, Status im Frontmatter noch "todo"). + await _repo.UpsertAsync(new TaskItem { Id = "t-1", Title = "geändert", Status = TaskItemStatus.Todo }, default); + + var after = await _repo.GetAsync("t-1", default); + after!.Title.ShouldBe("geändert", "die Definition wird übernommen"); + after.Status.ShouldBe(TaskItemStatus.Done, "der Ausführungszustand bleibt"); + after.LastOccurrence.ShouldBe(Occ(T0), "der Marker bleibt erhalten"); + } + + [Fact] + public async Task Die_Liste_filtert_nach_Status_und_blendet_Archiviertes_aus() + { + await Seed("t-1", TaskItemStatus.Todo); + await Seed("t-2", TaskItemStatus.Done); + await Seed("t-3", TaskItemStatus.Archived); + + (await _repo.ListAsync(new TaskQuery { Status = TaskItemStatus.Todo }, default)).Count.ShouldBe(1); + (await _repo.ListAsync(new TaskQuery(), default)).Count.ShouldBe(2, "Archiviertes ist standardmäßig ausgeblendet"); + (await _repo.ListAsync(new TaskQuery { IncludeArchived = true }, default)).Count.ShouldBe(3); + } + + [Fact] + public async Task Die_Liste_ordnet_nach_Prioritaet() + { + await Seed("t-low", priority: 1, title: "nebensächlich"); + await Seed("t-high", priority: 5, title: "dringend"); + await Seed("t-mid", priority: 3, title: "mittel"); + + var list = await _repo.ListAsync(new TaskQuery(), default); + + list[0].Title.ShouldBe("dringend"); + list[^1].Title.ShouldBe("nebensächlich"); + } + + [Fact] + public async Task Die_Liste_filtert_nach_Assignee_und_Freitext() + { + await Seed("t-1", assignee: "@crawler", title: "NVDA Zahlen"); + await Seed("t-2", assignee: "@analyst", title: "TSLA Bericht"); + + (await _repo.ListAsync(new TaskQuery { Assignee = "@crawler" }, default)).Count.ShouldBe(1); + (await _repo.ListAsync(new TaskQuery { Search = "nvda" }, default)).Count.ShouldBe(1); + } + + // ═══════════════════════════════════════════════════════════ + // Invariante 1 & 3: nie zwei Claims, doppelter Tick = ein Lauf + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ein_Termin_laesst_sich_genau_einmal_beanspruchen() + { + await Seed("t-1"); + + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok-a", Now, NoStaleClaims, default)).ShouldBeTrue(); + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok-b", Now, NoStaleClaims, default)) + .ShouldBeFalse("der Termin ist bereits beansprucht"); + } + + [Fact] + public async Task Gleichzeitige_Claims_auf_denselben_Termin_ergeben_genau_einen_Gewinner() + { + // Die Kern-Invariante. Mehrfach wiederholt, weil ein Race sporadisch auftritt. + for (var round = 0; round < 25; round++) + { + var id = $"t-race-{round}"; + await Seed(id); + + var attempts = Enumerable.Range(0, 32) + .Select(i => _repo.TryClaimAsync(id, Occ(T0), $"tok-{i}", Now, NoStaleClaims, default)); + + var results = await Task.WhenAll(attempts); + + results.Count(won => won).ShouldBe(1, $"Runde {round}: genau ein Lauf darf den Termin ziehen"); + } + } + + [Fact] + public async Task Ein_zweiter_Tick_auf_denselben_Termin_loest_keinen_zweiten_Lauf_aus() + { + await Seed("t-1"); + + // Erster Tick beansprucht und schließt ab. + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); + await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); + + // Zweiter Tick im selben Terminfenster: derselbe Occurrence-Key. + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default)) + .ShouldBeFalse("derselbe Termin darf nach Abschluss nicht erneut laufen"); + } + + [Fact] + public async Task Ein_gescheiterter_Lauf_wird_nicht_automatisch_wiederholt() + { + // Kein Retry-Sturm: Der Marker steht schon beim Claim, nicht erst beim Erfolg. + await Seed("t-1"); + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); + + // Lauf scheitert → zurück auf todo, aber derselbe Termin bleibt verbraucht. + await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); + + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok2", Now, NoStaleClaims, default)) + .ShouldBeFalse("derselbe Termin wird nicht erneut versucht"); + } + + [Fact] + public async Task Der_naechste_planmaessige_Termin_laesst_sich_wieder_beanspruchen() + { + await Seed("t-1"); + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); + await _repo.CompleteClaimAsync("t-1", "tok", TaskItemStatus.Todo, Now, default); + + // Ein späterer Occurrence-Key liegt über dem Marker → wieder fällig. + var later = Occ(T0.AddDays(1)); + (await _repo.TryClaimAsync("t-1", later, "tok3", Now, NoStaleClaims, default)) + .ShouldBeTrue("ein neuer Termin darf laufen"); + } + + // ═══════════════════════════════════════════════════════════ + // Invariante 2: kein Dispatch bei offenem Blocker + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Eine_blockierte_Aufgabe_laesst_sich_nicht_beanspruchen() + { + await Seed("t-1", TaskItemStatus.Blocked, blockedBy: ["t-0"]); + + (await _repo.TryClaimAsync("t-1", Occ(T0), "tok", Now, NoStaleClaims, default)) + .ShouldBeFalse("solange der Blocker offen ist, läuft nichts"); + } + + [Fact] + public async Task Wartende_Aufgaben_lassen_sich_ueber_ihren_Blocker_finden() + { + await Seed("t-a", blockedBy: ["t-blocker"]); + await Seed("t-b", blockedBy: ["t-blocker", "t-anderer"]); + await Seed("t-c", blockedBy: ["t-blocker10"]); + + var waiting = await _repo.ListBlockedByAsync("t-blocker", default); + + waiting.Select(t => t.Id).OrderBy(x => x).ShouldBe(["t-a", "t-b"]); + waiting.ShouldNotContain(t => t.Id == "t-c", "t-blocker darf nicht t-blocker10 treffen"); + } + + // ═══════════════════════════════════════════════════════════ + // Claim-Abschluss, Lease und Reconciliation + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Ein_Lauf_ohne_gueltigen_Claim_kann_nichts_ueberschreiben() + { + await Seed("t-1"); + (await _repo.TryClaimAsync("t-1", Occ(T0), "echt", Now, NoStaleClaims, default)).ShouldBeTrue(); + + (await _repo.CompleteClaimAsync("t-1", "falsch", TaskItemStatus.Done, Now, default)) + .ShouldBeFalse("ein fremdes Token darf den Abschluss nicht setzen"); + (await _repo.CompleteClaimAsync("t-1", "echt", TaskItemStatus.Done, Now, default)) + .ShouldBeTrue(); + + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done); + } + + [Fact] + public async Task Verwaiste_Claims_werden_beim_Start_zurueckgesetzt() + { + await Seed("t-stuck"); + // Claim mit einem Zeitpunkt, der bereits weit zurückliegt (abgestürzter Lauf). + var longAgo = Now.AddMinutes(-30); + (await _repo.TryClaimAsync("t-stuck", Occ(T0), "tok", longAgo, longAgo.AddMinutes(-1), default)).ShouldBeTrue(); + + // Reconciliation: Claims älter als 5 Minuten gelten als verwaist. + var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default); + + reset.ShouldBe(1); + var after = await _repo.GetAsync("t-stuck", default); + after!.Status.ShouldBe(TaskItemStatus.Todo); + after.ClaimToken.ShouldBeNull(); + } + + [Fact] + public async Task Ein_frischer_Claim_wird_bei_der_Reconciliation_nicht_angetastet() + { + await Seed("t-live"); + (await _repo.TryClaimAsync("t-live", Occ(T0), "tok", Now, NoStaleClaims, default)).ShouldBeTrue(); + + var reset = await _repo.ReleaseStaleClaimsAsync(Now.AddMinutes(-5), Now, default); + + reset.ShouldBe(0, "ein laufender Lauf darf nicht abgeräumt werden"); + (await _repo.GetAsync("t-live", default))!.Status.ShouldBe(TaskItemStatus.InProgress); + } + + [Fact] + public async Task Der_Status_laesst_sich_direkt_setzen() + { + await Seed("t-1"); + (await _repo.SetStatusAsync("t-1", TaskItemStatus.Canceled, Now, default)).ShouldBeTrue(); + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Canceled); + } + + // ═══════════════════════════════════════════════════════════ + // Nebenläufigkeit und Persistenz + // ═══════════════════════════════════════════════════════════ + + [Fact] + public async Task Viele_Aufgaben_lassen_sich_gleichzeitig_importieren() + { + var imports = Enumerable.Range(0, 60).Select(i => Seed($"t-{i}")); + await Task.WhenAll(imports); + + (await _repo.CountAsync(default)).ShouldBe(60); + } + + [Fact] + public async Task Aufgaben_ueberdauern_das_Schliessen_der_Datenbank() + { + await Seed("t-1", title: "muss einen Neustart überleben"); + + var reopened = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); + + (await reopened.GetAsync("t-1", default))!.Title.ShouldBe("muss einen Neustart überleben"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs new file mode 100644 index 0000000..49602bc --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScannerTests.cs @@ -0,0 +1,248 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Der Scanner-Kern — der in der Roadmap für Opus 5/Fable markierte heikle Teil. Getestet +/// gegen echte SQLite (das atomare Claiming ist der Punkt) mit einer Attrappe für die +/// Ausführung, damit keine Engine nötig ist. Die drei Invarianten stehen im Mittelpunkt. +/// +public sealed class TaskScannerTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 7, 31, 12, 0, 0, TimeSpan.Zero)); + + public TaskScannerTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private TaskScanner Scanner(ITaskDispatcher dispatcher, IMarketCalendar? market = null) + => new(_repo, dispatcher, NullLoggerFactory.Instance, market, _clock); + + /// Eine sofort fällige Aufgabe (kein Termin, nie gelaufen). + private Task SeedDue( + string id, string assignee = "@new", + TaskItemStatus status = TaskItemStatus.Todo, + bool requireApproval = false, IReadOnlyList? blockedBy = null, + bool onlyWhenMarketOpen = false) + => _repo.UpsertAsync(new TaskItem + { + Id = id, + Title = id, + Assignee = assignee, + Status = status, + RequireApproval = requireApproval, + BlockedBy = blockedBy ?? [], + OnlyWhenMarketOpen = onlyWhenMarketOpen + }, default); + + // ─── Grundfluss ─── + + [Fact] + public async Task Eine_faellige_Aufgabe_wird_ausgefuehrt_und_abgeschlossen() + { + await SeedDue("t-1"); + var dispatcher = new FakeDispatcher(); + + var dispatched = await Scanner(dispatcher).ScanOnceAsync(default); + + dispatched.ShouldBe(1); + dispatcher.Dispatched.ShouldBe(["t-1"]); + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Done); + } + + [Fact] + public async Task Mit_require_approval_landet_das_Ergebnis_im_Review() + { + await SeedDue("t-1", requireApproval: true); + + await Scanner(new FakeDispatcher()).ScanOnceAsync(default); + + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.InReview); + } + + [Fact] + public async Task Ein_gescheiterter_Lauf_geht_zurueck_auf_todo_und_wird_nicht_wiederholt() + { + await SeedDue("t-1"); + var dispatcher = new FakeDispatcher(succeed: false); + var scanner = Scanner(dispatcher); + + await scanner.ScanOnceAsync(default); + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo); + + // Zweiter Takt: der Marker ist gesetzt, derselbe Termin läuft nicht erneut. + await scanner.ScanOnceAsync(default); + dispatcher.Dispatched.Count.ShouldBe(1, "kein Retry-Sturm für denselben Termin"); + } + + // ─── Invariante 2: kein Dispatch bei offenem Blocker / an Menschen ─── + + [Fact] + public async Task Eine_blockierte_Aufgabe_wird_nicht_angestossen() + { + await SeedDue("t-1", status: TaskItemStatus.Blocked, blockedBy: ["t-0"]); + var dispatcher = new FakeDispatcher(); + + (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); + dispatcher.Dispatched.ShouldBeEmpty(); + } + + [Fact] + public async Task Eine_Aufgabe_fuer_einen_Menschen_wird_nicht_angestossen() + { + await SeedDue("t-1", assignee: "@human"); + var dispatcher = new FakeDispatcher(); + + (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); + dispatcher.Dispatched.ShouldBeEmpty(); + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo, "sie wartet unverändert"); + } + + // ─── Invariante 3: doppelter Takt = ein Lauf ─── + + [Fact] + public async Task Zwei_gleichzeitige_Takte_stossen_eine_Aufgabe_nur_einmal_an() + { + for (var round = 0; round < 20; round++) + { + var id = $"t-{round}"; + await SeedDue(id); + var dispatcher = new FakeDispatcher(); + var scanner = Scanner(dispatcher); + + await Task.WhenAll( + scanner.ScanOnceAsync(default), + scanner.ScanOnceAsync(default)); + + dispatcher.Dispatched.Count(x => x == id).ShouldBe(1, $"Runde {round}"); + } + } + + // ─── Wiederkehrende Tasks (ersetzt den Alt-Scheduler) ─── + + [Fact] + public async Task Ein_wiederkehrender_Task_bleibt_nach_dem_Feuern_auf_todo() + { + _clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero)); + await _repo.UpsertAsync(new TaskItem + { + Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo, + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" } + }, default); + + await Scanner(new FakeDispatcher()).ScanOnceAsync(default); + + (await _repo.GetAsync("t-rec", default))!.Status.ShouldBe( + TaskItemStatus.Todo, "sonst liefe ein Cron-Task nur ein einziges Mal"); + } + + [Fact] + public async Task Ein_wiederkehrender_Task_feuert_an_jedem_Termin_erneut() + { + await _repo.UpsertAsync(new TaskItem + { + Id = "t-rec", Title = "täglich", Assignee = "@new", Status = TaskItemStatus.Todo, + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "UTC" } + }, default); + var dispatcher = new FakeDispatcher(); + var scanner = Scanner(dispatcher); + + _clock.SetUtcNow(new DateTimeOffset(2027, 1, 1, 9, 0, 0, TimeSpan.Zero)); + await scanner.ScanOnceAsync(default); + + _clock.SetUtcNow(new DateTimeOffset(2027, 1, 2, 9, 0, 0, TimeSpan.Zero)); + await scanner.ScanOnceAsync(default); + + dispatcher.Dispatched.Count(x => x == "t-rec").ShouldBe(2, "zwei Termine, zwei Läufe"); + } + + [Fact] + public async Task Ein_backlog_Task_wird_nicht_ausgefuehrt() + { + await SeedDue("t-1", status: TaskItemStatus.Backlog); + var dispatcher = new FakeDispatcher(); + + (await Scanner(dispatcher).ScanOnceAsync(default)).ShouldBe(0); + dispatcher.Dispatched.ShouldBeEmpty(); + } + + // ─── C1-Haken: Marktkalender ─── + + [Fact] + public async Task Bei_geschlossenem_Markt_bleibt_ein_marktabhaengiger_Termin_liegen() + { + await SeedDue("t-1", onlyWhenMarketOpen: true); + var dispatcher = new FakeDispatcher(); + + var scanner = Scanner(dispatcher, new ClosedMarket()); + (await scanner.ScanOnceAsync(default)).ShouldBe(0); + + dispatcher.Dispatched.ShouldBeEmpty(); + (await _repo.GetAsync("t-1", default))!.Status.ShouldBe(TaskItemStatus.Todo); + } + + // ─── Auto-Dispatch: Blocker fertig → Wartende frei ─── + + [Fact] + public async Task Wird_der_letzte_Blocker_fertig_gibt_das_die_wartende_Aufgabe_frei() + { + await SeedDue("t-blocker"); + await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-blocker"]); + + await Scanner(new FakeDispatcher()).ScanOnceAsync(default); + + (await _repo.GetAsync("t-blocker", default))!.Status.ShouldBe(TaskItemStatus.Done); + (await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe( + TaskItemStatus.Todo, "der Blocker ist erledigt, also ist sie jetzt bereit"); + } + + [Fact] + public async Task Solange_ein_Blocker_offen_ist_bleibt_die_wartende_Aufgabe_blockiert() + { + await SeedDue("t-b1"); // wird in diesem Takt fertig + await SeedDue("t-b2", assignee: "@human"); // ein Mensch — bleibt offen + await SeedDue("t-wartend", status: TaskItemStatus.Blocked, blockedBy: ["t-b1", "t-b2"]); + + await Scanner(new FakeDispatcher()).ScanOnceAsync(default); + + (await _repo.GetAsync("t-wartend", default))!.Status.ShouldBe( + TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt"); + } + + // ─── Attrappen ─── + + private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher + { + private readonly Lock _lock = new(); + public List Dispatched { get; } = []; + + public Task DispatchAsync(TaskItem task, CancellationToken ct) + { + lock (_lock) + Dispatched.Add(task.Id); + return Task.FromResult(succeed); + } + } + + private sealed class ClosedMarket : IMarketCalendar + { + public bool IsOpen(DateTime nowUtc) => false; + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskScheduleTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScheduleTests.cs new file mode 100644 index 0000000..908a095 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskScheduleTests.cs @@ -0,0 +1,246 @@ +using ClawdDotNet.Core.Tasks; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Die Terminberechnung — Antwort auf B6 (keine Delays, nur „was ist jetzt fällig") +/// und B7 (jeder Termin wird explizit in seiner Zeitzone gedeutet). Reine Funktion, +/// deshalb ohne DB und ohne Uhr-Attrappe: „jetzt" wird direkt übergeben. +/// +public sealed class TaskScheduleTests +{ + private static DateTime Utc(int y, int mo, int d, int h, int mi) + => new(y, mo, d, h, mi, 0, DateTimeKind.Utc); + + // ─── Einmalig, ohne Termin ─── + + [Fact] + public void Ohne_Termin_ist_die_Aufgabe_faellig_bis_sie_lief() + { + var task = new TaskItem { Id = "t-1", CreatedAt = Utc(2026, 7, 31, 6, 0) }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull(); + TaskSchedule.DueOccurrence(task with { LastOccurrence = "x" }, Utc(2026, 7, 31, 7, 0)).ShouldBeNull(); + } + + // ─── at: einmaliger Zeitpunkt ─── + + [Fact] + public void Ein_at_Termin_ist_erst_ab_dem_Zeitpunkt_faellig() + { + var task = new TaskItem + { + Id = "t-1", + When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" } + }; + + // 09:00 Berlin (Sommerzeit) = 07:00 UTC + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 59)).ShouldBeNull(); + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 0)).ShouldNotBeNull(); + } + + [Fact] + public void Ein_at_Termin_feuert_genau_einmal() + { + var task = new TaskItem + { + Id = "t-1", + LastOccurrence = "2026-07-31T07:00:00.0000000Z", + When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00", TimeZone = "Europe/Berlin" } + }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 12, 0)).ShouldBeNull(); + } + + [Fact] + public void Ein_at_Termin_mit_Z_wird_als_UTC_gedeutet() + { + var task = new TaskItem + { + Id = "t-1", + When = new TaskWhen { Kind = TaskWhenKind.At, Value = "2026-07-31T09:00:00Z", TimeZone = "Europe/Berlin" } + }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 59)).ShouldBeNull(); + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0)).ShouldNotBeNull(); + } + + // ─── every: Intervall ─── + + [Fact] + public void Ein_Intervall_wird_erst_nach_Ablauf_faellig() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 7, 0), + When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" } + }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 20)).ShouldBeNull(); + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 30)).ShouldNotBeNull(); + } + + [Fact] + public void Nach_dem_Marker_zaehlt_das_Intervall_neu() + { + var task = new TaskItem + { + Id = "t-1", + LastOccurrence = "2026-07-31T07:30:00.0000000Z", + When = new TaskWhen { Kind = TaskWhenKind.Every, Value = "30m", TimeZone = "UTC" } + }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 55)).ShouldBeNull(); + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 8, 0)).ShouldNotBeNull(); + } + + // ─── cron: mit Zeitzone (der Kern von B7) ─── + + [Fact] + public void Ein_Cron_Termin_wird_in_seiner_Zeitzone_gedeutet_Sommer() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 0, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" } + }; + + // 09:00 Berlin im Sommer (CEST, UTC+2) = 07:00 UTC + var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 7, 5)); + occ.ShouldBe("2026-07-31T07:00:00.0000000Z"); + } + + [Fact] + public void Derselbe_Cron_liegt_im_Winter_auf_einer_anderen_UTC_Stunde() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 1, 15, 0, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" } + }; + + // 09:00 Berlin im Winter (CET, UTC+1) = 08:00 UTC — genau das, was B7 verlangt. + var occ = TaskSchedule.DueOccurrence(task, Utc(2026, 1, 15, 8, 5)); + occ.ShouldBe("2026-01-15T08:00:00.0000000Z"); + } + + [Fact] + public void Vor_der_geplanten_Zeit_ist_der_Cron_nicht_faellig() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 5, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" } + }; + + // 06:00 UTC = 08:00 Berlin, vor der 09:00-Marke + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 6, 0)).ShouldBeNull(); + } + + [Fact] + public void Eine_frische_Cron_Aufgabe_holt_keinen_Termin_von_vor_ihrer_Anlage_nach() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 8, 0), // nach der heutigen 09:00-Berlin-Marke (07:00 UTC) + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" } + }; + + // Jetzt 10:00 UTC — die heutige 09:00-Marke lag vor der Anlage, also nicht nachholen. + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull(); + } + + [Fact] + public void Derselbe_Cron_Termin_feuert_nicht_zweimal() + { + var task = new TaskItem + { + Id = "t-1", + LastOccurrence = "2026-07-31T07:00:00.0000000Z", + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Europe/Berlin" } + }; + + // Später am selben Tag: der 09:00-Termin ist schon abgehakt. + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 10, 0)).ShouldBeNull(); + + // Der nächste Tag ist wieder fällig. + TaskSchedule.DueOccurrence(task, Utc(2026, 8, 1, 7, 5)).ShouldBe("2026-08-01T07:00:00.0000000Z"); + } + + [Fact] + public void Ein_kaputter_Cron_Ausdruck_kippt_den_Scanner_nicht() + { + var task = new TaskItem + { + Id = "t-1", + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "kein cron", TimeZone = "UTC" } + }; + + Should.NotThrow(() => TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 0))) + .ShouldBeNull(); + } + + /// + /// Früher fiel eine unbekannte Zone still auf UTC zurück. Das ist der schlechtere + /// Fehler: Ein Task für 09:00 Ortszeit lief im Sommer um 07:00, ohne dass irgendwo + /// etwas auffiel. Jetzt feuert er gar nicht — und der Scanner meldet einmal, warum. + /// + [Fact] + public void Eine_unbekannte_Zeitzone_laesst_den_Termin_nicht_faellig_werden() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 0, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "Phantasie/Ort" } + }; + + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)).ShouldBeNull(); + TaskSchedule.UnresolvableTimeZone(task).ShouldBe("Phantasie/Ort"); + } + + /// + /// Der Kern der Portierung: Dieselbe Zeitzone, zwei Schreibweisen. Eine Task-Datei, + /// die unter Windows entstanden ist, muss unter Linux dieselbe Feuerzeit ergeben — + /// und umgekehrt. + /// + [Theory] + [InlineData("Europe/Berlin")] + [InlineData("W. Europe Standard Time")] + public void Beide_Schreibweisen_derselben_Zone_ergeben_dieselbe_Feuerzeit(string tz) + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 0, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = tz } + }; + + TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull(); + + // 09:00 Ortszeit im Sommer (MESZ, UTC+2) = 07:00 UTC. + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)) + .ShouldBe("2026-07-31T07:00:00.0000000Z"); + } + + [Fact] + public void Keine_Zeitzonenangabe_bedeutet_weiterhin_UTC() + { + var task = new TaskItem + { + Id = "t-1", + CreatedAt = Utc(2026, 7, 31, 0, 0), + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 9 * * *", TimeZone = "" } + }; + + TaskSchedule.UnresolvableTimeZone(task).ShouldBeNull(); + TaskSchedule.DueOccurrence(task, Utc(2026, 7, 31, 9, 5)) + .ShouldBe("2026-07-31T09:00:00.0000000Z"); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs new file mode 100644 index 0000000..abe883d --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskboardServiceTests.cs @@ -0,0 +1,163 @@ +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Die Wahrheitsaufteilung in Aktion: Der Dienst hält Markdown-Datei (Definition) und +/// DB (Ausführungszustand) im Gleichschritt. Getestet gegen echtes Dateisystem und echte +/// SQLite — beide Seiten sind der Punkt. +/// +public sealed class TaskboardServiceTests : IDisposable +{ + private readonly string _directory; + private readonly string _tasksDir; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly TaskboardService _board; + + public TaskboardServiceTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _tasksDir = Path.Combine(_directory, "SharedWorkspace", "tasks"); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + _board = new TaskboardService(_repo, _tasksDir); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private static TaskItem Def(string title = "Aufgabe", string assignee = "@new") + => new() { Title = title, Assignee = assignee, Body = "Tu etwas." }; + + [Fact] + public async Task Anlegen_erzeugt_Datei_und_DB_Zeile_in_einem_Zug() + { + var created = await _board.CreateAsync(Def("NVDA recherchieren"), default); + + created.Id.ShouldNotBeNullOrWhiteSpace(); + created.Id.ShouldStartWith("t-"); + + // DB-Seite + (await _repo.GetAsync(created.Id, default)).ShouldNotBeNull(); + + // Datei-Seite: existiert und lässt sich zur selben Definition zurücklesen + var path = Path.Combine(_tasksDir, created.FileName); + File.Exists(path).ShouldBeTrue(); + + TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _).ShouldBeTrue(); + fromFile.Id.ShouldBe(created.Id); + fromFile.Title.ShouldBe("NVDA recherchieren"); + fromFile.Assignee.ShouldBe("@new"); + } + + [Fact] + public async Task Der_Dateiname_ist_beschreibend() + { + var created = await _board.CreateAsync(Def("NVDA Earnings recherchieren"), default); + created.FileName.ShouldStartWith("nvda-earnings-recherchieren-"); + created.FileName.ShouldEndWith(".md"); + } + + [Fact] + public async Task Aendern_schreibt_Datei_und_DB_fort() + { + var created = await _board.CreateAsync(Def("Alt"), default); + + var updated = await _board.UpdateAsync(created.Id, + c => c with { Title = "Neu", Status = TaskItemStatus.Done }, default); + + updated!.Title.ShouldBe("Neu"); + updated.Status.ShouldBe(TaskItemStatus.Done); + + // Beide Seiten spiegeln die Änderung + (await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done); + TaskFrontmatter.TryParse( + File.ReadAllText(Path.Combine(_tasksDir, created.FileName)), out var fromFile, out _); + fromFile.Title.ShouldBe("Neu"); + fromFile.Status.ShouldBe(TaskItemStatus.Done); + } + + [Fact] + public async Task Ein_Kommentar_landet_im_Rumpf() + { + var created = await _board.CreateAsync(Def(), default); + + await _board.AddCommentAsync(created.Id, "agent-a", "Erstes Zwischenergebnis.", default); + await _board.AddCommentAsync(created.Id, "agent-b", "Bitte nachbessern.", default); + + var body = (await _repo.GetAsync(created.Id, default))!.Body; + body.ShouldContain("Erstes Zwischenergebnis."); + body.ShouldContain("agent-b"); + body.ShouldContain("Bitte nachbessern."); + } + + [Fact] + public async Task Import_spiegelt_vorhandene_Dateien_in_die_DB() + { + Directory.CreateDirectory(_tasksDir); + File.WriteAllText(Path.Combine(_tasksDir, "eins.md"), + "---\nid: t-eins\ntitle: Eins\nassignee: \"@new\"\n---\nMach eins."); + File.WriteAllText(Path.Combine(_tasksDir, "zwei.md"), + "---\nid: t-zwei\ntitle: Zwei\nassignee: \"@new\"\n---\nMach zwei."); + + var imported = await _board.ImportAllAsync(default); + + imported.ShouldBe(2); + (await _repo.GetAsync("t-eins", default))!.Title.ShouldBe("Eins"); + (await _repo.GetAsync("t-zwei", default))!.Title.ShouldBe("Zwei"); + } + + [Fact] + public async Task Eine_von_Hand_angelegte_Datei_ohne_Id_bekommt_eine_und_behaelt_sie() + { + Directory.CreateDirectory(_tasksDir); + var path = Path.Combine(_tasksDir, "handarbeit.md"); + File.WriteAllText(path, "---\ntitle: Von Hand\nassignee: \"@human\"\n---\nManuell angelegt."); + + var imported = await _board.ImportFileAsync(path, default); + + imported.ShouldNotBeNull(); + imported!.Id.ShouldNotBeNullOrWhiteSpace(); + + // Die Id wurde in die Datei zurückgeschrieben — überlebt den nächsten Start. + TaskFrontmatter.TryParse(File.ReadAllText(path), out var fromFile, out _); + fromFile.Id.ShouldBe(imported.Id); + } + + [Fact] + public async Task Eine_verschwundene_Datei_wird_archiviert_statt_geloescht() + { + var created = await _board.CreateAsync(Def("Vergänglich"), default); + File.Delete(Path.Combine(_tasksDir, created.FileName)); + + await _board.ImportAllAsync(default); + + // Die Historie bleibt: die Zeile ist archiviert, nicht weg. + var after = await _repo.GetAsync(created.Id, default); + after.ShouldNotBeNull(); + after!.Status.ShouldBe(TaskItemStatus.Archived); + } + + [Fact] + public async Task Ein_Re_Import_setzt_einen_laufenden_Status_nicht_zurueck() + { + // Die Kernregel der Wahrheitsaufteilung über den Dienst geprüft. + var created = await _board.CreateAsync(Def(), default); + await _repo.TryClaimAsync(created.Id, "2026-07-31T07:00:00.0000000Z", "tok", + DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), default); + await _repo.CompleteClaimAsync(created.Id, "tok", TaskItemStatus.Done, DateTime.UtcNow, default); + + // Die Datei sagt weiterhin "todo" (so wurde sie angelegt) — ein Import darf das + // nicht über den erreichten Done-Status stülpen. + await _board.ImportAllAsync(default); + + (await _repo.GetAsync(created.Id, default))!.Status.ShouldBe(TaskItemStatus.Done); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/ToolJobDispatchTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/ToolJobDispatchTests.cs new file mode 100644 index 0000000..d707b00 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/ToolJobDispatchTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tests.Infrastructure; +using ClawdDotNet.Core.Tools; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Tasks; + +/// +/// Der tool_job-Dispatch — ersetzt den ToolJobScheduler. Ein fälliger Poll-Task tickt einen +/// und weckt den Agenten nur, wenn der Tick etwas meldet. +/// +public sealed class ToolJobDispatchTests +{ + private static TaskItem PollTask(string agent = "agent-a") => new() + { + Id = "tj-1", + Title = "Poll", + Type = TaskItemType.ToolJob, + ToolName = "PollTool", + JobTypeId = "poll", + Assignee = "@" + agent + }; + + private static EngineTaskDispatcher Dispatcher(EngineFixture fixture) + => new(fixture.Engine, () => fixture.Agents, "test-instance", + fixture.Registry, fixture.StateStore, NullLoggerFactory.Instance); + + [Fact] + public async Task Ein_Poll_ohne_Fund_weckt_den_Agenten_nicht() + { + var tool = new FakeJobTool(ToolJobResult.NoAction("nichts Neues")); + var fixture = new EngineFixture().WithTool(tool); + fixture.AddAgent("agent-a", "PollTool"); + + var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default); + + ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler"); + tool.Ticks.ShouldBe(1); + fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war"); + } + + [Fact] + public async Task Ein_Poll_mit_Fund_weckt_den_Agenten() + { + var tool = new FakeJobTool(ToolJobResult.Wake("Neue Nachricht eingetroffen")); + var fixture = new EngineFixture().WithTool(tool); + fixture.AddAgent("agent-a", "PollTool"); + fixture.Client.RespondsWithText("verarbeitet"); + + var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default); + + ok.ShouldBeTrue(); + tool.Ticks.ShouldBe(1); + fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt"); + } + + [Fact] + public async Task Ein_unbekanntes_Poll_Tool_meldet_einen_Fehler() + { + var fixture = new EngineFixture(); // PollTool nicht registriert + fixture.AddAgent("agent-a"); + + (await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse(); + } + + /// Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis. + private sealed class FakeJobTool(ToolJobResult result) : IAgentTool, IToolJobProvider + { + public int Ticks { get; private set; } + + public string Name => "PollTool"; + public string Description => "Test-Poll"; + public JsonElement InputSchema { get; } = + JsonDocument.Parse("""{ "type": "object" }""").RootElement.Clone(); + + public Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) + => Task.FromResult(ToolResult.Ok("ok")); + + public IReadOnlyList GetJobDefinitions() => []; + + public Task ExecuteJobAsync( + string jobTypeId, IReadOnlyDictionary toolConfig, IStateStore stateStore, + ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null) + { + Ticks++; + return Task.FromResult(result); + } + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs index 5bb51c5..812b55e 100644 --- a/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs @@ -9,8 +9,13 @@ namespace ClawdDotNet.Tools.Tests.FileRW; /// "…\Workspace-Backup\…". /// /// Das Dateisystem wird hier bewusst NICHT abstrahiert — die Tests sollen die echte -/// Windows-Pfadsemantik prüfen (.., UNC, Alternate Data Streams, abschließende Punkte). -/// Eine Abstraktion würde genau die Fehlerklasse verstecken, um die es geht. +/// Pfadsemantik der jeweiligen Plattform prüfen (.., UNC, Alternate Data Streams, +/// abschließende Punkte, unter Linux zusätzlich Groß-/Kleinschreibung und +/// Verknüpfungen). Eine Abstraktion würde genau die Fehlerklasse verstecken, um die es +/// geht. +/// +/// Die Verzeichnisgrenze selbst prüft PathBoundaryTests in Core.Tests; hier geht +/// es um das, was darüber hinaus abweist. /// public sealed class WorkspacePathTests : IDisposable { @@ -45,6 +50,13 @@ public sealed class WorkspacePathTests : IDisposable Should.Throw(() => Resolve(path)); } + /// + /// Die Windows-Formen müssen auch unter Linux abgewiesen werden — dort greift + /// aber eine andere Regel: Path.IsPathRooted(@"C:\temp\x") ist unter Linux + /// false, weil das schlicht ein Dateiname mit Doppelpunkt ist. Gefangen + /// werden die Fälle dann von der Doppelpunkt- bzw. der \\-Prüfung. + /// Das Ergebnis ist auf beiden Plattformen dasselbe, der Weg dorthin nicht. + /// [Theory] [InlineData(@"C:\Windows\System32\config\SAM")] [InlineData(@"\\server\share\evil.txt")] @@ -55,6 +67,20 @@ public sealed class WorkspacePathTests : IDisposable Should.Throw(() => Resolve(path)); } + /// + /// Die Linux-Entsprechungen. Unter Windows sind das gewöhnliche relative Pfade, + /// die entweder ins Leere zeigen oder von der ..-Regel gefangen werden — + /// deshalb laufen sie dort mit, statt übersprungen zu werden. + /// + [Theory] + [InlineData("/etc/passwd")] + [InlineData("/root/.ssh/id_rsa")] + [InlineData("../../../../etc/shadow")] + public void Unix_Systempfade_werden_abgelehnt(string path) + { + Should.Throw(() => Resolve(path)); + } + [Theory] [InlineData("datei.txt:versteckt")] [InlineData("datei.txt:$DATA")]