From 49f5420d056a6af1162ea91f80da8033d2bb9d58 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:10:02 +0200 Subject: [PATCH 1/5] Collapse duplicated code across the solution A repo-wide clone scan plus a semantic pass found ~85 verified places where the same logic existed more than once. This removes them, mostly by routing callers through a helper or base type that already existed. Backend: - Five retention services shared a byte-identical ExecuteAsync loop; they now derive from LeaderGatedRetentionService. The broad catch stays in each service's RunIterationAsync, one level below the host-fatal boundary. - fileOperation/folderOperation share FileSystemOperationActivityBase; the marker-envelope PostProcess preamble is now one helper used by seven activities. - The AES-GCM envelope (version byte + nonce + ciphertext + tag) had two independent implementations; both now use SecretEnvelope. Header validation stays outside the metrics scope, as before. - Crypto call/latency instrumentation collapses into DataMetrics.MeasureCrypto. - The ExecRow projection, the long-running/queued-long collectors, the NotificationDispatcher send tail, CsvField, the trigger manual.* extraction and the folder-tree helpers each exist once now. - ScimProvisioningService now forwards audits through AuditEventForwarder instead of hand-rolling the ECS scope. This fixes a real inconsistency: it hard-coded event.category=iam where the shared classifier returns "configuration" for SCIM_GROUP_* codes. Clients: - ApiException, the response plumbing and the config.json read side move to NodePilot.Core.Clients, shared by the CLI and the MCP server. The DTO copies stay duplicated on purpose (ApiDtoParityTests). Frontend: - The AI chat message mutators and the thread picker were duplicated between the designer panel and the global chat page; both now share one module. - LoggingTelemetrySection carried a private fork of useSectionForm. The fork had drifted and was the better version (it re-sends the mapped payload after a 412), so that behaviour moved into the shared hook before the fork was removed. - useDashboardFeed/useOperationsFeed, the type-the-phrase confirm dialog and the node context menu now use shared implementations. Dead code: three unused DbErrorClassifier predicates, a deprecated cloneableConfigKeys alias, four unused frontend exports, two orphaned stress scripts and an unused npm dependency. Test doubles: FakeSmtpServer, CapturingLogger, StubHttpClientFactory, StubGlobalVariableStore and FakeEngine each existed in two places; they now live in TestCommons or a shared Helpers file. ActivityConfigReferenceTests learned to follow an executor's base class and helpers, so config keys that moved into shared types stay verified instead of being exempted. Net effect: 1407 fewer lines of code. Full backend suites (5486 tests) and the frontend suite (2596 tests) pass; the solution builds clean. --- scripts/stress-test-100-executions.py | 274 ---------- scripts/stress-test-master-workflow.py | 276 ---------- src/NodePilot.Ai/ChatToolDispatch.cs | 69 +++ src/NodePilot.Ai/ChatToolRegistry.cs | 44 +- .../Knowledge/DocsKnowledgeReader.cs | 41 +- .../Knowledge/KnowledgeChatToolRegistry.cs | 44 +- .../Knowledge/KnowledgeCorpusReader.cs | 58 ++ .../Knowledge/KnowledgeTimeContext.cs | 21 +- .../Knowledge/SourceCodeKnowledgeReader.cs | 49 +- src/NodePilot.Ai/LlmConfiguredProxy.cs | 7 +- src/NodePilot.Ai/LlmJson.cs | 20 + src/NodePilot.Ai/LlmProfileValidation.cs | 26 +- src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs | 66 +-- src/NodePilot.Ai/OpenAiResponsesLlmClient.cs | 21 +- src/NodePilot.Ai/ToolCallAccumulator.cs | 7 + src/NodePilot.Ai/WorkflowAssistantService.cs | 22 +- .../Controllers/AiChatController.cs | 57 +- src/NodePilot.Api/Controllers/AiController.cs | 54 +- .../Controllers/AiKnowledgeController.cs | 65 +-- .../Controllers/AiStreamSupport.cs | 79 +++ .../Controllers/AlertingController.cs | 81 +-- .../Controllers/AlertingRuleMapping.cs | 165 ++++++ src/NodePilot.Api/Controllers/ApiProblems.cs | 33 -- .../Controllers/AuditController.cs | 29 +- .../Controllers/AuthController.cs | 272 ++++------ .../Controllers/CustomActivitiesController.cs | 96 ++-- .../Controllers/DashboardController.cs | 17 +- .../Controllers/DbAdminController.cs | 27 +- .../Controllers/DiagnosticsController.cs | 31 +- .../Controllers/ExecutionsController.cs | 9 +- .../Controllers/ExternalTriggerController.cs | 34 +- .../Controllers/FolderScopedQueries.cs | 34 ++ .../Controllers/OperationsController.cs | 13 +- .../Controllers/SystemAlertingController.cs | 77 +-- .../Controllers/WorkflowEditingController.cs | 250 ++++----- .../WorkflowImportExportController.cs | 9 +- .../Controllers/WorkflowsController.cs | 26 +- src/NodePilot.Api/Export/CsvWriter.cs | 29 + .../Security/DirectoryGroupPrincipal.cs | 38 ++ .../Security/DirectoryMembershipReconciler.cs | 72 +++ .../Ldap/DirectorySynchronizationService.cs | 25 +- .../Security/Ldap/ExternalUserMapper.cs | 42 +- .../Security/Oidc/OidcIdentityMapper.cs | 66 +-- .../Security/ResourceAuthorizationService.cs | 32 +- .../Security/Scim/ScimProvisioningService.cs | 28 +- .../SubWorkflowAuthorizationResolver.cs | 16 +- .../Services/Backup/BackupRestoreService.cs | 510 +++++++++--------- .../Services/Backup/FolderTreeShape.cs | 84 +++ .../Services/Backup/Parts/FolderBackupPart.cs | 14 +- .../Parts/GlobalVariableFolderBackupPart.cs | 17 +- .../WorkflowDefinitionSecretRewriter.cs | 24 +- src/NodePilot.Cli/Api/ApiException.cs | 45 -- src/NodePilot.Cli/Api/NodePilotApiClient.cs | 34 +- src/NodePilot.Cli/Api/TokenRefreshHandler.cs | 1 + src/NodePilot.Cli/CLAUDE.md | 2 + .../Commands/Auth/AuthCommands.cs | 1 + src/NodePilot.Cli/Commands/BaseCommand.cs | 1 + .../Commands/Config/ConfigCommands.cs | 1 + .../Workflow/WorkflowTriggerCommand.cs | 1 + src/NodePilot.Cli/Settings/ConfigStore.cs | 54 +- .../Clients}/ApiException.cs | 10 +- .../Clients/ApiResponseReader.cs | 43 ++ .../Clients/ClientConfigStore.cs} | 21 +- src/NodePilot.Data/DataMetrics.cs | 34 ++ src/NodePilot.Data/DbErrorClassifier.cs | 9 - .../Security/AesGcmSecretProtector.cs | 98 +--- .../Security/DpapiSecretProtector.cs | 59 +- .../Security/PassphraseSecretProtector.cs | 45 +- src/NodePilot.Data/Security/SecretEnvelope.cs | 83 +++ .../Activities/BaseActivity.cs | 52 +- .../Activities/FileHashActivity.cs | 13 +- .../Activities/FileOperationActivity.cs | 167 +----- .../FileSystemOperationActivityBase.cs | 182 +++++++ .../Activities/FolderOperationActivity.cs | 187 ++----- .../Activities/ForEachActivity.cs | 84 +-- .../Activities/JsonQueryActivity.cs | 46 +- .../Activities/QueryPayloadSource.cs | 58 ++ .../Activities/RegistryActivity.cs | 31 +- .../Activities/ReturnDataActivity.cs | 8 +- .../Activities/ScheduledTaskActivity.cs | 48 +- .../Activities/ServiceManagementActivity.cs | 44 +- .../Activities/StartProgramActivity.cs | 14 +- .../Activities/StartWorkflowActivity.cs | 101 ++-- .../Activities/SubWorkflowInvocation.cs | 123 +++++ .../Activities/TextFileEditActivity.cs | 14 +- .../Activities/XmlQueryActivity.cs | 43 +- .../Activities/ZipOperationActivity.cs | 13 +- .../Conditions/ConditionEvaluator.cs | 18 +- .../Debug/DebugCoordinator.cs | 4 +- src/NodePilot.Engine/Execution/StepRunner.cs | 5 +- .../Execution/VariableResolver.cs | 42 +- .../PowerShell/PowerShellOperation.cs | 23 + .../PowerShell/ProcessExecutionEngine.cs | 29 +- src/NodePilot.Engine/Scorch/ScorchImporter.cs | 25 +- .../Security/FileWatcherPathGuard.cs | 12 +- .../Security/OutputRedactor.cs | 19 + src/NodePilot.Engine/Security/PathGuard.cs | 35 +- .../Security/RestApiHttpClientProvider.cs | 12 +- .../Triggers/DatabaseTrigger.cs | 5 +- .../Triggers/EventLogTrigger.cs | 5 +- .../Triggers/FileWatcherTrigger.cs | 5 +- .../Triggers/ScheduleTrigger.cs | 11 +- .../Triggers/TriggerVariables.cs | 23 + .../Triggers/WebhookTrigger.cs | 5 +- src/NodePilot.Engine/WorkflowEngine.cs | 81 +-- src/NodePilot.Mcp/Api/NodePilotApiClient.cs | 34 +- src/NodePilot.Mcp/Auth/TokenStore.cs | 4 +- src/NodePilot.Mcp/CLAUDE.md | 2 + src/NodePilot.Mcp/Config/McpServerConfig.cs | 5 +- src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs | 1 + src/NodePilot.Mcp/Program.cs | 3 +- .../AuditLogRetentionService.cs | 71 +-- .../ExecutionRetentionService.cs | 79 +-- .../LeaderGatedRetentionService.cs | 135 +++++ .../NotificationDispatcher.cs | 33 +- .../NotificationRetentionService.cs | 74 +-- .../ElapsedExecutionCollector.cs | 123 +++++ .../Notifications/ExecutionEventCollector.cs | 24 +- .../Notifications/ExecutionEventSupport.cs | 28 + .../LongRunningExecutionCollector.cs | 100 +--- .../QueuedLongExecutionCollector.cs | 95 +--- .../SupportEventRetentionService.cs | 74 +-- .../WorkflowVersionsRetentionService.cs | 79 +-- src/nodepilot-ui/package.json | 1 - src/nodepilot-ui/src/api/backup.ts | 11 - .../admin-settings/DbAdminSection.tsx | 77 +-- .../LoggingTelemetrySection.tsx | 118 +--- .../admin-settings/SectionFormHelpers.tsx | 14 +- .../src/components/ai/AiWorkflowChatPanel.tsx | 198 +------ .../src/components/ai/ChatThreadMenu.tsx | 134 +++++ .../components/common/ContextMenuShell.tsx | 6 +- .../common/TypedPhraseConfirmDialog.tsx | 82 +++ .../src/components/dbviewer/QueryPane.tsx | 77 +-- .../components/designer/PropertiesPanel.tsx | 3 - .../designer/overlays/NodeContextMenu.tsx | 50 +- .../components/designer/properties/shared.tsx | 411 +++++++------- .../src/hooks/useDashboardFeed.ts | 87 +-- src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts | 95 ++++ .../src/hooks/useOperationsFeed.ts | 78 +-- src/nodepilot-ui/src/lib/chatMessages.ts | 74 +++ src/nodepilot-ui/src/lib/configClone.ts | 13 - src/nodepilot-ui/src/pages/AiChatPage.tsx | 195 +------ src/nodepilot-ui/src/stores/aiChatStore.ts | 5 - src/nodepilot-ui/src/stores/authStore.ts | 6 - src/nodepilot-ui/src/telemetry/otel.ts | 4 - .../LlmConnectGuardTests.cs | 11 - .../LlmUnreachableDiagnosticsTests.cs | 5 +- .../AdminSettingsControllerSectionTests.cs | 115 ---- .../Controllers/ApiProblemsTests.cs | 34 +- ...dePilotApiClientMaintenanceWindowsTests.cs | 1 + .../Api/NodePilotApiClientNewSurfaceTests.cs | 1 + .../Api/NodePilotApiClientResourcesTests.cs | 1 + .../Api/NodePilotApiClientTests.cs | 1 + .../Api/TokenRefreshHandlerTests.cs | 1 + .../Auth/SessionResolverTests.cs | 1 + .../Commands/BaseCommandTests.cs | 1 + .../Infra/CommandTestHarness.cs | 1 + .../Output/OutputAndRenderingTests.cs | 1 + .../Settings/ConfigStoreTests.cs | 1 + .../ActivityConfigReferenceTests.cs | 27 +- .../Activities/NonRemoteActivityTests.cs | 134 ----- .../RunScriptExecutionTargetTests.cs | 8 - .../Execution/StepTestContextProviderTests.cs | 39 +- .../Execution/StepTesterTests.cs | 22 +- .../Helpers/TestDoubles.cs | 70 +++ .../PowerShellEngineFactoryIsolationTests.cs | 8 +- tests/NodePilot.Mcp.Tests/Api/InfraTests.cs | 13 +- tests/NodePilot.Mcp.Tests/Infra/TestApi.cs | 3 +- tests/NodePilot.TestCommons/FakeSmtpServer.cs | 142 +++++ 169 files changed, 3784 insertions(+), 5189 deletions(-) delete mode 100644 scripts/stress-test-100-executions.py delete mode 100644 scripts/stress-test-master-workflow.py create mode 100644 src/NodePilot.Ai/ChatToolDispatch.cs create mode 100644 src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs create mode 100644 src/NodePilot.Ai/LlmJson.cs create mode 100644 src/NodePilot.Api/Controllers/AiStreamSupport.cs create mode 100644 src/NodePilot.Api/Controllers/AlertingRuleMapping.cs create mode 100644 src/NodePilot.Api/Controllers/FolderScopedQueries.cs create mode 100644 src/NodePilot.Api/Export/CsvWriter.cs create mode 100644 src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs create mode 100644 src/NodePilot.Api/Services/Backup/FolderTreeShape.cs delete mode 100644 src/NodePilot.Cli/Api/ApiException.cs rename src/{NodePilot.Mcp/Api => NodePilot.Core/Clients}/ApiException.cs (79%) create mode 100644 src/NodePilot.Core/Clients/ApiResponseReader.cs rename src/{NodePilot.Mcp/Config/ConfigStore.cs => NodePilot.Core/Clients/ClientConfigStore.cs} (57%) create mode 100644 src/NodePilot.Data/Security/SecretEnvelope.cs create mode 100644 src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs create mode 100644 src/NodePilot.Engine/Activities/QueryPayloadSource.cs create mode 100644 src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs create mode 100644 src/NodePilot.Engine/Triggers/TriggerVariables.cs create mode 100644 src/NodePilot.Scheduler/LeaderGatedRetentionService.cs create mode 100644 src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs create mode 100644 src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx create mode 100644 src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx create mode 100644 src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts create mode 100644 src/nodepilot-ui/src/lib/chatMessages.ts create mode 100644 tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs create mode 100644 tests/NodePilot.TestCommons/FakeSmtpServer.cs diff --git a/scripts/stress-test-100-executions.py b/scripts/stress-test-100-executions.py deleted file mode 100644 index 1825af40..00000000 --- a/scripts/stress-test-100-executions.py +++ /dev/null @@ -1,274 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Stress test: Launch 100 workflow executions in ~30 seconds and measure performance. -""" - -import requests -import json -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from statistics import mean, stdev, median -from datetime import datetime -import sys -import io - -# Force UTF-8 output -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -BASE_URL = "http://localhost:5000" -WORKFLOW_NAME = "Stress Test Simple" -NUM_EXECUTIONS = 100 -MAX_WORKERS = 10 # Concurrent requests - -# Global token cache -_auth_token = None -_token_lock = __import__('threading').Lock() - -def get_auth_token(): - """Get or refresh Bearer token with caching.""" - global _auth_token - - if _auth_token: - return _auth_token - - with _token_lock: - # Double-check pattern - if _auth_token: - return _auth_token - - login_url = f"{BASE_URL}/api/auth/login" - login_payload = { - "username": "admin", - "password": "admin123" - } - try: - resp = requests.post(login_url, json=login_payload, timeout=5) - if resp.status_code == 200: - data = resp.json() - _auth_token = data.get("token") - if _auth_token: - print(f"[✓] Auth token obtained") - return _auth_token - except Exception as e: - print(f"[WARN] Auth failed: {e}") - return None - -def create_auth_header(): - """Return auth header with cached token.""" - token = get_auth_token() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - -def import_test_workflow(): - """Create a simple test workflow if not already present.""" - print(f"[*] Creating test workflow '{WORKFLOW_NAME}'...") - - # Simple workflow with just a delay - workflow_def = { - "nodes": [ - { - "id": "delay", - "type": "activity", - "position": {"x": 100, "y": 100}, - "data": { - "label": "Delay 1s", - "activityType": "delay", - "config": {"seconds": 1} - } - } - ], - "edges": [] - } - - create_url = f"{BASE_URL}/api/workflows" - payload = { - "name": WORKFLOW_NAME, - "description": "Simple stress test workflow", - "definitionJson": json.dumps(workflow_def) - } - - headers = create_auth_header() - try: - resp = requests.post(create_url, json=payload, headers=headers, timeout=10) - if resp.status_code in [200, 201]: - print(f"[✓] Workflow created successfully") - return True - else: - print(f"[WARN] Create returned {resp.status_code}") - return False - except Exception as e: - print(f"[WARN] Create failed: {e}") - return False - -def get_workflow_id(): - """Get the workflow ID by name.""" - print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...") - list_url = f"{BASE_URL}/api/workflows" - headers = create_auth_header() - try: - resp = requests.get(list_url, headers=headers, timeout=5) - if resp.status_code == 200: - workflows = resp.json() - for wf in workflows: - if wf.get("name") == WORKFLOW_NAME: - wid = wf.get("id") - print(f"[✓] Found workflow ID: {wid}") - return wid - print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list") - except Exception as e: - print(f"[WARN] Lookup failed: {e}") - return None - -def execute_workflow(workflow_id, attempt_num): - """Execute a single workflow and record metrics.""" - execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute" - payload = { - "parameters": {}, - "timeoutSeconds": 300, - "debug": False - } - headers = create_auth_header() - - start = time.time() - try: - resp = requests.post(execute_url, json=payload, headers=headers, timeout=30) - elapsed = time.time() - start - status = resp.status_code - execution_id = None - - if status == 202: - try: - data = resp.json() - execution_id = data.get("executionId") - except: - pass - - return { - "attempt": attempt_num, - "status": status, - "elapsed_ms": elapsed * 1000, - "execution_id": execution_id, - "success": status == 202 - } - except Exception as e: - elapsed = time.time() - start - return { - "attempt": attempt_num, - "status": 0, - "elapsed_ms": elapsed * 1000, - "execution_id": None, - "success": False, - "error": str(e) - } - -def main(): - global _auth_token - - print("=" * 70) - print("NODEPILOT STRESS TEST: 100 Executions in 30 Seconds") - print("=" * 70) - print(f"[*] Start time: {datetime.now().isoformat()}") - print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers") - print() - - # Step 0: Get auth token first - _auth_token = get_auth_token() - if not _auth_token: - print("[!] ERROR: Could not obtain auth token. Aborting.") - sys.exit(1) - print() - - # Step 1: Import workflow - import_test_workflow() - time.sleep(1) - - # Step 2: Get workflow ID - workflow_id = get_workflow_id() - if not workflow_id: - print("[!] ERROR: Could not find or import workflow. Aborting.") - sys.exit(1) - - print() - print(f"[*] Starting execution burst at {datetime.now().isoformat()}...") - print() - - # Step 3: Launch execution burst - results = [] - start_time = time.time() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = [] - for i in range(NUM_EXECUTIONS): - future = executor.submit(execute_workflow, workflow_id, i + 1) - futures.append(future) - - completed = 0 - for future in as_completed(futures): - result = future.result() - results.append(result) - completed += 1 - if completed % 10 == 0: - print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched") - - total_time = time.time() - start_time - - print() - print("=" * 70) - print("PERFORMANCE METRICS") - print("=" * 70) - print() - - # Parse results - successful = [r for r in results if r["success"]] - failed = [r for r in results if not r["success"]] - response_times = [r["elapsed_ms"] for r in successful] - - print(f"Total Time: {total_time:.2f} seconds") - print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)") - print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}") - print() - - if response_times: - print("Response Time (ms):") - print(f" Min: {min(response_times):.1f}") - print(f" Max: {max(response_times):.1f}") - print(f" Mean: {mean(response_times):.1f}") - print(f" Median: {median(response_times):.1f}") - if len(response_times) > 1: - print(f" Stdev: {stdev(response_times):.1f}") - print() - - # Status code distribution - status_counts = {} - for r in results: - status = r["status"] - status_counts[status] = status_counts.get(status, 0) + 1 - - print("Status Code Distribution:") - for status in sorted(status_counts.keys()): - count = status_counts[status] - pct = 100 * count / NUM_EXECUTIONS - print(f" {status:3d}: {count:3d} ({pct:5.1f}%)") - print() - - # Throughput - throughput = NUM_EXECUTIONS / total_time - print(f"Throughput: {throughput:.1f} executions/second") - print() - - # Sample execution IDs - exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]] - if exec_ids: - print("Sample Execution IDs (first 5):") - for eid in exec_ids: - print(f" - {eid}") - print() - - print("=" * 70) - print(f"[*] End time: {datetime.now().isoformat()}") - print("=" * 70) - -if __name__ == "__main__": - main() diff --git a/scripts/stress-test-master-workflow.py b/scripts/stress-test-master-workflow.py deleted file mode 100644 index 2189728b..00000000 --- a/scripts/stress-test-master-workflow.py +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Stress test: Launch 100 executions of "Master Test — All Activities" in ~30 seconds. -This workflow tests all 24 activity types, all edge conditions, retry logic, etc. -""" - -import requests -import json -import time -from concurrent.futures import ThreadPoolExecutor, as_completed -from statistics import mean, stdev, median -from datetime import datetime -import sys -import io - -# Force UTF-8 output -sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - -BASE_URL = "http://localhost:5000" -WORKFLOW_NAME = "Master Test — All Activities" -NUM_EXECUTIONS = 100 -MAX_WORKERS = 10 # Concurrent requests - -# Global token cache -_auth_token = None -_token_lock = __import__('threading').Lock() - -def get_auth_token(): - """Get or refresh Bearer token with caching.""" - global _auth_token - - if _auth_token: - return _auth_token - - with _token_lock: - # Double-check pattern - if _auth_token: - return _auth_token - - login_url = f"{BASE_URL}/api/auth/login" - login_payload = { - "username": "admin", - "password": "admin123" - } - try: - resp = requests.post(login_url, json=login_payload, timeout=5) - if resp.status_code == 200: - data = resp.json() - _auth_token = data.get("token") - if _auth_token: - print(f"[✓] Auth token obtained") - return _auth_token - except Exception as e: - print(f"[WARN] Auth failed: {e}") - return None - -def create_auth_header(): - """Return auth header with cached token.""" - token = get_auth_token() - if token: - return {"Authorization": f"Bearer {token}"} - return {} - -def import_master_workflow(): - """Import the Master Test workflow from file.""" - print(f"[*] Importing master workflow '{WORKFLOW_NAME}'...") - - try: - with open('scripts/test-master-all-activities.json', 'r', encoding='utf-8') as f: - export_data = json.load(f) - except Exception as e: - print(f"[!] Failed to read workflow file: {e}") - return False - - # Extract first workflow from export - if "workflows" not in export_data or not export_data["workflows"]: - print(f"[!] No workflows in export file") - return False - - workflow = export_data["workflows"][0] - - create_url = f"{BASE_URL}/api/workflows" - payload = { - "name": workflow.get("name", WORKFLOW_NAME), - "description": workflow.get("description", ""), - "definitionJson": json.dumps(workflow.get("definition", {})) - } - - headers = create_auth_header() - try: - resp = requests.post(create_url, json=payload, headers=headers, timeout=10) - if resp.status_code in [200, 201]: - print(f"[✓] Workflow imported successfully") - return True - elif resp.status_code == 409: - print(f"[✓] Workflow already exists (conflict, using existing)") - return True - else: - print(f"[WARN] Import returned {resp.status_code}: {resp.text}") - return False - except Exception as e: - print(f"[WARN] Import failed: {e}") - return False - -def get_workflow_id(): - """Get the workflow ID by name.""" - print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...") - list_url = f"{BASE_URL}/api/workflows" - headers = create_auth_header() - try: - resp = requests.get(list_url, headers=headers, timeout=5) - if resp.status_code == 200: - workflows = resp.json() - for wf in workflows: - if wf.get("name") == WORKFLOW_NAME: - wid = wf.get("id") - print(f"[✓] Found workflow ID: {wid}") - return wid - print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list") - except Exception as e: - print(f"[WARN] Lookup failed: {e}") - return None - -def execute_workflow(workflow_id, attempt_num): - """Execute a single workflow and record metrics.""" - execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute" - payload = { - "parameters": {}, - "timeoutSeconds": 300, - "debug": False - } - headers = create_auth_header() - - start = time.time() - try: - resp = requests.post(execute_url, json=payload, headers=headers, timeout=30) - elapsed = time.time() - start - status = resp.status_code - execution_id = None - - if status == 202: - try: - data = resp.json() - execution_id = data.get("executionId") - except: - pass - - return { - "attempt": attempt_num, - "status": status, - "elapsed_ms": elapsed * 1000, - "execution_id": execution_id, - "success": status == 202 - } - except Exception as e: - elapsed = time.time() - start - return { - "attempt": attempt_num, - "status": 0, - "elapsed_ms": elapsed * 1000, - "execution_id": None, - "success": False, - "error": str(e) - } - -def main(): - global _auth_token - - print("=" * 70) - print("NODEPILOT STRESS TEST: Master Workflow — 100 Executions") - print("=" * 70) - print(f"[*] Start time: {datetime.now().isoformat()}") - print(f"[*] Workflow: {WORKFLOW_NAME}") - print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers") - print() - - # Step 0: Get auth token first - _auth_token = get_auth_token() - if not _auth_token: - print("[!] ERROR: Could not obtain auth token. Aborting.") - sys.exit(1) - print() - - # Step 1: Import workflow - import_master_workflow() - time.sleep(1) - - # Step 2: Get workflow ID - workflow_id = get_workflow_id() - if not workflow_id: - print("[!] ERROR: Could not find or import workflow. Aborting.") - sys.exit(1) - - print() - print(f"[*] Starting execution burst at {datetime.now().isoformat()}...") - print() - - # Step 3: Launch execution burst - results = [] - start_time = time.time() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = [] - for i in range(NUM_EXECUTIONS): - future = executor.submit(execute_workflow, workflow_id, i + 1) - futures.append(future) - - completed = 0 - for future in as_completed(futures): - result = future.result() - results.append(result) - completed += 1 - if completed % 10 == 0: - print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched") - - total_time = time.time() - start_time - - print() - print("=" * 70) - print("PERFORMANCE METRICS") - print("=" * 70) - print() - - # Parse results - successful = [r for r in results if r["success"]] - failed = [r for r in results if not r["success"]] - response_times = [r["elapsed_ms"] for r in successful] - - print(f"Total Time: {total_time:.2f} seconds") - print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)") - print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}") - print() - - if response_times: - print("Response Time (ms):") - print(f" Min: {min(response_times):.1f}") - print(f" Max: {max(response_times):.1f}") - print(f" Mean: {mean(response_times):.1f}") - print(f" Median: {median(response_times):.1f}") - if len(response_times) > 1: - print(f" Stdev: {stdev(response_times):.1f}") - print() - - # Status code distribution - status_counts = {} - for r in results: - status = r["status"] - status_counts[status] = status_counts.get(status, 0) + 1 - - print("Status Code Distribution:") - for status in sorted(status_counts.keys()): - count = status_counts[status] - pct = 100 * count / NUM_EXECUTIONS - print(f" {status:3d}: {count:3d} ({pct:5.1f}%)") - print() - - # Throughput - throughput = NUM_EXECUTIONS / total_time - print(f"Throughput: {throughput:.1f} executions/second") - print() - - # Sample execution IDs - exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]] - if exec_ids: - print("Sample Execution IDs (first 5):") - for eid in exec_ids: - print(f" - {eid}") - print() - - print("=" * 70) - print(f"[*] End time: {datetime.now().isoformat()}") - print("=" * 70) - -if __name__ == "__main__": - main() diff --git a/src/NodePilot.Ai/ChatToolDispatch.cs b/src/NodePilot.Ai/ChatToolDispatch.cs new file mode 100644 index 00000000..61758031 --- /dev/null +++ b/src/NodePilot.Ai/ChatToolDispatch.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace NodePilot.Ai; + +/// One tool implementation: raw arguments + the registry's per-request context. +internal delegate Task ChatToolHandler(JsonElement args, TContext context, CancellationToken ct); + +/// +/// The dispatch plumbing both chat tool registries share ( +/// and ): the { "error": … } envelope, the +/// tolerant argument parse — a blank or malformed argument blob is a model artefact and must never +/// abort the tool loop — and the serialized result envelope. Tool sets, gating and serializer +/// options stay with the registries. +/// +internal static class ChatToolDispatch +{ + /// The error envelope a tool failure comes back as instead of aborting the loop. + public static string Error(string message, JsonSerializerOptions json) => + JsonSerializer.Serialize(new { error = message }, json); + + /// Answer for a tool name no registry entry matches (the model can hallucinate names). + public static string UnknownTool(string name, JsonSerializerOptions json) => + Error($"Unbekanntes Tool: {name}", json); + + /// Parses a tool's JSON-schema literal into a detached element. + public static JsonElement ParseParams(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + /// + /// Runs one tool: tolerant argument parse (blank → {}, malformed → ), + /// handler, serialized result — optionally reshaped by (token-budget + /// truncation). Cancellation propagates; every other exception becomes an error envelope. + /// + public static async Task ExecuteAsync( + ChatToolHandler handler, + string argumentsJson, + JsonElement emptyArgs, + TContext context, + JsonSerializerOptions json, + CancellationToken ct, + Func? shape = null) + { + try + { + JsonElement args; + try + { + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); + args = doc.RootElement.Clone(); + } + catch (JsonException) { args = emptyArgs; } + + var result = await handler(args, context, ct); + var serialized = JsonSerializer.Serialize(result, json); + return shape is null ? serialized : shape(serialized); + } + catch (OperationCanceledException) + { + throw; // Cancellation belongs to the caller's loop, not the error-JSON path. + } + catch (Exception ex) + { + return Error(ex.Message, json); + } + } +} diff --git a/src/NodePilot.Ai/ChatToolRegistry.cs b/src/NodePilot.Ai/ChatToolRegistry.cs index cbd35789..b2654969 100644 --- a/src/NodePilot.Ai/ChatToolRegistry.cs +++ b/src/NodePilot.Ai/ChatToolRegistry.cs @@ -41,7 +41,8 @@ public interface IChatToolRegistry /// public sealed class WorkflowChatToolRegistry : IChatToolRegistry { - private static readonly JsonElement NoParams = ParseParams("""{"type":"object","properties":{}}"""); + private static readonly JsonElement NoParams = + ChatToolDispatch.ParseParams("""{"type":"object","properties":{}}"""); private static readonly JsonSerializerOptions Json = new(); // Token-budget caps: redaction happens in the reader on the FULL string; truncation only @@ -59,13 +60,11 @@ public sealed class WorkflowChatToolRegistry : IChatToolRegistry "list_recent_executions", "get_execution_steps", "get_failure_context", }; - private delegate Task ToolHandler(JsonElement args, ChatToolContext context, CancellationToken ct); - - private readonly Dictionary _tools; + private readonly Dictionary Handler)> _tools; public WorkflowChatToolRegistry() { - _tools = new Dictionary(StringComparer.Ordinal) + _tools = new Dictionary)>(StringComparer.Ordinal) { ["analyze_workflow"] = ( new LlmToolDefinition("analyze_workflow", @@ -88,7 +87,7 @@ public WorkflowChatToolRegistry() "Listet die jüngsten Ausführungen (Runs) des aktuell geöffneten Workflows: Status, Zeiten, " + "Fehlermeldung und fehlgeschlagene Steps. Rufe es, wenn der User nach vergangenen Läufen oder " + "Fehlschlägen fragt. Ergebnisse sind redigiert und gekürzt.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{"take":{"type":"integer","minimum":1,"maximum":20, "description":"Anzahl der jüngsten Läufe (Default 10)."}}} """)), @@ -99,7 +98,7 @@ public WorkflowChatToolRegistry() "Liefert die Step-Details EINER Ausführung dieses Workflows: pro Step Status, Versuche " + "(attemptCount), Output und ErrorOutput (redigiert + gekürzt). executionId stammt aus " + "list_recent_executions.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{"executionId":{"type":"string", "description":"GUID der Execution (aus list_recent_executions)."}},"required":["executionId"]} """)), @@ -126,28 +125,8 @@ public IReadOnlyList GetTools(ChatToolContext context) => public async Task ExecuteAsync(string name, string argumentsJson, ChatToolContext context, CancellationToken ct) { if (!_tools.TryGetValue(name, out var tool)) - return Error($"Unbekanntes Tool: {name}"); - try - { - JsonElement args; - try - { - using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); - args = doc.RootElement.Clone(); - } - catch (JsonException) { args = NoParams; } - - var result = await tool.Handler(args, context, ct); - return JsonSerializer.Serialize(result, Json); - } - catch (OperationCanceledException) - { - throw; // Cancellation belongs to the caller's loop, not the error-JSON path. - } - catch (Exception ex) - { - return Error(ex.Message); - } + return ChatToolDispatch.UnknownTool(name, Json); + return await ChatToolDispatch.ExecuteAsync(tool.Handler, argumentsJson, NoParams, context, Json, ct); } private static object AnalyzeWorkflow(ChatToolContext ctx) @@ -305,11 +284,4 @@ private static async Task GetFailureContextAsync(JsonElement args, ChatT return value[..maxChars] + $"…[+{value.Length - maxChars} Zeichen abgeschnitten]"; } - private static string Error(string message) => JsonSerializer.Serialize(new { error = message }, Json); - - private static JsonElement ParseParams(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } } diff --git a/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs b/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs index d8d837cb..6bf47d02 100644 --- a/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs +++ b/src/NodePilot.Ai/Knowledge/DocsKnowledgeReader.cs @@ -19,45 +19,26 @@ public interface IDocsKnowledgeReader /// public sealed class DocsKnowledgeReader(IOptionsMonitor options) : IDocsKnowledgeReader { - private string Root() - { - var configured = options.CurrentValue.DocsRootPath; - return string.IsNullOrWhiteSpace(configured) - ? Path.Combine(AppContext.BaseDirectory, "knowledge", "docs") - : configured; - } + private readonly KnowledgeCorpusReader _corpus = new( + () => options.CurrentValue.DocsRootPath, + "docs", + IsMarkdown, + full => IsMarkdown(full) ? null : "Nur Markdown-Dokumente (.md) sind lesbar.", + notFoundError: "Dokument nicht gefunden.", + unreadableError: "Dokument konnte nicht gelesen werden."); private static bool IsMarkdown(string path) => path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase); - public bool IsAvailable() => Directory.Exists(Root()); + public bool IsAvailable() => _corpus.IsAvailable(); public IReadOnlyList Search(string query) { var o = options.CurrentValue; - return KnowledgeFileSearch.Search(Root(), query, o.DocsMaxResults, o.DocsMaxFileBytes, IsMarkdown); + return _corpus.Search(query, o.DocsMaxResults, o.DocsMaxFileBytes); } - public KnowledgeFileResult Read(string relPath) - { - var o = options.CurrentValue; - var root = Root(); - if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) - return KnowledgeFileResult.Fail("Ungültiger oder unerlaubter Pfad."); - if (!IsMarkdown(full)) - return KnowledgeFileResult.Fail("Nur Markdown-Dokumente (.md) sind lesbar."); - if (!File.Exists(full)) - return KnowledgeFileResult.Fail("Dokument nicht gefunden."); - try - { - if (new FileInfo(full).Length > o.DocsMaxFileBytes) - return KnowledgeFileResult.Fail($"Datei zu groß (> {o.DocsMaxFileBytes} Bytes)."); - return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return KnowledgeFileResult.Fail("Dokument konnte nicht gelesen werden."); - } - } + public KnowledgeFileResult Read(string relPath) => + _corpus.Read(relPath, options.CurrentValue.DocsMaxFileBytes); } diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs b/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs index bb5d1142..d8790456 100644 --- a/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs +++ b/src/NodePilot.Ai/Knowledge/KnowledgeChatToolRegistry.cs @@ -46,10 +46,10 @@ public sealed class KnowledgeChatToolRegistry : IKnowledgeToolRegistry { private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static readonly JsonElement NoParams = - ParseParams("""{"type":"object","properties":{},"additionalProperties":false}"""); + ChatToolDispatch.ParseParams("""{"type":"object","properties":{},"additionalProperties":false}"""); - private delegate Task Handler(JsonElement args, KnowledgeToolContext ctx, CancellationToken ct); - private sealed record Tool(LlmToolDefinition Def, Handler Run, Func Gate); + private sealed record Tool( + LlmToolDefinition Def, ChatToolHandler Run, Func Gate); private readonly Dictionary _tools; @@ -98,7 +98,7 @@ public KnowledgeChatToolRegistry(IDocsKnowledgeReader docs, ISourceCodeKnowledge "Berechnet die nächsten geplanten Ausführungszeitpunkte (UTC) aktivierter Workflows mit " + "scheduleTrigger — die verlässliche Quelle für 'wann läuft der/ein Workflow als Nächstes'. " + "Nicht aus vergangenen Läufen raten. Optional per idOrName auf einen Workflow eingrenzen.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{ "idOrName":{"type":"string","description":"Optional: Workflow-Name oder GUID, um nur dessen Fires zu liefern."}, "count":{"type":"integer","minimum":1,"maximum":5,"description":"Fires pro Workflow (Default 3)."}}} @@ -154,7 +154,7 @@ public KnowledgeChatToolRegistry(IDocsKnowledgeReader docs, ISourceCodeKnowledge + "Fragen nach eingestellten Werten oder Defaults ('wie viele Runspaces werden beim Start vorab " + "allokiert', 'welches Log-Format', 'wie lange werden Executions aufbewahrt') — nicht raten. " + "Optional per 'section' auf eine Sektion eingrenzen.", - ParseParams(""" + ChatToolDispatch.ParseParams(""" {"type":"object","properties":{ "section":{"type":"string","description":"Optionaler Sektions-Teilname (z.B. 'Engine', 'Retention', 'Remote'). Leer = alle Sektionen."}}} """)), @@ -169,31 +169,11 @@ public IReadOnlyList GetTools(KnowledgeToolContext context) = public async Task ExecuteAsync(string name, string argumentsJson, KnowledgeToolContext context, CancellationToken ct) { if (!_tools.TryGetValue(name, out var tool)) - return Error($"Unbekanntes Tool: {name}"); + return ChatToolDispatch.UnknownTool(name, Json); if (!tool.Gate(context)) return Error($"Tool '{name}' ist in dieser Sitzung nicht verfügbar."); - try - { - JsonElement args; - try - { - using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson); - args = doc.RootElement.Clone(); - } - catch (JsonException) { args = NoParams; } - - var result = await tool.Run(args, context, ct); - return Truncate(JsonSerializer.Serialize(result, Json)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - return Error(ex.Message); - } + return await ChatToolDispatch.ExecuteAsync(tool.Run, argumentsJson, NoParams, context, Json, ct, Truncate); } // ---- docs / source ------------------------------------------------------------------------ @@ -369,19 +349,13 @@ private static string Truncate(string json) }, Json); } - private static string Error(string message) => JsonSerializer.Serialize(new { error = message }, Json); + private static string Error(string message) => ChatToolDispatch.Error(message, Json); private static JsonElement StringParam(string name, string description, bool required = false) { var desc = description.Replace("\\", "\\\\").Replace("\"", "\\\""); var req = required ? $",\"required\":[\"{name}\"]" : ""; - return ParseParams( + return ChatToolDispatch.ParseParams( $"{{\"type\":\"object\",\"properties\":{{\"{name}\":{{\"type\":\"string\",\"description\":\"{desc}\"}}}}{req},\"additionalProperties\":false}}"); } - - private static JsonElement ParseParams(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } } diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs b/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs new file mode 100644 index 00000000..fd134181 --- /dev/null +++ b/src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs @@ -0,0 +1,58 @@ +namespace NodePilot.Ai.Knowledge; + +/// +/// The corpus mechanics shared by and +/// : root resolution (configured value, else +/// {AppBaseDir}/knowledge/<folder>), availability, live keyword search, and the guarded +/// read — traversal guard → the corpus' own eligibility gates → existence → size cap → IO. +/// Every corpus supplies its own eligibility rules and its own rejection wording, so the +/// message a caller sees stays corpus-specific; only the order of the gates is shared. +/// +internal sealed class KnowledgeCorpusReader( + Func configuredRoot, + string defaultFolderName, + Func isEligible, + Func readRejection, + string notFoundError, + string unreadableError) +{ + /// Traversal rejection — identical for every corpus, the guard is root-agnostic. + private const string InvalidPathError = "Ungültiger oder unerlaubter Pfad."; + + public string Root + { + get + { + var configured = configuredRoot(); + return string.IsNullOrWhiteSpace(configured) + ? Path.Combine(AppContext.BaseDirectory, "knowledge", defaultFolderName) + : configured; + } + } + + public bool IsAvailable() => Directory.Exists(Root); + + public IReadOnlyList Search(string query, int maxResults, int maxFileBytes) => + KnowledgeFileSearch.Search(Root, query, maxResults, maxFileBytes, isEligible); + + public KnowledgeFileResult Read(string relPath, int maxFileBytes) + { + var root = Root; + if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) + return KnowledgeFileResult.Fail(InvalidPathError); + if (readRejection(full) is { } rejection) + return KnowledgeFileResult.Fail(rejection); + if (!File.Exists(full)) + return KnowledgeFileResult.Fail(notFoundError); + try + { + if (new FileInfo(full).Length > maxFileBytes) + return KnowledgeFileResult.Fail($"Datei zu groß (> {maxFileBytes} Bytes)."); + return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return KnowledgeFileResult.Fail(unreadableError); + } + } +} diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs b/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs index 696362d0..8ad3026d 100644 --- a/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs +++ b/src/NodePilot.Ai/Knowledge/KnowledgeTimeContext.cs @@ -1,4 +1,5 @@ using System.Globalization; +using NodePilot.Core.Time; namespace NodePilot.Ai.Knowledge; @@ -14,10 +15,10 @@ namespace NodePilot.Ai.Knowledge; public static class KnowledgeTimeContext { /// - /// Renders the German context block. is the caller's IANA zone - /// (e.g. Europe/Berlin); is its current UTC offset in - /// minutes (browser fallback). Resolution order: a valid IANA zone (honours DST) → the raw - /// offset → UTC only. + /// Renders the German context block. is the caller's zone — the + /// browser sends an IANA id (e.g. Europe/Berlin), the Windows form resolves too; + /// is its current UTC offset in minutes (browser fallback). + /// Resolution order: a resolvable zone id (honours DST) → the raw offset → UTC only. /// public static string Build(DateTimeOffset nowUtc, string? timeZoneId, int? offsetMinutes) { @@ -25,16 +26,10 @@ public static string Build(DateTimeOffset nowUtc, string? timeZoneId, int? offse var utcLine = utc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture); string? localLine = null; - if (!string.IsNullOrWhiteSpace(timeZoneId)) + if (!string.IsNullOrWhiteSpace(timeZoneId) && TimeZoneResolver.TryResolve(timeZoneId, out var tz)) { - try - { - var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId.Trim()); - var local = TimeZoneInfo.ConvertTime(nowUtc, tz); - localLine = $"{Format(local)} ({timeZoneId.Trim()}, {OffsetLabel(local.Offset)})"; - } - catch (TimeZoneNotFoundException) { } - catch (InvalidTimeZoneException) { } + var local = TimeZoneInfo.ConvertTime(nowUtc, tz); + localLine = $"{Format(local)} ({timeZoneId.Trim()}, {OffsetLabel(local.Offset)})"; } if (localLine is null && offsetMinutes is int mins && Math.Abs(mins) <= 14 * 60) diff --git a/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs b/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs index 5a053721..6e3f4b29 100644 --- a/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs +++ b/src/NodePilot.Ai/Knowledge/SourceCodeKnowledgeReader.cs @@ -39,15 +39,15 @@ public sealed class SourceCodeKnowledgeReader(IOptionsMonitor options.CurrentValue.SourceCodeRootPath, + "source", + IsEligible, + RejectionFor, + notFoundError: "Datei nicht gefunden.", + unreadableError: "Datei konnte nicht gelesen werden."); - public bool IsAvailable() => Directory.Exists(Root()); + public bool IsAvailable() => _corpus.IsAvailable(); /// DENY first (belt-and-suspenders on top of the git-tracked-only snapshot), then extension allowlist. internal static bool IsEligible(string path) => !IsDenied(path) && AllowedExtensions.Contains(Path.GetExtension(path)); @@ -65,33 +65,20 @@ internal static bool IsDenied(string path) || normalized.Contains("/.git/", StringComparison.Ordinal); } - public IReadOnlyList Search(string query) + /// Read gates in the same DENY-before-allowlist order as ; null = readable. + private static string? RejectionFor(string path) { - var o = options.CurrentValue; - return KnowledgeFileSearch.Search(Root(), query, o.SourceCodeMaxResults, o.SourceCodeMaxFileBytes, IsEligible); + if (IsDenied(path)) return "Diese Datei ist gesperrt (Secret-/Konfigurationsdatei)."; + if (!AllowedExtensions.Contains(Path.GetExtension(path))) return "Dieser Dateityp ist nicht lesbar."; + return null; } - public KnowledgeFileResult Read(string relPath) + public IReadOnlyList Search(string query) { var o = options.CurrentValue; - var root = Root(); - if (!KnowledgeFileSearch.TryResolveWithin(root, relPath, out var full)) - return KnowledgeFileResult.Fail("Ungültiger oder unerlaubter Pfad."); - if (IsDenied(full)) - return KnowledgeFileResult.Fail("Diese Datei ist gesperrt (Secret-/Konfigurationsdatei)."); - if (!AllowedExtensions.Contains(Path.GetExtension(full))) - return KnowledgeFileResult.Fail("Dieser Dateityp ist nicht lesbar."); - if (!File.Exists(full)) - return KnowledgeFileResult.Fail("Datei nicht gefunden."); - try - { - if (new FileInfo(full).Length > o.SourceCodeMaxFileBytes) - return KnowledgeFileResult.Fail($"Datei zu groß (> {o.SourceCodeMaxFileBytes} Bytes)."); - return KnowledgeFileResult.Success(KnowledgeFileSearch.RelativeOf(root, full), File.ReadAllText(full)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return KnowledgeFileResult.Fail("Datei konnte nicht gelesen werden."); - } + return _corpus.Search(query, o.SourceCodeMaxResults, o.SourceCodeMaxFileBytes); } + + public KnowledgeFileResult Read(string relPath) => + _corpus.Read(relPath, options.CurrentValue.SourceCodeMaxFileBytes); } diff --git a/src/NodePilot.Ai/LlmConfiguredProxy.cs b/src/NodePilot.Ai/LlmConfiguredProxy.cs index d4894a81..272f62fd 100644 --- a/src/NodePilot.Ai/LlmConfiguredProxy.cs +++ b/src/NodePilot.Ai/LlmConfiguredProxy.cs @@ -116,8 +116,8 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) var cached = _cache; if (cached is not null && cached.Matches(proxy)) return cached.Proxy; - var address = proxy.Address?.Trim(); - if (string.IsNullOrWhiteSpace(address)) + // Same two rules the settings validation applies, from the same place — see LlmProfileValidation. + if (!LlmProfileValidation.HasProxyAddress(proxy.Address, out var address)) { // Rejected by LlmProfileValidation on every save and at boot, so this only fires for a // hand-edited config picked up by hot-reload. Failing loudly beats silently going @@ -127,8 +127,7 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) + "Set a proxy URL (e.g. http://proxy.corp.local:8080) or switch the mode to 'Off' or 'System'."); } - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) + if (!LlmProfileValidation.IsHttpProxyUrl(address, out var proxyUri)) { throw new InvalidOperationException( $"{LlmProxyOptions.SectionName}:Address '{address}' is not a valid http(s) URL."); diff --git a/src/NodePilot.Ai/LlmJson.cs b/src/NodePilot.Ai/LlmJson.cs new file mode 100644 index 00000000..b1965468 --- /dev/null +++ b/src/NodePilot.Ai/LlmJson.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace NodePilot.Ai; + +/// +/// Guarded reads over an LLM response element, shared by both wire dialects +/// (, ): a missing +/// property, a null, or a value of the wrong kind all read as null instead of +/// throwing — upstream payloads are never trusted to have the documented shape. +/// +internal static class LlmJson +{ + public static string? ReadString(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + + public static int? ReadInt(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number + ? value.GetInt32() : null; +} diff --git a/src/NodePilot.Ai/LlmProfileValidation.cs b/src/NodePilot.Ai/LlmProfileValidation.cs index 1b381d99..db9bc93e 100644 --- a/src/NodePilot.Ai/LlmProfileValidation.cs +++ b/src/NodePilot.Ai/LlmProfileValidation.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; namespace NodePilot.Ai; @@ -84,8 +85,7 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura if (mode != LlmProxyMode.Custom) return issues; - var address = configuration[addressKey]?.Trim(); - if (string.IsNullOrWhiteSpace(address)) + if (!HasProxyAddress(configuration[addressKey], out var address)) { issues.Add(new ProfileIssue( addressKey, @@ -94,8 +94,7 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura return issues; } - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) + if (!IsHttpProxyUrl(address, out _)) { issues.Add(new ProfileIssue( addressKey, @@ -114,6 +113,25 @@ public static IReadOnlyList ValidateProxy(IConfiguration configura return issues; } + /// + /// First rule for a Custom proxy address: it has to be there. + /// is the trimmed value both callers go on to use. + /// + public static bool HasProxyAddress(string? rawAddress, out string address) + { + address = rawAddress?.Trim() ?? ""; + return !string.IsNullOrWhiteSpace(address); + } + + /// + /// Second rule: an absolute http(s) URL. Shared with , which + /// builds the live proxy from the same value — the two must not disagree on what "valid" means, + /// while each keeps its own wording for the rejection. + /// + public static bool IsHttpProxyUrl(string address, [NotNullWhen(true)] out Uri? url) => + Uri.TryCreate(address, UriKind.Absolute, out url) + && (url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps); + /// /// True when Llm:ActiveProfileId names an existing profile. Read straight from /// configuration so it works on a simulated merged config (settings PUT) as well as at boot. diff --git a/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs b/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs index f832e573..bc9735b8 100644 --- a/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs +++ b/src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Extensions.Logging; +using static NodePilot.Ai.LlmJson; namespace NodePilot.Ai; @@ -170,32 +171,23 @@ private async Task SendOnceAsync( } // Tool-call responses often have content: null plus tool_calls — accept both cases. - var contentStr = message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String - ? content.GetString() ?? string.Empty - : string.Empty; + var contentStr = ReadString(message, "content") ?? string.Empty; var toolCalls = ParseToolCalls(message); - var finishReason = first.TryGetProperty("finish_reason", out var fr) && fr.ValueKind == JsonValueKind.String - ? fr.GetString() : null; + var finishReason = ReadString(first, "finish_reason"); if (contentStr.Length == 0 && (toolCalls is null || toolCalls.Count == 0)) { throw new LlmException(LlmErrorKind.MalformedResponse, "LLM-Antwort enthielt weder 'content' (string) noch 'tool_calls'."); } - var modelEcho = doc.RootElement.TryGetProperty("model", out var m) && m.ValueKind == JsonValueKind.String - ? m.GetString() ?? _config.Model - : _config.Model; + var modelEcho = ReadString(doc.RootElement, "model") ?? _config.Model; int? promptTokens = null, completionTokens = null, totalTokens = null; if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { - promptTokens = usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number - ? pt.GetInt32() : null; - // Deliberately not named `ct` locally — that would shadow the outer CancellationToken parameter (CS0136). - completionTokens = usage.TryGetProperty("completion_tokens", out var ctTok) && ctTok.ValueKind == JsonValueKind.Number - ? ctTok.GetInt32() : null; - totalTokens = usage.TryGetProperty("total_tokens", out var tt) && tt.ValueKind == JsonValueKind.Number - ? tt.GetInt32() : null; + promptTokens = ReadInt(usage, "prompt_tokens"); + completionTokens = ReadInt(usage, "completion_tokens"); + totalTokens = ReadInt(usage, "total_tokens"); } return new LlmResponse(contentStr, modelEcho, @@ -265,18 +257,15 @@ public async IAsyncEnumerable StreamAsync( { using var doc = JsonDocument.Parse(data); var root = doc.RootElement; - if (root.TryGetProperty("model", out var m) && m.ValueKind == JsonValueKind.String) - model = m.GetString(); + model = ReadString(root, "model") ?? model; if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0) { var choice0 = choices[0]; - if (choice0.TryGetProperty("finish_reason", out var frEl) && frEl.ValueKind == JsonValueKind.String) - finishReason = frEl.GetString(); + finishReason = ReadString(choice0, "finish_reason") ?? finishReason; if (choice0.TryGetProperty("delta", out var d) && d.ValueKind == JsonValueKind.Object) { - if (d.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String) - delta = c.GetString(); + delta = ReadString(d, "content"); if (d.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array) { AccumulateToolCallDeltas(tcs, toolAcc, ref toolAutoIndex); @@ -286,10 +275,8 @@ public async IAsyncEnumerable StreamAsync( } if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { - if (usage.TryGetProperty("prompt_tokens", out var pt) && pt.ValueKind == JsonValueKind.Number) - promptTokens = pt.GetInt32(); - if (usage.TryGetProperty("completion_tokens", out var cpt) && cpt.ValueKind == JsonValueKind.Number) - completionTokens = cpt.GetInt32(); + promptTokens = ReadInt(usage, "prompt_tokens") ?? promptTokens; + completionTokens = ReadInt(usage, "completion_tokens") ?? completionTokens; } } catch (JsonException) @@ -429,10 +416,10 @@ private static void AppendTools(Dictionary body, LlmRequest req var list = new List(); foreach (var tc in tcs.EnumerateArray()) { - var id = tc.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? "" : ""; + var id = ReadString(tc, "id") ?? ""; if (!tc.TryGetProperty("function", out var fn) || fn.ValueKind != JsonValueKind.Object) continue; - var name = fn.TryGetProperty("name", out var nm) && nm.ValueKind == JsonValueKind.String ? nm.GetString() ?? "" : ""; - var args = fn.TryGetProperty("arguments", out var ar) && ar.ValueKind == JsonValueKind.String ? ar.GetString() ?? "" : ""; + var name = ReadString(fn, "name") ?? ""; + var args = ReadString(fn, "arguments") ?? ""; if (name.Length > 0) list.Add(new LlmToolCall(id, name, args)); } return list.Count > 0 ? list : null; @@ -452,28 +439,29 @@ private static void AccumulateToolCallDeltas(JsonElement toolCallsArray, Diction { foreach (var tc in toolCallsArray.EnumerateArray()) { - var hasId = tc.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(idEl.GetString()); + var id = ReadString(tc, "id"); + var hasId = !string.IsNullOrEmpty(id); var hasFn = tc.TryGetProperty("function", out var fn) && fn.ValueKind == JsonValueKind.Object; - var startsNewCall = hasId - || (hasFn && fn.TryGetProperty("name", out var nm) && nm.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(nm.GetString())); + var fnName = hasFn ? ReadString(fn, "name") : null; + var startsNewCall = hasId || !string.IsNullOrEmpty(fnName); int index; - if (tc.TryGetProperty("index", out var ix) && ix.ValueKind == JsonValueKind.Number) - index = ix.GetInt32(); // canonical OpenAI incremental stream + if (ReadInt(tc, "index") is { } wireIndex) + index = wireIndex; // canonical OpenAI incremental stream else if (startsNewCall || acc.Count == 0) index = autoIndex++; // index-less runtime: a new call opens a fresh slot else index = Math.Max(0, autoIndex - 1); // index-less arguments continuation → current slot - if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } + var slot = ToolCallAccumulator.Slot(acc, index); if (hasId) - slot.Id = idEl.GetString()!; + slot.Id = id!; if (hasFn) { - if (fn.TryGetProperty("name", out var nm2) && nm2.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(nm2.GetString())) - slot.Name = nm2.GetString()!; - if (fn.TryGetProperty("arguments", out var ar) && ar.ValueKind == JsonValueKind.String) - slot.Arguments.Append(ar.GetString()); + if (!string.IsNullOrEmpty(fnName)) + slot.Name = fnName; + if (ReadString(fn, "arguments") is { } arguments) + slot.Arguments.Append(arguments); } } } diff --git a/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs b/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs index 3bbdbddf..0b42a506 100644 --- a/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs +++ b/src/NodePilot.Ai/OpenAiResponsesLlmClient.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; +using static NodePilot.Ai.LlmJson; namespace NodePilot.Ai; @@ -95,7 +96,7 @@ public async IAsyncEnumerable StreamAsync( if (root.TryGetProperty("item", out var added) && added.ValueKind == JsonValueKind.Object && ReadString(added, "type") == "function_call") { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? toolAcc.Count); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? toolAcc.Count); slot.Id = ReadString(added, "call_id") ?? slot.Id; slot.Name = ReadString(added, "name") ?? slot.Name; sawOutput = true; @@ -104,7 +105,7 @@ public async IAsyncEnumerable StreamAsync( case "response.function_call_arguments.delta": { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); if (root.TryGetProperty("delta", out var ad) && ad.ValueKind == JsonValueKind.String) slot.Arguments.Append(ad.GetString()); sawOutput = true; @@ -118,7 +119,7 @@ public async IAsyncEnumerable StreamAsync( if (root.TryGetProperty("item", out var done) && done.ValueKind == JsonValueKind.Object && ReadString(done, "type") == "function_call") { - var slot = Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); + var slot = ToolCallAccumulator.Slot(toolAcc, ReadInt(root, "output_index") ?? Math.Max(0, toolAcc.Count - 1)); slot.Id = ReadString(done, "call_id") ?? slot.Id; slot.Name = ReadString(done, "name") ?? slot.Name; if (slot.Arguments.Length == 0 && ReadString(done, "arguments") is { } args) @@ -351,18 +352,4 @@ private static (int?, int?) ReadUsage(JsonElement response, int? promptTokens, i return (ReadInt(usage, "input_tokens") ?? promptTokens, ReadInt(usage, "output_tokens") ?? completionTokens); } - private static ToolCallAccumulator Slot(Dictionary acc, int index) - { - if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } - return slot; - } - - private static string? ReadString(JsonElement element, string name) => - element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() : null; - - private static int? ReadInt(JsonElement element, string name) => - element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number - ? value.GetInt32() : null; - } diff --git a/src/NodePilot.Ai/ToolCallAccumulator.cs b/src/NodePilot.Ai/ToolCallAccumulator.cs index bf1a48a7..f7333501 100644 --- a/src/NodePilot.Ai/ToolCallAccumulator.cs +++ b/src/NodePilot.Ai/ToolCallAccumulator.cs @@ -14,6 +14,13 @@ internal sealed class ToolCallAccumulator public string Name = ""; public StringBuilder Arguments { get; } = new(); + /// The slot for , created on first use. + public static ToolCallAccumulator Slot(Dictionary acc, int index) + { + if (!acc.TryGetValue(index, out var slot)) { slot = new ToolCallAccumulator(); acc[index] = slot; } + return slot; + } + /// /// Orders by wire index and drops nameless slots (argument fragments whose header /// chunk never arrived). Returns null when nothing usable accumulated, so callers can diff --git a/src/NodePilot.Ai/WorkflowAssistantService.cs b/src/NodePilot.Ai/WorkflowAssistantService.cs index c01df2e1..9ae58b33 100644 --- a/src/NodePilot.Ai/WorkflowAssistantService.cs +++ b/src/NodePilot.Ai/WorkflowAssistantService.cs @@ -292,14 +292,9 @@ private static bool IsEmptyCanvas(JsonElement original) return true; foreach (var node in nodes.EnumerateArray()) { - if (node.ValueKind != JsonValueKind.Object) continue; - if (node.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object - && data.TryGetProperty("activityType", out var at) && at.ValueKind == JsonValueKind.String) - { - var t = at.GetString(); - if (!string.IsNullOrEmpty(t) && !t.EndsWith("Trigger", StringComparison.Ordinal)) - return false; // a real activity exists → not an empty canvas - } + var t = ActivityTypeOf(node); + if (!string.IsNullOrEmpty(t) && !t.EndsWith("Trigger", StringComparison.Ordinal)) + return false; // a real activity exists → not an empty canvas } return true; } @@ -316,15 +311,8 @@ private static string BuildActivityMetadata(JsonElement original, IReadOnlyDicti { foreach (var node in nodes.EnumerateArray()) { - if (node.ValueKind != JsonValueKind.Object) continue; - if (node.TryGetProperty("data", out var data) - && data.ValueKind == JsonValueKind.Object - && data.TryGetProperty("activityType", out var at) - && at.ValueKind == JsonValueKind.String) - { - var t = at.GetString(); - if (!string.IsNullOrEmpty(t)) types.Add(t); - } + var t = ActivityTypeOf(node); + if (!string.IsNullOrEmpty(t)) types.Add(t); } } diff --git a/src/NodePilot.Api/Controllers/AiChatController.cs b/src/NodePilot.Api/Controllers/AiChatController.cs index 64923421..c88a45cf 100644 --- a/src/NodePilot.Api/Controllers/AiChatController.cs +++ b/src/NodePilot.Api/Controllers/AiChatController.cs @@ -11,10 +11,8 @@ using NodePilot.Core.Audit; using NodePilot.Api.Dtos; using NodePilot.Api.Security; -using NodePilot.Api.Telemetry; using NodePilot.Core.Interfaces; using NodePilot.Data; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -173,11 +171,21 @@ public async Task Chat(WorkflowChatRequest request, CancellationT async Task Write(ChatStreamEvent e) { + // The closing totals must be captured before the write, so a client that + // disconnects mid-flush still lands in the audit trail with what we knew. + if (e is ChatStreamEvent.DoneEvent done) + { + model = done.Model; + durationMs = done.DurationMs; + promptTokens = done.PromptTokens; + completionTokens = done.CompletionTokens; + } + + if (await AiStreamSupport.TryWriteSharedEventAsync(sse, e, onToolCall: null, ct)) + return; + switch (e) { - case ChatStreamEvent.DeltaEvent d: - await sse.WriteAsync("delta", new { text = d.Text }, ct); - break; case ChatStreamEvent.BuildingEvent: await sse.WriteAsync("building", new { }, ct); break; @@ -185,19 +193,6 @@ async Task Write(ChatStreamEvent e) proposed = true; await sse.WriteAsync("proposal", p.Dto, ct); break; - case ChatStreamEvent.ToolCallEvent tc: - await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); - break; - case ChatStreamEvent.ToolResultEvent tr: - await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); - break; - case ChatStreamEvent.DoneEvent done: - model = done.Model; - durationMs = done.DurationMs; - promptTokens = done.PromptTokens; - completionTokens = done.CompletionTokens; - await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); - break; } } @@ -273,33 +268,19 @@ public async Task>> ChatActivity( return Ok(rows); } + private const string LlmKind = "chat"; + private static void RecordResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "chat"), new("result", result)); + AiStreamSupport.RecordResult(LlmKind, result); private void RecordError(LlmException ex) { - RecordResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(LlmKind, ex); _logger.LogWarning(ex, "LLM chat stream failed: {Kind}", ex.Kind); } - private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "chat"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(LlmKind, model, durationMs, promptTokens, completionTokens); private Task AuditAsync(string model, int durationMs, bool proposed, bool cancelled, int turnCount, Guid? workflowId, CancellationToken ct = default) => diff --git a/src/NodePilot.Api/Controllers/AiController.cs b/src/NodePilot.Api/Controllers/AiController.cs index aa755e36..0d262936 100644 --- a/src/NodePilot.Api/Controllers/AiController.cs +++ b/src/NodePilot.Api/Controllers/AiController.cs @@ -6,8 +6,6 @@ using NodePilot.Ai; using NodePilot.Api.Configuration; using NodePilot.Core.Audit; -using NodePilot.Api.Telemetry; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -128,33 +126,20 @@ async Task Write(ScriptStreamEvent e) return new EmptyResult(); } + private const string ScriptKind = "script"; + private const string WorkflowKind = "workflow"; + private static void RecordScriptResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "script"), new("result", result)); + AiStreamSupport.RecordResult(ScriptKind, result); private void RecordScriptError(LlmException ex) { - RecordScriptResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(ScriptKind, ex); _logger.LogWarning(ex, "LLM script stream failed: {Kind}", ex.Kind); } - private static void RecordScriptSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordScriptResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "script"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordScriptSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(ScriptKind, model, durationMs, promptTokens, completionTokens); private Task ScriptAuditAsync(string model, int durationMs, int responseChars, GenerateScriptRequest request, bool cancelled, CancellationToken ct = default) => @@ -193,22 +178,8 @@ public async Task> GenerateWorkflow( { var resp = await _workflowGen.GenerateAsync(request, ct); - ApiMetrics.LlmCalls.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new("result", "success")); - ApiMetrics.LlmCallDuration.Record(resp.DurationMs, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model)); - if (resp.PromptTokens.HasValue) - ApiMetrics.LlmTokens.Add(resp.PromptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model), - new("token_type", "prompt")); - if (resp.CompletionTokens.HasValue) - ApiMetrics.LlmTokens.Add(resp.CompletionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmModel, resp.Model), - new("token_type", "completion")); + AiStreamSupport.RecordSuccess(WorkflowKind, resp.Model, resp.DurationMs, + resp.PromptTokens, resp.CompletionTokens); await _audit.LogAsync(AuditActions.AiWorkflowGenerated, "Workflow", null, AuditDetails.Json( @@ -224,12 +195,7 @@ await _audit.LogAsync(AuditActions.AiWorkflowGenerated, "Workflow", null, } catch (LlmException ex) { - ApiMetrics.LlmCalls.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new("result", "error")); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "workflow"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(WorkflowKind, ex); return this.MapLlmException(_logger, ex, "LLM call"); } } diff --git a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs index 3efc603f..a5c63527 100644 --- a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs +++ b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs @@ -11,10 +11,8 @@ using NodePilot.Api.Ai; using NodePilot.Api.Dtos; using NodePilot.Api.Security; -using NodePilot.Api.Telemetry; using NodePilot.Core.Audit; using NodePilot.Core.Interfaces; -using NodePilot.Core.Telemetry; namespace NodePilot.Api.Controllers; @@ -144,32 +142,25 @@ public async Task Ask(KnowledgeAskRequest request, CancellationTo var dbQueryFingerprints = new List(); int? promptTokens = null, completionTokens = null; + // BuildingEvent / ProposalEvent never occur on the knowledge stream, so the shared + // writer covers every event this endpoint can see. async Task Write(ChatStreamEvent e) { - switch (e) + if (e is ChatStreamEvent.DoneEvent done) { - case ChatStreamEvent.DeltaEvent d: - await sse.WriteAsync("delta", new { text = d.Text }, ct); - break; - case ChatStreamEvent.ToolCallEvent tc: - toolCalls++; - if (string.Equals(tc.ToolName, "execute_readonly_sql", StringComparison.Ordinal) - && TryFingerprintSqlToolCall(tc.ArgumentsJson) is { } fingerprint) - dbQueryFingerprints.Add(fingerprint); - await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); - break; - case ChatStreamEvent.ToolResultEvent tr: - await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); - break; - case ChatStreamEvent.DoneEvent done: - model = done.Model; - durationMs = done.DurationMs; - promptTokens = done.PromptTokens; - completionTokens = done.CompletionTokens; - await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); - break; - // BuildingEvent / ProposalEvent never occur on the knowledge stream. + model = done.Model; + durationMs = done.DurationMs; + promptTokens = done.PromptTokens; + completionTokens = done.CompletionTokens; } + + await AiStreamSupport.TryWriteSharedEventAsync(sse, e, tc => + { + toolCalls++; + if (string.Equals(tc.ToolName, "execute_readonly_sql", StringComparison.Ordinal) + && TryFingerprintSqlToolCall(tc.ArgumentsJson) is { } fingerprint) + dbQueryFingerprints.Add(fingerprint); + }, ct); } try @@ -248,32 +239,18 @@ private static List NormalizeHistory(IReadOnlyList .ToList(); } + private const string LlmKind = "knowledge"; + private static void RecordResult(string result) => - ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, "knowledge"), new("result", result)); + AiStreamSupport.RecordResult(LlmKind, result); private void RecordError(LlmException ex) { - RecordResult("error"); - ApiMetrics.LlmErrors.Add(1, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + AiStreamSupport.RecordError(LlmKind, ex); _logger.LogWarning(ex, "LLM knowledge stream failed: {Kind}", ex.Kind); } - private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) - { - RecordResult("success"); - ApiMetrics.LlmCallDuration.Record(durationMs, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model)); - if (promptTokens.HasValue) - ApiMetrics.LlmTokens.Add(promptTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); - if (completionTokens.HasValue) - ApiMetrics.LlmTokens.Add(completionTokens.Value, - new(TelemetryConstants.Attributes.LlmKind, "knowledge"), - new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); - } + private static void RecordSuccess(string model, int durationMs, int? promptTokens, int? completionTokens) => + AiStreamSupport.RecordSuccess(LlmKind, model, durationMs, promptTokens, completionTokens); } diff --git a/src/NodePilot.Api/Controllers/AiStreamSupport.cs b/src/NodePilot.Api/Controllers/AiStreamSupport.cs new file mode 100644 index 00000000..8d86ca0e --- /dev/null +++ b/src/NodePilot.Api/Controllers/AiStreamSupport.cs @@ -0,0 +1,79 @@ +using NodePilot.Ai; +using NodePilot.Api.Ai; +using NodePilot.Api.Telemetry; +using NodePilot.Core.Telemetry; + +namespace NodePilot.Api.Controllers; + +/// +/// Shared plumbing for the AI endpoints (, , +/// ): the LLM telemetry trio and the SSE events their streams +/// have in common. Everything that differs stays with the caller — the metric kind tag is a +/// parameter, the per-endpoint log line stays at the call site, and the chat-only +/// building/proposal events are handled there too. +/// +internal static class AiStreamSupport +{ + /// Counts one LLM call outcome. Metric name, tag names and tag order are dashboard contract. + public static void RecordResult(string kind, string result) => + ApiMetrics.LlmCalls.Add(1, new(TelemetryConstants.Attributes.LlmKind, kind), new("result", result)); + + /// Counts a failed call: the error result first, then the error-kind breakdown. + public static void RecordError(string kind, LlmException ex) + { + RecordResult(kind, "error"); + ApiMetrics.LlmErrors.Add(1, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmErrorKind, ex.Kind.ToString())); + } + + /// Counts a successful call plus its latency and (when reported) its token usage. + public static void RecordSuccess(string kind, string model, int durationMs, int? promptTokens, int? completionTokens) + { + RecordResult(kind, "success"); + ApiMetrics.LlmCallDuration.Record(durationMs, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model)); + if (promptTokens.HasValue) + ApiMetrics.LlmTokens.Add(promptTokens.Value, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "prompt")); + if (completionTokens.HasValue) + ApiMetrics.LlmTokens.Add(completionTokens.Value, + new(TelemetryConstants.Attributes.LlmKind, kind), + new(TelemetryConstants.Attributes.LlmModel, model), new("token_type", "completion")); + } + + /// + /// Writes the chat-stream events that the workflow assistant and the knowledge assistant emit + /// identically (delta/tool_call/tool_result/done) and reports + /// false for everything else, so a caller with extra events (building/proposal) can + /// handle those itself. runs before the tool_call event is + /// written — the knowledge stream counts calls and fingerprints SQL there. + /// + public static async Task TryWriteSharedEventAsync( + SseResponseWriter sse, + ChatStreamEvent e, + Action? onToolCall, + CancellationToken ct) + { + switch (e) + { + case ChatStreamEvent.DeltaEvent d: + await sse.WriteAsync("delta", new { text = d.Text }, ct); + return true; + case ChatStreamEvent.ToolCallEvent tc: + onToolCall?.Invoke(tc); + await sse.WriteAsync("tool_call", new { toolName = tc.ToolName, toolId = tc.ToolId }, ct); + return true; + case ChatStreamEvent.ToolResultEvent tr: + await sse.WriteAsync("tool_result", new { toolId = tr.ToolId, toolName = tr.ToolName }, ct); + return true; + case ChatStreamEvent.DoneEvent done: + await sse.WriteAsync("done", new { model = done.Model, durationMs = done.DurationMs, generationMs = done.GenerationMs, promptTokens = done.PromptTokens, completionTokens = done.CompletionTokens }, ct); + return true; + default: + return false; + } + } +} diff --git a/src/NodePilot.Api/Controllers/AlertingController.cs b/src/NodePilot.Api/Controllers/AlertingController.cs index 6347efdb..6e98df38 100644 --- a/src/NodePilot.Api/Controllers/AlertingController.cs +++ b/src/NodePilot.Api/Controllers/AlertingController.cs @@ -232,41 +232,7 @@ public async Task> TestFire(Guid id, Cancellation var user = this.GetCurrentUsername(); var ctx = BuildSampleContext(rule.Name, user); - var now = DateTime.UtcNow; - var results = new List(); - - foreach (var route in rule.Routes) - { - var attempt = new NotificationDeliveryAttempt - { - Id = Guid.NewGuid(), - NotificationRuleId = rule.Id, - NotificationRouteId = route.Id, - EventKey = $"test:{Guid.NewGuid():N}", - DedupKey = $"test:{rule.Id}", - IsTest = true, - Attempt = 1, - CreatedAt = now, - SentAt = now, - }; - - NotificationSendResult result; - if (!_sinks.TryGetValue(route.Channel, out var sink)) - { - result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); - } - else - { - var secret = string.IsNullOrEmpty(route.Secret) ? null : await _store.GetRouteSecretAsync(route.Id, ct); - result = await sink.SendAsync(ctx, route.Target, secret, ct); - } - - attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; - attempt.Error = result.Error; - attempt.Summary = $"[test] {route.Channel}:{route.Target}"; - _db.NotificationDeliveryAttempts.Add(attempt); - results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); - } + var results = await AlertingRuleMapping.DeliverTestFireAsync(_db, _store, _sinks, rule, ctx, ct); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditActions.AlertRuleTestFired, "NotificationRule", rule.Id, @@ -384,49 +350,12 @@ private bool TryBuildDraft( // Routes if (routes is null || routes.Count == 0) return Fail("At least one route is required", out error); - var mappedRoutes = new List(); - var order = 0; - foreach (var rt in routes) - { - if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) - return Fail($"Invalid channel '{rt.Channel}'", out error); - if (!_sinks.ContainsKey(channel)) - return Fail($"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", _sinks.Keys)})", out error); - if (string.IsNullOrWhiteSpace(rt.Target)) - return Fail("Each route requires a target", out error); - if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) - return Fail($"route conditionExpressionJson {routeConditionError}", out error); - mappedRoutes.Add(new NotificationRoute - { - Id = rt.Id ?? Guid.Empty, - Channel = channel, - Target = rt.Target.Trim(), - Secret = rt.Secret, // store resolves unchanged-sentinel / encrypts plaintext - ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, - Order = order++, - }); - } + if (!AlertingRuleMapping.TryMapRoutes(routes, _sinks, out var mappedRoutes, out var routeError)) + return Fail(routeError!, out error); // Scope targets - var mappedTargets = new List(); - if (scope != NotificationScopeKind.Global) - { - var expectedKind = scope == NotificationScopeKind.Folders - ? NotificationTargetKind.Folder - : NotificationTargetKind.Workflow; - if (targets is null || targets.Count == 0) - return Fail($"{scope} rules require at least one target", out error); - foreach (var t in targets) - { - if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) - return Fail($"Invalid target kind '{t.TargetKind}'", out error); - if (kind != expectedKind) - return Fail($"{scope} rules may only contain {expectedKind} targets", out error); - if (t.TargetId == Guid.Empty) - return Fail("Target id must not be empty", out error); - mappedTargets.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); - } - } + if (!AlertingRuleMapping.TryMapScopeTargets(scope, targets, "rules", out var mappedTargets, out var targetError)) + return Fail(targetError!, out error); draft = new NotificationRule { diff --git a/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs b/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs new file mode 100644 index 00000000..5b83dc58 --- /dev/null +++ b/src/NodePilot.Api/Controllers/AlertingRuleMapping.cs @@ -0,0 +1,165 @@ +using NodePilot.Api.Dtos; +using NodePilot.Core.Enums; +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.Notifications; + +namespace NodePilot.Api.Controllers; + +/// +/// The parts (custom rules) and +/// (system policies) run identically over the same graph: route mapping, +/// scope-target mapping and the test-fire delivery loop. Only the noun inside two target-validation +/// messages differs between the surfaces ("rules" vs "policies"), so it is a parameter; every other +/// message, the route order and the ledger rows stay byte-identical. +/// +internal static class AlertingRuleMapping +{ + /// + /// Validates and maps request routes onto entities. Returns false + /// with set on the first problem; the caller wraps that in its own 400 + /// shape. An absent/empty route list maps to an empty result — whether that is legal is the + /// caller's rule (custom rules always need one, a disabled system policy does not). + /// + public static bool TryMapRoutes( + IReadOnlyList? routes, + IReadOnlyDictionary sinks, + out List mapped, + out string? error) + { + mapped = new List(); + error = null; + var order = 0; + foreach (var rt in routes ?? []) + { + if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) + { + error = $"Invalid channel '{rt.Channel}'"; + return false; + } + if (!sinks.ContainsKey(channel)) + { + error = $"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", sinks.Keys)})"; + return false; + } + if (string.IsNullOrWhiteSpace(rt.Target)) + { + error = "Each route requires a target"; + return false; + } + if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) + { + error = $"route conditionExpressionJson {routeConditionError}"; + return false; + } + mapped.Add(new NotificationRoute + { + Id = rt.Id ?? Guid.Empty, + Channel = channel, + Target = rt.Target.Trim(), + Secret = rt.Secret, // store resolves unchanged-sentinel / encrypts plaintext + ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, + Order = order++, + }); + } + return true; + } + + /// + /// Maps the scope targets. Global scope carries none. is the plural the + /// caller's surface uses in its validation messages ("rules" / "policies") — the wording is part + /// of the API contract, so it must not converge. + /// + public static bool TryMapScopeTargets( + NotificationScopeKind scope, + IReadOnlyList? targets, + string noun, + out List mapped, + out string? error) + { + mapped = new List(); + error = null; + if (scope == NotificationScopeKind.Global) return true; + + var expectedKind = scope == NotificationScopeKind.Folders + ? NotificationTargetKind.Folder + : NotificationTargetKind.Workflow; + if (targets is null || targets.Count == 0) + { + error = $"{scope} {noun} require at least one target"; + return false; + } + foreach (var t in targets) + { + if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) + { + error = $"Invalid target kind '{t.TargetKind}'"; + return false; + } + if (kind != expectedKind) + { + error = $"{scope} {noun} may only contain {expectedKind} targets"; + return false; + } + if (t.TargetId == Guid.Empty) + { + error = "Target id must not be empty"; + return false; + } + mapped.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); + } + return true; + } + + /// + /// Sends the synthetic notification through every route of and stages one + /// IsTest delivery-ledger row per route. The caller keeps SaveChanges and the audit entry — + /// those are the only parts that differ between the two test-fire endpoints. + /// + public static async Task> DeliverTestFireAsync( + NodePilotDbContext db, + INotificationRuleStore store, + IReadOnlyDictionary sinks, + NotificationRule rule, + NotificationContext ctx, + CancellationToken ct) + { + var now = DateTime.UtcNow; + var results = new List(); + + foreach (var route in rule.Routes) + { + var attempt = new NotificationDeliveryAttempt + { + Id = Guid.NewGuid(), + NotificationRuleId = rule.Id, + NotificationRouteId = route.Id, + EventKey = $"test:{Guid.NewGuid():N}", + DedupKey = $"test:{rule.Id}", + IsTest = true, + Attempt = 1, + CreatedAt = now, + SentAt = now, + }; + + NotificationSendResult result; + if (!sinks.TryGetValue(route.Channel, out var sink)) + { + result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); + } + else + { + var secret = string.IsNullOrEmpty(route.Secret) ? null : await store.GetRouteSecretAsync(route.Id, ct); + result = await sink.SendAsync(ctx, route.Target, secret, ct); + } + + attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; + attempt.Error = result.Error; + attempt.Summary = $"[test] {route.Channel}:{route.Target}"; + db.NotificationDeliveryAttempts.Add(attempt); + results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); + } + return results; + } +} diff --git a/src/NodePilot.Api/Controllers/ApiProblems.cs b/src/NodePilot.Api/Controllers/ApiProblems.cs index cf8e8524..1941818f 100644 --- a/src/NodePilot.Api/Controllers/ApiProblems.cs +++ b/src/NodePilot.Api/Controllers/ApiProblems.cs @@ -17,39 +17,6 @@ public static BadRequestObjectResult BadRequest( return result; } - public static NotFoundObjectResult NotFound( - ControllerBase controller, - string code, - string detail, - string title = "Not found") - { - var result = new NotFoundObjectResult(BuildProblem(controller, StatusCodes.Status404NotFound, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - - public static ConflictObjectResult Conflict( - ControllerBase controller, - string code, - string detail, - string title = "Conflict") - { - var result = new ConflictObjectResult(BuildProblem(controller, StatusCodes.Status409Conflict, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - - public static UnauthorizedObjectResult Unauthorized( - ControllerBase controller, - string code, - string detail, - string title = "Unauthorized") - { - var result = new UnauthorizedObjectResult(BuildProblem(controller, StatusCodes.Status401Unauthorized, code, title, detail)); - result.ContentTypes.Add("application/problem+json"); - return result; - } - public static ProblemDetails BuildProblem( ControllerBase controller, int status, diff --git a/src/NodePilot.Api/Controllers/AuditController.cs b/src/NodePilot.Api/Controllers/AuditController.cs index 5c168638..2fb34d3e 100644 --- a/src/NodePilot.Api/Controllers/AuditController.cs +++ b/src/NodePilot.Api/Controllers/AuditController.cs @@ -7,6 +7,7 @@ using NodePilot.Api.Dtos; using NodePilot.Core.Audit; using NodePilot.Data; +using NodePilot.Api.Export; namespace NodePilot.Api.Controllers; @@ -199,12 +200,12 @@ await writer.WriteLineAsync(JsonSerializer.Serialize(new sb.Append(row.Id).Append(',') .Append(row.Timestamp.ToString("O")).Append(',') .Append(row.UserId?.ToString() ?? "").Append(','); - CsvField(sb, row.Username); sb.Append(','); - CsvField(sb, row.Action); sb.Append(','); - CsvField(sb, row.ResourceType); sb.Append(','); + CsvWriter.Field(sb, row.Username); sb.Append(','); + CsvWriter.Field(sb, row.Action); sb.Append(','); + CsvWriter.Field(sb, row.ResourceType); sb.Append(','); sb.Append(row.ResourceId?.ToString() ?? "").Append(','); - CsvField(sb, row.IpAddress); sb.Append(','); - CsvField(sb, row.Details); + CsvWriter.Field(sb, row.IpAddress); sb.Append(','); + CsvWriter.Field(sb, row.Details); await writer.WriteLineAsync(sb); } @@ -231,22 +232,4 @@ await _audit.LogAsync( ("exported", batch)), ct); } - - /// - /// RFC 4180 minimal CSV escaping: only quote when the value contains a comma, quote, - /// or newline; double internal quotes. NULL and empty render as empty (no quotes). - /// - private static void CsvField(StringBuilder sb, string? value) - { - if (string.IsNullOrEmpty(value)) return; - var needsQuoting = value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needsQuoting) { sb.Append(value); return; } - sb.Append('"'); - foreach (var c in value) - { - if (c == '"') sb.Append("\"\""); - else sb.Append(c); - } - sb.Append('"'); - } } diff --git a/src/NodePilot.Api/Controllers/AuthController.cs b/src/NodePilot.Api/Controllers/AuthController.cs index c89b23c4..896b7817 100644 --- a/src/NodePilot.Api/Controllers/AuthController.cs +++ b/src/NodePilot.Api/Controllers/AuthController.cs @@ -118,6 +118,42 @@ public AuthController(NodePilotDbContext db, IConfiguration config, IAuditWriter internal const int LockoutFailureThreshold = 10; internal static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15); + /// Single write point for the login-outcome metric. Tag names, order and values are + /// dashboard contract — every login path funnels through here instead of restating them. + private static void RecordLoginAttempt(string result, string reason) => + ApiMetrics.AuthLoginAttempts.Add(1, + new KeyValuePair("result", result), + new KeyValuePair("reason", reason)); + + /// + /// Stages a JWT revocation for : derives the expiry from the token's + /// exp claim (falling back to the configured lifetime) and adds a + /// row when the jti is not already revoked. Returns true when a + /// row was staged. The caller keeps SaveChanges and the revocation metric, because logout + /// and refresh deliberately order those two differently. + /// + private async Task TryStageTokenRevocationAsync( + string jti, Guid userId, string? expClaim, string reason, CancellationToken ct) + { + long.TryParse(expClaim, out var expSec); + var expiresAt = expSec > 0 + ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime + : DateTime.UtcNow.Add(TokenLifetime); + + var existing = await _db.RevokedTokens.FindAsync([jti], ct); + if (existing is not null) return false; + + _db.RevokedTokens.Add(new RevokedToken + { + Jti = jti, + UserId = userId, + RevokedAt = DateTime.UtcNow, + ExpiresAt = expiresAt, + Reason = reason, + }); + return true; + } + // H-5: cookie names for the httpOnly auth cookie and the (JS-readable) CSRF token. // The SPA no longer persists the JWT in localStorage where a single XSS bug would // exfiltrate an admin token; the browser holds it in an httpOnly cookie and the @@ -468,9 +504,7 @@ await _audit.LogAsync(AuditActions.LoginLocked, "User", null, _ = BCrypt.Net.BCrypt.Verify(request.Password, DummyHash); await _audit.LogAsync(AuditActions.LoginFailed, "User", null, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", "unknown_username")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "unknown_user")); + RecordLoginAttempt("failure", "unknown_user"); return Unauthorized(new { message = "Invalid credentials" }); } } @@ -487,9 +521,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, ("username", user.Username), ("reason", "external_user_local_login_attempt"), ("provider", user.Provider.ToString())), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "external_user_local_login")); + RecordLoginAttempt("failure", "external_user_local_login"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -503,9 +535,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await _audit.LogAsync(AuditActions.LoginLocked, "User", user.Id, AuditDetails.Json(("username", user.Username), ("lockedUntil", localAttempt.LockedUntil?.ToString("o") ?? "unknown")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "locked")); + RecordLoginAttempt("failure", "locked"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -517,9 +547,7 @@ await _audit.LogAsync(AuditActions.LoginLocked, "User", user.Id, await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", auditReason), ("failedCount", localAttempt.FailureCount)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "bad_password")); + RecordLoginAttempt("failure", "bad_password"); if (localAttempt.TriggeredLockout) ApiMetrics.AuthLockouts.Add(1); return Unauthorized(new { message = "Invalid credentials" }); } @@ -534,9 +562,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await ReleaseUserAttemptAsync(user, localAttempt, ct); await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -546,9 +572,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, // IAuthSessionIssuer so LDAP / Windows-Auth flows can reuse them. AuthSource.Local // for the BCrypt path; LDAP/Windows pass their own value. var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Local, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "ok")); + RecordLoginAttempt("success", "ok"); return SessionResult(session.Token, user, TokenInBodyRequested()); } @@ -680,9 +704,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, await _audit.LogAsync(AuditActions.LoginLocked, "User", existing.Id, AuditDetails.Json(("username", existing.Username), ("lockedUntil", ldapLockedUntil.ToString("o")), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "locked")); + RecordLoginAttempt("failure", "locked"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -740,45 +762,14 @@ await _externalLoginThrottle.RecordSuccessAsync( if (existing is { Provider: AuthProvider.Ldap } && ldapUserAttempt is not null) await ResetUserAttemptsAsync(existing, CancellationToken.None); var mapping = await _externalUserMapper.MapAsync(ldap.Result!, ct); - if (mapping.Result == ExternalUserMapResult.RefusedUsernameCollision) + if (MapExternalIdentityRefusal( + mapping.Result, + "ldap", + collisionMessage: "Invalid credentials", + identityRefusedMessage: "Invalid credentials", + lastActiveAdminMessage: "Invalid credentials") is { } refused) { - // Audit was already written by the mapper; surface a generic 401 so the - // outsider can't tell collision from wrong-password. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_username_collision")); - return Unauthorized(new { message = "Invalid credentials" }); - } - if (mapping.Result is ExternalUserMapResult.RefusedIdentityConflict - or ExternalUserMapResult.RefusedTombstoned - or ExternalUserMapResult.RefusedDirectoryAccess) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_identity_refused")); - return Unauthorized(new { message = "Invalid credentials" }); - } - if (mapping.Result == ExternalUserMapResult.RefusedBootstrapNotAdmin) - { - // External identities never bootstrap the recovery administrator. The - // one-shot local bootstrap must establish a break-glass account first. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_bootstrap_refused")); - return Unauthorized(new - { - message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", - }); - } - if (mapping.Result == ExternalUserMapResult.RefusedLastActiveAdmin) - { - // The mapper preserved the database Admin invariant, invalidated stale - // sessions, and wrote the refusal audit. Do not mint a new token from - // that deliberately-preserved role. - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_last_admin_demotion_refused")); - return Unauthorized(new { message = "Invalid credentials" }); + return refused; } var user = mapping.User!; @@ -786,9 +777,7 @@ or ExternalUserMapResult.RefusedTombstoned { await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Invalid credentials" }); } @@ -798,9 +787,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, await ResetUserAttemptsAsync(user, ct); var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Ldap, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "ldap_ok")); + RecordLoginAttempt("success", "ldap_ok"); return SessionResult(session.Token, user, TokenInBodyRequested()); } case LdapAuthOutcome.InvalidCredentials: @@ -825,9 +812,7 @@ await _audit.LogAsync( ("failedCount", (ldapUserAttempt?.FailureCount ?? 0).ToString()), ("lockoutTriggered", ldapTriggeredLockout.ToString()), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_invalid_credentials")); + RecordLoginAttempt("failure", "ldap_invalid_credentials"); return Unauthorized(new { message = "Invalid credentials" }); case LdapAuthOutcome.DirectoryObjectMissing: // Password verified, but the directory holds no user object for this UPN — @@ -839,9 +824,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", existing?.Id, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", "ldap_user_object_not_found"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "ldap_user_object_not_found")); + RecordLoginAttempt("failure", "ldap_user_object_not_found"); return Unauthorized(new { message = "Invalid credentials" }); default: // Local password users were already short-circuited before LDAP. Reaching @@ -854,9 +837,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", existing?.Id, AuditDetails.Json(("username", SafeUsernameForAudit(request.Username)), ("reason", ldap.UnavailableReason ?? "directory_unavailable"), ("source", AuthSourceLdap)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", ldap.UnavailableReason ?? "unavailable")); + RecordLoginAttempt("failure", ldap.UnavailableReason ?? "unavailable"); return StatusCode(StatusCodes.Status503ServiceUnavailable, new { message = "Directory authentication cannot verify current authorization.", @@ -888,6 +869,54 @@ await ReleaseUserAttemptAsync( } } + /// + /// Shared refusal ladder for the two external-identity login paths (LDAP and Windows SSO). + /// yields the same verdicts for both; only the metric-reason + /// prefix and the client-facing messages differ, so they are parameters. Returns null + /// when the mapping was not refused and the caller may continue with mapping.User. + /// + private ActionResult? MapExternalIdentityRefusal( + ExternalUserMapResult result, + string reasonPrefix, + string collisionMessage, + string identityRefusedMessage, + string lastActiveAdminMessage) + { + if (result == ExternalUserMapResult.RefusedUsernameCollision) + { + // Audit was already written by the mapper; surface a generic 401 so the + // outsider can't tell collision from wrong-password. + RecordLoginAttempt("failure", reasonPrefix + "_username_collision"); + return Unauthorized(new { message = collisionMessage }); + } + if (result is ExternalUserMapResult.RefusedIdentityConflict + or ExternalUserMapResult.RefusedTombstoned + or ExternalUserMapResult.RefusedDirectoryAccess) + { + RecordLoginAttempt("failure", reasonPrefix + "_identity_refused"); + return Unauthorized(new { message = identityRefusedMessage }); + } + if (result == ExternalUserMapResult.RefusedBootstrapNotAdmin) + { + // External identities never bootstrap the recovery administrator. The + // one-shot local bootstrap must establish a break-glass account first. + RecordLoginAttempt("failure", reasonPrefix + "_bootstrap_refused"); + return Unauthorized(new + { + message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", + }); + } + if (result == ExternalUserMapResult.RefusedLastActiveAdmin) + { + // The mapper preserved the database Admin invariant, invalidated stale + // sessions, and wrote the refusal audit. Do not mint a new token from + // that deliberately-preserved role. + RecordLoginAttempt("failure", reasonPrefix + "_last_admin_demotion_refused"); + return Unauthorized(new { message = lastActiveAdminMessage }); + } + return null; + } + private const string AuthSourceLdap = "Ldap"; /// @@ -941,9 +970,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, ("reason", "windows_ntlm_disabled"), ("source", "Windows"), ("mechanism", authMechanism)), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_ntlm_disabled")); + RecordLoginAttempt("failure", "windows_ntlm_disabled"); return Unauthorized(new { message = "Kerberos required — NTLM fallback is disabled. Verify your client has a Kerberos ticket and the SPN is registered." @@ -1014,41 +1041,14 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", null, GroupSids: snapshot.GroupSids); var mapping = await _externalUserMapper.MapAsync(ldapResult, AuthProvider.Windows, ct); - if (mapping.Result == ExternalUserMapResult.RefusedUsernameCollision) + if (MapExternalIdentityRefusal( + mapping.Result, + "windows", + collisionMessage: "Windows authentication refused — username collision.", + identityRefusedMessage: "Windows authentication refused.", + lastActiveAdminMessage: "Windows authentication refused because the directory mapping would remove the last active Admin.") is { } refused) { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_username_collision")); - return Unauthorized(new { message = "Windows authentication refused — username collision." }); - } - if (mapping.Result is ExternalUserMapResult.RefusedIdentityConflict - or ExternalUserMapResult.RefusedTombstoned - or ExternalUserMapResult.RefusedDirectoryAccess) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_identity_refused")); - return Unauthorized(new { message = "Windows authentication refused." }); - } - if (mapping.Result == ExternalUserMapResult.RefusedBootstrapNotAdmin) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_bootstrap_refused")); - return Unauthorized(new - { - message = "Admin bootstrap required: bootstrap a local break-glass Admin first using the X-Setup-Token header.", - }); - } - if (mapping.Result == ExternalUserMapResult.RefusedLastActiveAdmin) - { - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "windows_last_admin_demotion_refused")); - return Unauthorized(new - { - message = "Windows authentication refused because the directory mapping would remove the last active Admin.", - }); + return refused; } var user = mapping.User!; @@ -1056,9 +1056,7 @@ or ExternalUserMapResult.RefusedTombstoned { await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, AuditDetails.Json(("username", user.Username), ("reason", "account_disabled"), ("source", "Windows")), ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "failure"), - new KeyValuePair("reason", "disabled")); + RecordLoginAttempt("failure", "disabled"); return Unauthorized(new { message = "Account is disabled" }); } @@ -1070,9 +1068,7 @@ await _audit.LogAsync(AuditActions.LoginFailed, "User", user.Id, } var session = await _sessionIssuer.IssueAsync(user, NodePilot.Api.Security.AuthSource.Windows, HttpContext, ct); - ApiMetrics.AuthLoginAttempts.Add(1, - new KeyValuePair("result", "success"), - new KeyValuePair("reason", "windows_ok")); + RecordLoginAttempt("success", "windows_ok"); // Windows SSO is browser-only and is driven by ambient OS credentials via the Negotiate // handshake — i.e. an XSS could trigger it without knowing any secret. So, unlike the // password-gated login paths, the token is NEVER returned in the body here: always @@ -1129,23 +1125,9 @@ public async Task Logout(CancellationToken ct) return NoContent(); } - long.TryParse(expClaim, out var expSec); - var expiresAt = expSec > 0 - ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime - : DateTime.UtcNow.Add(TokenLifetime); - // Idempotent: if the jti is already revoked we leave it as is. - var existing = await _db.RevokedTokens.FindAsync([jti], ct); - if (existing is null) + if (await TryStageTokenRevocationAsync(jti, userId, expClaim, "user-logout", ct)) { - _db.RevokedTokens.Add(new RevokedToken - { - Jti = jti, - UserId = userId, - RevokedAt = DateTime.UtcNow, - ExpiresAt = expiresAt, - Reason = "user-logout", - }); ApiMetrics.AuthTokenRevocations.Add(1, new KeyValuePair("reason", "user-logout")); } @@ -1219,28 +1201,12 @@ public async Task> Refresh(CancellationToken ct) // that's the intended behavior. var presentedJti = User.FindFirstValue(JwtRegisteredClaimNames.Jti); var expClaim = User.FindFirstValue("exp"); - if (!string.IsNullOrEmpty(presentedJti)) + if (!string.IsNullOrEmpty(presentedJti) + && await TryStageTokenRevocationAsync(presentedJti, id, expClaim, "rotated", ct)) { - long.TryParse(expClaim, out var expSec); - var expiresAt = expSec > 0 - ? DateTimeOffset.FromUnixTimeSeconds(expSec).UtcDateTime - : DateTime.UtcNow.Add(TokenLifetime); - - var existing = await _db.RevokedTokens.FindAsync([presentedJti], ct); - if (existing is null) - { - _db.RevokedTokens.Add(new RevokedToken - { - Jti = presentedJti, - UserId = id, - RevokedAt = DateTime.UtcNow, - ExpiresAt = expiresAt, - Reason = "rotated", - }); - await _db.SaveChangesAsync(ct); - ApiMetrics.AuthTokenRevocations.Add(1, - new KeyValuePair("reason", "rotated")); - } + await _db.SaveChangesAsync(ct); + ApiMetrics.AuthTokenRevocations.Add(1, + new KeyValuePair("reason", "rotated")); } // Cookies were already rotated by RefreshAsync above (np_auth + np_csrf both set diff --git a/src/NodePilot.Api/Controllers/CustomActivitiesController.cs b/src/NodePilot.Api/Controllers/CustomActivitiesController.cs index b294e353..898b202a 100644 --- a/src/NodePilot.Api/Controllers/CustomActivitiesController.cs +++ b/src/NodePilot.Api/Controllers/CustomActivitiesController.cs @@ -63,25 +63,18 @@ public async Task>> Ge [Authorize(Roles = "Admin,Operator")] public async Task> Create(CreateCustomActivityRequest request, CancellationToken ct) { - var inputs = request.Inputs ?? []; - var outputs = request.Outputs ?? []; - var icon = string.IsNullOrWhiteSpace(request.Icon) ? "extension" : request.Icon!; - var engine = string.IsNullOrWhiteSpace(request.Engine) ? "auto" : request.Engine!; + var (icon, engine, inputs, outputs) = NormalizePayload(request.Icon, request.Engine, request.Inputs, request.Outputs); var error = CustomActivityValidation.Validate(request.Key, request.Name, icon, engine, inputs, outputs, requireKey: true); if (error is not null) return BadRequest(new { message = error }); if (string.IsNullOrWhiteSpace(request.ScriptTemplate)) return BadRequest(new { message = "ScriptTemplate is required." }); - var newInput = new CustomActivityDefinitionInput - { - Key = request.Key, Name = request.Name, Description = request.Description, Icon = icon, Color = request.Color, - ScriptTemplate = request.ScriptTemplate, Engine = engine, RunsRemote = request.RunsRemote, Isolated = request.Isolated, - MemoryLimitMb = request.MemoryLimitMb, MaxProcesses = request.MaxProcesses, - DefaultTimeoutSeconds = request.DefaultTimeoutSeconds, SuccessExitCodes = request.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - }; + var newInput = BuildDefinitionInput( + request.Key, request.Name, request.Description, icon, request.Color, + request.ScriptTemplate, engine, request.RunsRemote, request.Isolated, + request.MemoryLimitMb, request.MaxProcesses, request.DefaultTimeoutSeconds, request.SuccessExitCodes, + inputs, outputs); CustomActivityDefinition def; try { def = await store.CreateAsync(newInput, this.GetCurrentUsername(), ct); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } @@ -101,26 +94,18 @@ public async Task> Update(Guid id, Upda if (existing is null) return NotFound(); if (MutationForbidden(existing, out var forbid)) return forbid!; - var inputs = request.Inputs ?? []; - var outputs = request.Outputs ?? []; - var icon = string.IsNullOrWhiteSpace(request.Icon) ? "extension" : request.Icon!; - var engine = string.IsNullOrWhiteSpace(request.Engine) ? "auto" : request.Engine!; + var (icon, engine, inputs, outputs) = NormalizePayload(request.Icon, request.Engine, request.Inputs, request.Outputs); var error = CustomActivityValidation.Validate(null, request.Name, icon, engine, inputs, outputs, requireKey: false); if (error is not null) return BadRequest(new { message = error }); if (string.IsNullOrWhiteSpace(request.ScriptTemplate)) return BadRequest(new { message = "ScriptTemplate is required." }); - var updInput = new CustomActivityDefinitionInput - { - Key = existing.Key, Name = request.Name, Description = request.Description, Icon = icon, Color = request.Color, - ScriptTemplate = request.ScriptTemplate, Engine = engine, RunsRemote = request.RunsRemote, Isolated = request.Isolated, - MemoryLimitMb = request.MemoryLimitMb, MaxProcesses = request.MaxProcesses, - DefaultTimeoutSeconds = request.DefaultTimeoutSeconds, SuccessExitCodes = request.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - ChangeNote = request.ChangeNote, - }; + var updInput = BuildDefinitionInput( + existing.Key, request.Name, request.Description, icon, request.Color, + request.ScriptTemplate, engine, request.RunsRemote, request.Isolated, + request.MemoryLimitMb, request.MaxProcesses, request.DefaultTimeoutSeconds, request.SuccessExitCodes, + inputs, outputs, request.ChangeNote); CustomActivityDefinition def; try { def = await store.UpdateAsync(id, updInput, request.ConcurrencyToken, this.GetCurrentUsername(), ct); } catch (KeyNotFoundException) { return NotFound(); } @@ -220,24 +205,17 @@ public async Task>> Import(Cu var imported = new List(); foreach (var item in envelope.Items) { - var icon = string.IsNullOrWhiteSpace(item.Icon) ? "extension" : item.Icon; - var engine = string.IsNullOrWhiteSpace(item.Engine) ? "auto" : item.Engine; - var inputs = item.Inputs ?? []; - var outputs = item.Outputs ?? []; + var (icon, engine, inputs, outputs) = NormalizePayload(item.Icon, item.Engine, item.Inputs, item.Outputs); if (CustomActivityValidation.Validate(item.Key, item.Name, icon, engine, inputs, outputs, requireKey: true) is not null) continue; // skip malformed entries if (await store.GetByKeyAsync(item.Key, ct) is not null) continue; // skip key collisions - var input = new CustomActivityDefinitionInput - { - Key = item.Key, Name = item.Name, Description = item.Description, Icon = icon, Color = item.Color, - ScriptTemplate = item.ScriptTemplate, Engine = engine, RunsRemote = item.RunsRemote, Isolated = item.Isolated, - MemoryLimitMb = item.MemoryLimitMb, MaxProcesses = item.MaxProcesses, - DefaultTimeoutSeconds = item.DefaultTimeoutSeconds, SuccessExitCodes = item.SuccessExitCodes, - InputParametersJson = CustomActivityParameters.Serialize(inputs), - OutputParametersJson = CustomActivityParameters.Serialize(outputs), - }; + var input = BuildDefinitionInput( + item.Key, item.Name, item.Description, icon, item.Color, + item.ScriptTemplate, engine, item.RunsRemote, item.Isolated, + item.MemoryLimitMb, item.MaxProcesses, item.DefaultTimeoutSeconds, item.SuccessExitCodes, + inputs, outputs); var def = await store.CreateAsync(input, this.GetCurrentUsername(), ct); // created disabled await audit.LogAsync(AuditActions.CustomActivityImported, "CustomActivity", def.Id, AuditDetails.Json(("key", def.Key)), ct); @@ -263,6 +241,44 @@ private bool MutationForbidden(CustomActivityDefinition def, out ActionResult? r return false; } + /// + /// Applies the defaults every write payload shares: icon falls back to "extension", engine to + /// "auto", absent parameter lists to empty. Validation and persistence must see the same + /// normalised values, so this runs ahead of both. + /// + private static (string Icon, string Engine, + IReadOnlyList Inputs, + IReadOnlyList Outputs) NormalizePayload( + string? icon, string? engine, + IReadOnlyList? inputs, + IReadOnlyList? outputs) + => (string.IsNullOrWhiteSpace(icon) ? "extension" : icon!, + string.IsNullOrWhiteSpace(engine) ? "auto" : engine!, + inputs ?? [], + outputs ?? []); + + /// + /// Single mapper onto the store's input record. Create, Update and Import persist the exact same + /// shape from three different payload types; only the key source (request vs. existing row) and + /// the change note differ, which is why those are parameters. + /// + private static CustomActivityDefinitionInput BuildDefinitionInput( + string key, string name, string? description, string icon, string? color, + string scriptTemplate, string engine, bool runsRemote, bool isolated, + int? memoryLimitMb, int? maxProcesses, int? defaultTimeoutSeconds, string? successExitCodes, + IReadOnlyList inputs, + IReadOnlyList outputs, + string? changeNote = null) => new() + { + Key = key, Name = name, Description = description, Icon = icon, Color = color, + ScriptTemplate = scriptTemplate, Engine = engine, RunsRemote = runsRemote, Isolated = isolated, + MemoryLimitMb = memoryLimitMb, MaxProcesses = maxProcesses, + DefaultTimeoutSeconds = defaultTimeoutSeconds, SuccessExitCodes = successExitCodes, + InputParametersJson = CustomActivityParameters.Serialize(inputs), + OutputParametersJson = CustomActivityParameters.Serialize(outputs), + ChangeNote = changeNote, + }; + private static IReadOnlyList Lint(string script) => WorkflowScriptLinter.LintScript(script).Select(w => new CustomActivityLintWarning(w.Rule, w.Message)).ToList(); diff --git a/src/NodePilot.Api/Controllers/DashboardController.cs b/src/NodePilot.Api/Controllers/DashboardController.cs index dd227cab..d0eedbba 100644 --- a/src/NodePilot.Api/Controllers/DashboardController.cs +++ b/src/NodePilot.Api/Controllers/DashboardController.cs @@ -53,18 +53,13 @@ public async Task> Get(CancellationToken ct, [FromQ // accessible-folder set once and reuse for every workflow + execution query // below. Global Admin gets the unrestricted set and skips filtering. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var workflowQuery = _db.Workflows.AsNoTracking().AsQueryable(); - var execQuery = _db.WorkflowExecutions.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) + var workflowQuery = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + var execQuery = _db.WorkflowExecutions.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (workflowQuery is null || execQuery is null) { - if (accessible.FolderIds.Count == 0) - { - // User has zero folder access — return an empty dashboard rather than a - // potentially confusing partial one. - return Ok(EmptyStats(sinceWindow, windowHours, NormalizeProvider(_db.Database.ProviderName), GetClusterRole(), GetLlmEnabled())); - } - workflowQuery = workflowQuery.Where(w => accessible.FolderIds.Contains(w.FolderId)); - execQuery = execQuery.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + // User has zero folder access — return an empty dashboard rather than a + // potentially confusing partial one. + return Ok(EmptyStats(sinceWindow, windowHours, NormalizeProvider(_db.Database.ProviderName), GetClusterRole(), GetLlmEnabled())); } // TriggerTypesJson, not DefinitionJson. The definition is unbounded text holding the whole diff --git a/src/NodePilot.Api/Controllers/DbAdminController.cs b/src/NodePilot.Api/Controllers/DbAdminController.cs index 4845ac9a..1172b641 100644 --- a/src/NodePilot.Api/Controllers/DbAdminController.cs +++ b/src/NodePilot.Api/Controllers/DbAdminController.cs @@ -1,4 +1,3 @@ -using System.Security.Claims; using System.Security.Cryptography; using System.Data; using System.Text; @@ -6,6 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Security; using NodePilot.Api.Services.DbAdmin; @@ -227,7 +227,7 @@ private async Task PatchRowCore( // transaction-scoped invariant gate established by PatchRow. if (entity is User user) { - var callerId = GetCallerId(); + var callerId = this.GetCurrentUserId(); if (callerId is null) return Unauthorized(); var guard = await DbAdminPolicy.PreUpdateUserGuardAsync(user, req.Column, coercedValue, _db, callerId.Value, ct); if (guard.IsBlocked) return BadRequest(new { code = guard.Code, message = guard.Message }); @@ -267,8 +267,7 @@ private async Task PatchRowCore( // so the row mutation and the audit row are atomic. Routes through IAuditStager // (same as every other audit-write path) so redaction + 4 KiB cap apply uniformly. var pkDisplay = string.Join(";", pk); - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); var auditEntry = _stager.Build( action: AuditActions.DbAdminRowUpdated, actor: auditActor, @@ -382,7 +381,7 @@ private async Task DeleteRowCore( // transaction-scoped invariant gate established by DeleteRow. if (entity is User user) { - var callerId = GetCallerId(); + var callerId = this.GetCurrentUserId(); if (callerId is null) return Unauthorized(); var guard = await DbAdminPolicy.PreDeleteUserGuardAsync(user, _db, callerId.Value, ct); if (guard.IsBlocked) return BadRequest(new { code = guard.Code, message = guard.Message }); @@ -392,8 +391,7 @@ private async Task DeleteRowCore( _db.Remove(entity); var pkDisplay = string.Join(";", pk); - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); var auditEntry = _stager.Build( action: AuditActions.DbAdminRowDeleted, actor: auditActor, @@ -610,8 +608,7 @@ private async Task WriteQueryAuditAsync( string? reason, CancellationToken ct) { - var auditActor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var auditActor = CurrentAuditActor(); // The stager already caps details at 4 KiB, so even pathological SQL pastes are bounded // in audit storage. The full statement is represented by a stable hash + byte length + @@ -656,8 +653,7 @@ private async Task WriteRowsViewedAuditAsync( int returned, CancellationToken ct) { - var actor = new AuditActor(GetCallerId(), User.FindFirstValue(ClaimTypes.Name), - HttpContext?.Connection?.RemoteIpAddress?.ToString()); + var actor = CurrentAuditActor(); var entry = _stager.Build( AuditActions.DbAdminRowsViewed, actor, @@ -720,11 +716,10 @@ private async Task CountRowsAsync(Type clrType, CancellationToken ct) } } - private Guid? GetCallerId() - { - var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); - return Guid.TryParse(raw, out var id) ? id : null; - } + /// Actor for every DB-admin audit row: caller identity plus the remote address. + private AuditActor CurrentAuditActor() => new( + this.GetCurrentUserId(), this.GetCurrentUsername(), + HttpContext?.Connection?.RemoteIpAddress?.ToString()); private static Guid? TryParseGuid(string? s) => s is not null && Guid.TryParse(s, out var g) ? g : null; diff --git a/src/NodePilot.Api/Controllers/DiagnosticsController.cs b/src/NodePilot.Api/Controllers/DiagnosticsController.cs index d3c89d11..c222fe40 100644 --- a/src/NodePilot.Api/Controllers/DiagnosticsController.cs +++ b/src/NodePilot.Api/Controllers/DiagnosticsController.cs @@ -8,6 +8,7 @@ using NodePilot.Api.Dtos; using NodePilot.Core.Audit; using NodePilot.Data; +using NodePilot.Api.Export; namespace NodePilot.Api.Controllers; @@ -406,14 +407,14 @@ await writer.WriteLineAsync(JsonSerializer.Serialize(new sb.Append(row.Id).Append(',') .Append(row.Timestamp.ToString("O")).Append(',') .Append(row.Level).Append(','); - CsvField(sb, row.EventType); sb.Append(','); - CsvField(sb, row.WorkflowName); sb.Append(','); - CsvField(sb, row.ExecutionShort); sb.Append(','); - CsvField(sb, row.StepLabel); sb.Append(','); - CsvField(sb, row.ActivityType); sb.Append(','); - CsvField(sb, row.UserName); sb.Append(','); - CsvField(sb, row.Message); sb.Append(','); - CsvField(sb, row.PropertiesJson); + CsvWriter.Field(sb, row.EventType); sb.Append(','); + CsvWriter.Field(sb, row.WorkflowName); sb.Append(','); + CsvWriter.Field(sb, row.ExecutionShort); sb.Append(','); + CsvWriter.Field(sb, row.StepLabel); sb.Append(','); + CsvWriter.Field(sb, row.ActivityType); sb.Append(','); + CsvWriter.Field(sb, row.UserName); sb.Append(','); + CsvWriter.Field(sb, row.Message); sb.Append(','); + CsvWriter.Field(sb, row.PropertiesJson); await writer.WriteLineAsync(sb); } @@ -437,19 +438,5 @@ await _audit.LogAsync( ("exported", batch)), ct); } - - private static void CsvField(StringBuilder sb, string? value) - { - if (string.IsNullOrEmpty(value)) return; - var needsQuoting = value.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0; - if (!needsQuoting) { sb.Append(value); return; } - sb.Append('"'); - foreach (var c in value) - { - if (c == '"') sb.Append("\"\""); - else sb.Append(c); - } - sb.Append('"'); - } } diff --git a/src/NodePilot.Api/Controllers/ExecutionsController.cs b/src/NodePilot.Api/Controllers/ExecutionsController.cs index 74d50d43..9e173866 100644 --- a/src/NodePilot.Api/Controllers/ExecutionsController.cs +++ b/src/NodePilot.Api/Controllers/ExecutionsController.cs @@ -55,12 +55,9 @@ public ExecutionsController( IQueryable query, CancellationToken ct) { var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - if (accessible.IsUnrestricted) return query; - if (accessible.FolderIds.Count == 0) - return query.Where(_ => false); - // Inner-join semantics: pulls each execution's workflow folder via the navigation - // property. Translates to a single JOIN on Postgres + SQL Server. - return query.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + // Zero folder access has no dedicated response on the list endpoints — an always-false + // query keeps the rest of the pipeline (paging, projections) intact. + return query.ScopeToAccessibleFolders(accessible) ?? query.Where(_ => false); } /// diff --git a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs index 5d7a1b89..15020207 100644 --- a/src/NodePilot.Api/Controllers/ExternalTriggerController.cs +++ b/src/NodePilot.Api/Controllers/ExternalTriggerController.cs @@ -76,6 +76,19 @@ public ExternalTriggerController( _redactor.Redact(execution.ReturnData), _redactor.Redact(execution.InputParametersJson)); + /// + /// Answers an idempotency replay: flag the response, count the cached hit, hand back the + /// original execution. Three call sites reach it (pre-check, unique-violation race, and the + /// replay decided inside the transaction) and they must answer identically. + /// + private OkObjectResult IdempotentReplay(WorkflowExecution replay) + { + Response.Headers["Idempotent-Replayed"] = "true"; + NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, + new KeyValuePair("result", "cached")); + return Ok(ToResponse(replay)); + } + private static async Task FindIdempotencyReplayAsync( NodePilotDbContext db, string idempotencyKey, @@ -204,12 +217,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); if (replay is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(replay)); - } + return IdempotentReplay(replay); } @@ -314,12 +322,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" _db.ChangeTracker.Clear(); var replay = await FindIdempotencyReplayAsync(_db, idempotencyKey, workflow.Id, ct); if (replay is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(replay)); - } + return IdempotentReplay(replay); return Conflict(new { message = "Idempotency-Key is currently being processed; retry with the same key." }); } @@ -327,12 +330,7 @@ await _audit.LogAsync(AuditActions.ExecutionBlockedMaintenanceWindow, "Workflow" // Replay is decided inside the transaction but answered out here: an early return // from within strategy.ExecuteAsync would escape the retry unit. if (outcome.Replayed is not null) - { - Response.Headers["Idempotent-Replayed"] = "true"; - NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, - new KeyValuePair("result", "cached")); - return Ok(ToResponse(outcome.Replayed)); - } + return IdempotentReplay(outcome.Replayed); pending = outcome.Fresh!; NodePilot.Api.Telemetry.ApiMetrics.IdempotencyKeyHits.Add(1, diff --git a/src/NodePilot.Api/Controllers/FolderScopedQueries.cs b/src/NodePilot.Api/Controllers/FolderScopedQueries.cs new file mode 100644 index 00000000..4165e1b8 --- /dev/null +++ b/src/NodePilot.Api/Controllers/FolderScopedQueries.cs @@ -0,0 +1,34 @@ +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; + +namespace NodePilot.Api.Controllers; + +/// +/// Folder-RBAC scoping for the list/aggregate endpoints. The three-step shape — global Admin +/// passes through unrestricted, a caller with zero accessible folders is a dead end, everyone +/// else gets the folder IN-filter — was restated at every call site. Only the dead-end answer +/// differs per endpoint (404, empty list, empty dashboard, always-false query), so it stays with +/// the caller: a null return means "this caller can read no folder at all". +/// +internal static class FolderScopedQueries +{ + public static IQueryable? ScopeToAccessibleFolders( + this IQueryable query, AccessibleFolderSet accessible) + { + if (accessible.IsUnrestricted) return query; + if (accessible.FolderIds.Count == 0) return null; + return query.Where(w => accessible.FolderIds.Contains(w.FolderId)); + } + + /// + /// Execution variant. Inner-join semantics: pulls each execution's workflow folder via the + /// navigation property. Translates to a single JOIN on Postgres + SQL Server. + /// + public static IQueryable? ScopeToAccessibleFolders( + this IQueryable query, AccessibleFolderSet accessible) + { + if (accessible.IsUnrestricted) return query; + if (accessible.FolderIds.Count == 0) return null; + return query.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); + } +} diff --git a/src/NodePilot.Api/Controllers/OperationsController.cs b/src/NodePilot.Api/Controllers/OperationsController.cs index bbfe426b..5372a079 100644 --- a/src/NodePilot.Api/Controllers/OperationsController.cs +++ b/src/NodePilot.Api/Controllers/OperationsController.cs @@ -119,15 +119,10 @@ public async Task> GetGraph( // RBAC: resolve the accessible-folder set once and scope every query to it. Global Admin // is unrestricted and skips the filter; a user with zero folder access gets an empty graph. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var workflowQuery = _db.Workflows.AsNoTracking().AsQueryable(); - var execQuery = _db.WorkflowExecutions.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) - return Ok(new OperationsGraphDto([], [], [], [], [], emptyMeta)); - workflowQuery = workflowQuery.Where(w => accessible.FolderIds.Contains(w.FolderId)); - execQuery = execQuery.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); - } + var workflowQuery = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + var execQuery = _db.WorkflowExecutions.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (workflowQuery is null || execQuery is null) + return Ok(new OperationsGraphDto([], [], [], [], [], emptyMeta)); // Deliberately WITHOUT DefinitionJson. Definitions are unbounded text including every // inline script (21-42 KB apiece in the repo's example set) and the only thing this endpoint diff --git a/src/NodePilot.Api/Controllers/SystemAlertingController.cs b/src/NodePilot.Api/Controllers/SystemAlertingController.cs index b6e92761..3b7048da 100644 --- a/src/NodePilot.Api/Controllers/SystemAlertingController.cs +++ b/src/NodePilot.Api/Controllers/SystemAlertingController.cs @@ -204,39 +204,7 @@ public async Task> TestFire(Guid id, Cancellation if (policy is null) return NotFound(); var ctx = BuildSampleContext(policy); - var now = DateTime.UtcNow; - var results = new List(); - - foreach (var route in policy.Routes) - { - var attempt = new NotificationDeliveryAttempt - { - Id = Guid.NewGuid(), - NotificationRuleId = policy.Id, - NotificationRouteId = route.Id, - EventKey = $"test:{Guid.NewGuid():N}", - DedupKey = $"test:{policy.Id}", - IsTest = true, - Attempt = 1, - CreatedAt = now, - SentAt = now, - }; - - NotificationSendResult result; - if (!_sinks.TryGetValue(route.Channel, out var sink)) - result = NotificationSendResult.Fail($"no sink registered for channel {route.Channel}"); - else - { - var secret = string.IsNullOrEmpty(route.Secret) ? null : await _store.GetRouteSecretAsync(route.Id, ct); - result = await sink.SendAsync(ctx, route.Target, secret, ct); - } - - attempt.Status = result.Success ? NotificationDeliveryStatus.Sent : NotificationDeliveryStatus.Failed; - attempt.Error = result.Error; - attempt.Summary = $"[test] {route.Channel}:{route.Target}"; - _db.NotificationDeliveryAttempts.Add(attempt); - results.Add(new TestFireRouteResult(route.Channel.ToString(), route.Target, result.Success, result.Error)); - } + var results = await AlertingRuleMapping.DeliverTestFireAsync(_db, _store, _sinks, policy, ctx, ct); await _db.SaveChangesAsync(ct); await _audit.LogAsync(AuditActions.SystemAlertPolicyTestFired, "SystemAlertPolicy", policy.Id, @@ -290,48 +258,13 @@ private bool TryBuildDraft(SaveSystemAlertPolicyRequest request, out Notificatio return Bad("minOccurrences must be at least 1", out problem); // Routes: required only to enable (a draft policy may be saved disabled with no route). - var mappedRoutes = new List(); if (request.IsEnabled && (request.Routes is null || request.Routes.Count == 0)) return Bad("An enabled policy requires at least one route.", out problem); - var order = 0; - foreach (var rt in request.Routes ?? []) - { - if (!Enum.TryParse(rt.Channel, ignoreCase: true, out var channel)) - return Bad($"Invalid channel '{rt.Channel}'", out problem); - if (!_sinks.ContainsKey(channel)) - return Bad($"No delivery sink is registered for channel '{rt.Channel}' (available: {string.Join(", ", _sinks.Keys)})", out problem); - if (string.IsNullOrWhiteSpace(rt.Target)) - return Bad("Each route requires a target", out problem); - if (!NotificationRuleSemantics.TryValidateConditionJson(rt.ConditionExpressionJson, out var routeConditionError)) - return Bad($"route conditionExpressionJson {routeConditionError}", out problem); - mappedRoutes.Add(new NotificationRoute - { - Id = rt.Id ?? Guid.Empty, - Channel = channel, - Target = rt.Target.Trim(), - Secret = rt.Secret, - ConditionExpressionJson = string.IsNullOrWhiteSpace(rt.ConditionExpressionJson) ? null : rt.ConditionExpressionJson, - Order = order++, - }); - } + if (!AlertingRuleMapping.TryMapRoutes(request.Routes, _sinks, out var mappedRoutes, out var routeError)) + return Bad(routeError!, out problem); - var mappedTargets = new List(); - if (scope != NotificationScopeKind.Global) - { - var expectedKind = scope == NotificationScopeKind.Folders ? NotificationTargetKind.Folder : NotificationTargetKind.Workflow; - if (request.Targets is null || request.Targets.Count == 0) - return Bad($"{scope} policies require at least one target", out problem); - foreach (var t in request.Targets) - { - if (!Enum.TryParse(t.TargetKind, ignoreCase: true, out var kind)) - return Bad($"Invalid target kind '{t.TargetKind}'", out problem); - if (kind != expectedKind) - return Bad($"{scope} policies may only contain {expectedKind} targets", out problem); - if (t.TargetId == Guid.Empty) - return Bad("Target id must not be empty", out problem); - mappedTargets.Add(new NotificationRuleTarget { TargetKind = kind, TargetId = t.TargetId }); - } - } + if (!AlertingRuleMapping.TryMapScopeTargets(scope, request.Targets, "policies", out var mappedTargets, out var targetError)) + return Bad(targetError!, out problem); draft = new NotificationRule { diff --git a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs index 6ebcd638..2df676c9 100644 --- a/src/NodePilot.Api/Controllers/WorkflowEditingController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowEditingController.cs @@ -1,7 +1,9 @@ +using System.Linq.Expressions; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; using NodePilot.Api.Audit; using NodePilot.Core.Audit; using NodePilot.Api.Dtos; @@ -43,6 +45,87 @@ public WorkflowEditingController( _testContextProvider = testContextProvider; } + // --- Optimistic-concurrency core (shared by Publish + Rollback) ------------------------ + + /// + /// M-3 (security audit 2026-05-15): field mutation + history snapshot inside one + /// execution-strategy transaction, with the workflow UPDATE an atomic compare-and-swap on + /// (folder, lock owner, lock timestamp, version). This closes two TOCTOU windows the old + /// load→check→SaveChanges left open: (a) a force-unlock + re-lock by another user landing + /// between the in-memory EnsureWriteLockAsync check and the write (lock-theft), and + /// (b) a concurrent publish/update bumping the version (lost-update). A return value of 0 + /// means one of those raced us — the caller re-reads via + /// to return the right 423/409 verdict. + /// + /// Publish and Rollback differ only in the setter chain and the snapshot row, so both + /// come in as parameters. The snapshot is a factory, not an instance: every retry of + /// the execution strategy clears the change tracker and must stage a fresh row. + /// + private Task CompareAndSwapWorkflowAsync( + Guid id, + Workflow workflow, + Guid? meId, + int oldVersion, + Action> setters, + Func snapshot, + CancellationToken ct) + { + var strategy = _db.Database.CreateExecutionStrategy(); + return strategy.ExecuteAsync(async () => + { + _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt + await using var tx = await _db.Database.BeginTransactionAsync(ct); + var rows = await _db.Workflows + .Where(w => w.Id == id + && w.FolderId == workflow.FolderId + && w.CheckedOutByUserId == meId + && w.CheckedOutAt == workflow.CheckedOutAt + && w.Version == oldVersion) + .ExecuteUpdateAsync(setters, ct); + if (rows == 0) + { + await tx.RollbackAsync(ct); + return 0; + } + _db.WorkflowVersions.Add(snapshot()); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return rows; + }); + } + + /// + /// The compare-and-swap matched no row: re-read and answer with whichever verdict actually + /// applies now — 404 if the workflow vanished, the access gate's verdict if permissions + /// changed, 423 if the lock moved, otherwise the version conflict. + /// + private async Task LostCompareAndSwapAsync(Guid id, string conflictMessage, CancellationToken ct) + { + var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); + if (current is null) return NotFound(); + if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) + return deniedNow; + if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; + return Conflict(new + { + code = "workflow_version_conflict", + message = conflictMessage, + currentVersion = current.Version, + }); + } + + /// Same conflict body, but for the unique-index violation raised by a racing insert. + private async Task VersionUniqueConflictAsync(Guid id, string conflictMessage, CancellationToken ct) + => Conflict(new + { + code = "workflow_version_conflict", + message = conflictMessage, + currentVersion = await _db.Workflows.AsNoTracking() + .Where(w => w.Id == id) + .Select(w => w.Version) + .FirstOrDefaultAsync(ct), + }); + // --- Versions / Rollback -------------------------------------------------------------- [HttpGet("{id:guid}/versions")] @@ -134,40 +217,26 @@ public async Task> Rollback( var computed = new Workflow { DefinitionJson = target.DefinitionJson }; PopulateComputedColumns(computed); - // M-3 (security audit 2026-05-15): roll-forward (snapshot live row, apply target as a new - // version) inside one execution-strategy transaction, with the workflow UPDATE an atomic - // compare-and-swap on (lock-owner == me, version == oldVersion). The lock is intentionally - // retained (rollback ≠ publish). The CAS closes the lock-theft / lost-update TOCTOU the old - // load→check→SaveChanges left open. + // Roll-forward: snapshot the live row, apply the target as a new version — through the + // shared compare-and-swap (see CompareAndSwapWorkflowAsync). The lock is intentionally + // retained here (rollback ≠ publish), which is why the setter chain clears no lock fields. + const string rollbackConflictMessage = + "Workflow was updated concurrently. Reload the workflow and retry publish."; int updated; try { - var strategy = _db.Database.CreateExecutionStrategy(); - updated = await strategy.ExecuteAsync(async () => - { - _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt - await using var tx = await _db.Database.BeginTransactionAsync(ct); - var rows = await _db.Workflows - .Where(w => w.Id == id - && w.FolderId == workflow.FolderId - && w.CheckedOutByUserId == meId - && w.CheckedOutAt == workflow.CheckedOutAt - && w.Version == oldVersion) - .ExecuteUpdateAsync(s => s - .SetProperty(w => w.Name, target.Name) - .SetProperty(w => w.Description, target.Description) - .SetProperty(w => w.DefinitionJson, target.DefinitionJson) - .SetProperty(w => w.Version, oldVersion + 1) - .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) - .SetProperty(w => w.ActivityCount, computed.ActivityCount) - .SetProperty(w => w.UpdatedAt, now) - .SetProperty(w => w.UpdatedBy, updatedBy), ct); - if (rows == 0) - { - await tx.RollbackAsync(ct); - return 0; - } - _db.WorkflowVersions.Add(new WorkflowVersion + updated = await CompareAndSwapWorkflowAsync( + id, workflow, meId, oldVersion, + s => s + .SetProperty(w => w.Name, target.Name) + .SetProperty(w => w.Description, target.Description) + .SetProperty(w => w.DefinitionJson, target.DefinitionJson) + .SetProperty(w => w.Version, oldVersion + 1) + .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) + .SetProperty(w => w.ActivityCount, computed.ActivityCount) + .SetProperty(w => w.UpdatedAt, now) + .SetProperty(w => w.UpdatedBy, updatedBy), + () => new WorkflowVersion { Id = Guid.NewGuid(), WorkflowId = id, @@ -178,39 +247,16 @@ public async Task> Rollback( CreatedAt = now, CreatedBy = updatedBy, ChangeNote = $"Superseded by rollback to v{version}", - }); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return rows; - }); + }, + ct); } catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) == true) { - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was updated concurrently. Reload the workflow and retry publish.", - currentVersion = await _db.Workflows.AsNoTracking() - .Where(w => w.Id == id) - .Select(w => w.Version) - .FirstOrDefaultAsync(ct), - }); + return await VersionUniqueConflictAsync(id, rollbackConflictMessage, ct); } if (updated == 0) - { - var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); - if (current is null) return NotFound(); - if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) - return deniedNow; - if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was updated concurrently. Reload the workflow and retry publish.", - currentVersion = current.Version, - }); - } + return await LostCompareAndSwapAsync(id, rollbackConflictMessage, ct); var rolled = await _db.Workflows.AsNoTracking().FirstAsync(w => w.Id == id, ct); var reason = string.IsNullOrWhiteSpace(body?.Reason) ? $"Rolled back to v{version}" : body!.Reason!; @@ -437,46 +483,29 @@ public async Task> Publish( var computed = new Workflow { DefinitionJson = request.DefinitionJson }; PopulateComputedColumns(computed); - // M-3 (security audit 2026-05-15): the field mutation + history snapshot run inside one - // execution-strategy transaction, and the workflow UPDATE is an atomic compare-and-swap - // on (lock-owner == me, version == oldVersion). This closes two TOCTOU windows the old - // load→check→SaveChanges left open: (a) a force-unlock + re-lock by another user landing - // between the in-memory EnsureWriteLockAsync check and the write (lock-theft), and (b) a - // concurrent publish/update bumping the version (lost-update). updated==0 ⇒ one of those - // raced us; we re-read to return the right 423/409 verdict. + // Publish through the shared compare-and-swap (see CompareAndSwapWorkflowAsync); the + // setter chain additionally enables the workflow and releases the edit lock. + const string publishConflictMessage = + "Workflow was published concurrently. Reload the workflow and retry publish."; int updated; try { - var strategy = _db.Database.CreateExecutionStrategy(); - updated = await strategy.ExecuteAsync(async () => - { - _db.ChangeTracker.Clear(); // retry-safe: drop any snapshot added by a prior attempt - await using var tx = await _db.Database.BeginTransactionAsync(ct); - var rows = await _db.Workflows - .Where(w => w.Id == id - && w.FolderId == workflow.FolderId - && w.CheckedOutByUserId == meId - && w.CheckedOutAt == workflow.CheckedOutAt - && w.Version == oldVersion) - .ExecuteUpdateAsync(s => s - .SetProperty(w => w.Name, request.Name) - .SetProperty(w => w.Description, request.Description) - .SetProperty(w => w.DefinitionJson, request.DefinitionJson) - .SetProperty(w => w.Version, oldVersion + 1) - .SetProperty(w => w.IsEnabled, true) - .SetProperty(w => w.PublishedByUserId, meId) - .SetProperty(w => w.CheckedOutByUserId, (Guid?)null) - .SetProperty(w => w.CheckedOutAt, (DateTime?)null) - .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) - .SetProperty(w => w.ActivityCount, computed.ActivityCount) - .SetProperty(w => w.UpdatedAt, now) - .SetProperty(w => w.UpdatedBy, updatedBy), ct); - if (rows == 0) - { - await tx.RollbackAsync(ct); - return 0; - } - _db.WorkflowVersions.Add(new WorkflowVersion + updated = await CompareAndSwapWorkflowAsync( + id, workflow, meId, oldVersion, + s => s + .SetProperty(w => w.Name, request.Name) + .SetProperty(w => w.Description, request.Description) + .SetProperty(w => w.DefinitionJson, request.DefinitionJson) + .SetProperty(w => w.Version, oldVersion + 1) + .SetProperty(w => w.IsEnabled, true) + .SetProperty(w => w.PublishedByUserId, meId) + .SetProperty(w => w.CheckedOutByUserId, (Guid?)null) + .SetProperty(w => w.CheckedOutAt, (DateTime?)null) + .SetProperty(w => w.TriggerTypesJson, computed.TriggerTypesJson) + .SetProperty(w => w.ActivityCount, computed.ActivityCount) + .SetProperty(w => w.UpdatedAt, now) + .SetProperty(w => w.UpdatedBy, updatedBy), + () => new WorkflowVersion { Id = Guid.NewGuid(), WorkflowId = id, @@ -486,39 +515,16 @@ public async Task> Publish( DefinitionJson = oldDefinitionJson, CreatedAt = now, CreatedBy = oldCreatedBy ?? updatedBy, - }); - await _db.SaveChangesAsync(ct); - await tx.CommitAsync(ct); - return rows; - }); + }, + ct); } catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase) == true) { - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was published concurrently. Reload the workflow and retry publish.", - currentVersion = await _db.Workflows.AsNoTracking() - .Where(w => w.Id == id) - .Select(w => w.Version) - .FirstOrDefaultAsync(ct), - }); + return await VersionUniqueConflictAsync(id, publishConflictMessage, ct); } if (updated == 0) - { - var current = await _db.Workflows.AsNoTracking().FirstOrDefaultAsync(w => w.Id == id, ct); - if (current is null) return NotFound(); - if (await RequireWorkflowAccessAsync(current, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } deniedNow) - return deniedNow; - if (await EnsureWriteLockAsync(current, ct) is { } lockedNow) return lockedNow; - return Conflict(new - { - code = "workflow_version_conflict", - message = "Workflow was published concurrently. Reload the workflow and retry publish.", - currentVersion = current.Version, - }); - } + return await LostCompareAndSwapAsync(id, publishConflictMessage, ct); var published = await _db.Workflows.AsNoTracking().FirstAsync(w => w.Id == id, ct); await _audit.LogAsync(AuditActions.WorkflowPublished, "Workflow", published.Id, diff --git a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs index 8a4507c7..9c17ad06 100644 --- a/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowImportExportController.cs @@ -44,17 +44,12 @@ public async Task ExportAll(CancellationToken ct) var sw = Stopwatch.StartNew(); // RBAC: export only what the caller may read. Global Admin gets everything. var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); + var all = _db.Workflows.AsNoTracking(); // A restricted user with zero accessible folders still gets a (valid, empty) // envelope rather than an early-return — the audit emission below must run for // the empty case too. An attempted catalogue-pull from a viewer who lost their // last grant is exactly the SIEM signal "WORKFLOW_EXPORTED_BULK count=0" is for. - if (!accessible.IsUnrestricted) - { - query = accessible.FolderIds.Count == 0 - ? query.Where(_ => false) - : query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = all.ScopeToAccessibleFolders(accessible) ?? all.Where(_ => false); var workflows = await query .OrderBy(w => w.Name) .ToListAsync(ct); diff --git a/src/NodePilot.Api/Controllers/WorkflowsController.cs b/src/NodePilot.Api/Controllers/WorkflowsController.cs index 82d42849..91ec60ad 100644 --- a/src/NodePilot.Api/Controllers/WorkflowsController.cs +++ b/src/NodePilot.Api/Controllers/WorkflowsController.cs @@ -69,12 +69,8 @@ public async Task> GetContractByName(stri { if (string.IsNullOrWhiteSpace(name)) return NotFound(); var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) return NotFound(); - query = query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (query is null) return NotFound(); var result = await WorkflowNameResolver.ResolveByNameAsync(query, name, ct); if (result.Outcome == WorkflowNameResolver.Outcome.Ambiguous) return Conflict(new { message = $"Multiple workflows named '{name.Trim()}' — disambiguate with the GUID." }); @@ -99,13 +95,9 @@ public async Task>> GetAll(CancellationToken // RBAC list-filter: collapse to "every workflow whose folder I can read". Global // Admin gets the unrestricted set and skips the IN-clause. var accessibleFolders = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessibleFolders.IsUnrestricted) - { - if (accessibleFolders.FolderIds.Count == 0) - return Ok(new List()); - query = query.Where(w => accessibleFolders.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessibleFolders); + if (query is null) + return Ok(new List()); var workflows = await query .OrderByDescending(w => w.UpdatedAt) .Take(HardLimitWorkflows) @@ -319,12 +311,8 @@ public async Task> GetByName(string name, Cancell { if (string.IsNullOrWhiteSpace(name)) return NotFound(); var accessible = await _authz.GetAccessibleFolderIdsAsync(User, ct); - var query = _db.Workflows.AsNoTracking().AsQueryable(); - if (!accessible.IsUnrestricted) - { - if (accessible.FolderIds.Count == 0) return NotFound(); - query = query.Where(w => accessible.FolderIds.Contains(w.FolderId)); - } + var query = _db.Workflows.AsNoTracking().ScopeToAccessibleFolders(accessible); + if (query is null) return NotFound(); var result = await WorkflowNameResolver.ResolveByNameAsync(query, name, ct); if (result.Outcome == WorkflowNameResolver.Outcome.Ambiguous) return Conflict(new { message = $"Multiple workflows named '{name.Trim()}' — disambiguate with the GUID." }); diff --git a/src/NodePilot.Api/Export/CsvWriter.cs b/src/NodePilot.Api/Export/CsvWriter.cs new file mode 100644 index 00000000..1a0f82ae --- /dev/null +++ b/src/NodePilot.Api/Export/CsvWriter.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace NodePilot.Api.Export; + +/// +/// CSV escaping shared by every controller that streams a table export (audit log, support +/// events). Kept in one place so the two exports cannot drift into different quoting rules — +/// a consumer that parses both would otherwise see the same value escaped two ways. +/// +public static class CsvWriter +{ + /// + /// RFC 4180 minimal CSV escaping: only quote when the value contains a comma, quote, + /// or newline; double internal quotes. NULL and empty render as empty (no quotes). + /// + public static void Field(StringBuilder sb, string? value) + { + if (string.IsNullOrEmpty(value)) return; + var needsQuoting = value.IndexOfAny([',', '"', '\n', '\r']) >= 0; + if (!needsQuoting) { sb.Append(value); return; } + sb.Append('"'); + foreach (var c in value) + { + if (c == '"') sb.Append("\"\""); + else sb.Append(c); + } + sb.Append('"'); + } +} diff --git a/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs b/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs index d3571da0..859f30e0 100644 --- a/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs +++ b/src/NodePilot.Api/Security/DirectoryGroupPrincipal.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Enums; using NodePilot.Core.Models; @@ -26,6 +27,43 @@ public bool Matches(SharedFolderPermission permission) : StringComparison.Ordinal); } + /// + /// SQL-translatable grant predicate shared by every folder-permission lookup: the user's own + /// grants plus any group grant whose key and authority both appear in . + /// Coarse on purpose — the authority-exact, per-authority string comparison lives in + /// and has to run in memory over the candidates (see + /// ). A grant written before authority scoping carries an empty + /// PrincipalAuthority and means Active Directory, so the empty string joins the authority list + /// whenever the user holds an AD group. + /// + public static Expression> GrantPredicate( + Guid userId, + IReadOnlyCollection groups) + { + var userKey = userId.ToString("D"); + var groupKeys = groups.Select(group => group.GroupKey).Distinct().ToList(); + var groupAuthorities = groups.Select(group => group.Authority).Distinct().ToList(); + if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) + groupAuthorities.Add(string.Empty); + return permission => + (permission.PrincipalType == FolderPrincipalType.User && permission.PrincipalKey == userKey) + || (permission.PrincipalType == FolderPrincipalType.Group + && groupKeys.Contains(permission.PrincipalKey) + && groupAuthorities.Contains(permission.PrincipalAuthority)); + } + + /// + /// Narrows the candidates returned by to the grants that really + /// apply: user grants pass through, group grants must match one principal exactly. + /// + public static List ExactMatches( + IEnumerable candidates, + IReadOnlyCollection groups) => + candidates + .Where(permission => permission.PrincipalType == FolderPrincipalType.User + || groups.Any(group => group.Matches(permission))) + .ToList(); + public static async Task> LoadAsync( NodePilotDbContext db, User user, diff --git a/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs b/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs new file mode 100644 index 00000000..4a6767e9 --- /dev/null +++ b/src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using NodePilot.Core.Models; +using NodePilot.Data; + +namespace NodePilot.Api.Security; + +/// +/// Reconciles the persisted rows of one (user, authority) +/// pair against a desired group set — the single implementation behind the AD login mapper, +/// the directory-sync background pass and the OIDC/SCIM identity mapper. +/// +/// The key comparer is a caller decision and part of the authority's semantics: AD SIDs are +/// compared case-insensitively, OIDC/SCIM group ids are opaque and compared ordinally. Removals +/// use the desired set's own comparer, so the caller controls both halves. +/// +/// +internal static class DirectoryMembershipReconciler +{ + /// + /// Applies onto an already-loaded membership list. Rows of a + /// different authority are left untouched, so a caller may pass every membership of the user. + /// + public static void Apply( + NodePilotDbContext db, + Guid userId, + string authority, + IReadOnlyCollection existing, + IReadOnlySet desired, + DateTime timestamp, + StringComparer keyComparer) + { + var scoped = existing + .Where(membership => string.Equals(membership.Authority, authority, StringComparison.Ordinal)) + .ToList(); + + foreach (var membership in scoped) + { + if (!desired.Contains(membership.GroupKey)) + db.DirectoryMemberships.Remove(membership); + else + membership.LastSeenAt = timestamp; + } + + var existingKeys = scoped.Select(membership => membership.GroupKey).ToHashSet(keyComparer); + foreach (var group in desired.Where(group => !existingKeys.Contains(group))) + { + db.DirectoryMemberships.Add(new DirectoryMembership + { + UserId = userId, + Authority = authority, + GroupKey = group, + LastSeenAt = timestamp, + }); + } + } + + /// Loads the (user, authority) memberships and applies to them. + public static async Task ApplyAsync( + NodePilotDbContext db, + Guid userId, + string authority, + IReadOnlySet desired, + DateTime timestamp, + StringComparer keyComparer, + CancellationToken ct) + { + var existing = await db.DirectoryMemberships + .Where(membership => membership.UserId == userId && membership.Authority == authority) + .ToListAsync(ct); + Apply(db, userId, authority, existing, desired, timestamp, keyComparer); + } +} diff --git a/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs b/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs index 417fae04..8f4d830d 100644 --- a/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs +++ b/src/NodePilot.Api/Security/Ldap/DirectorySynchronizationService.cs @@ -227,23 +227,14 @@ private async Task ApplyAttemptAsync( user.KnownGroupSidsJson = JsonSerializer.Serialize(desiredGroups.OrderBy(group => group)); identity.LastSeenAt = snapshot is null ? identity.LastSeenAt : state.AttemptTime; - foreach (var membership in oldGroups) - { - if (!desiredGroups.Contains(membership.GroupKey)) - db.DirectoryMemberships.Remove(membership); - else - membership.LastSeenAt = state.AttemptTime; - } - foreach (var group in desiredGroups.Where(group => !oldGroupKeys.Contains(group))) - { - db.DirectoryMemberships.Add(new DirectoryMembership - { - UserId = user.Id, - Authority = ExternalIdentity.ActiveDirectoryAuthority, - GroupKey = group, - LastSeenAt = state.AttemptTime, - }); - } + DirectoryMembershipReconciler.Apply( + db, + user.Id, + ExternalIdentity.ActiveDirectoryAuthority, + oldGroups, + desiredGroups, + state.AttemptTime, + StringComparer.OrdinalIgnoreCase); IReadOnlyList executionIds = []; if (securityChanged) diff --git a/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs b/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs index cf07d0a8..593a9d7c 100644 --- a/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs +++ b/src/NodePilot.Api/Security/Ldap/ExternalUserMapper.cs @@ -847,39 +847,19 @@ private async Task SaveMutationWithAuditAsync( await _db.SaveChangesAsync(ct); } - private async Task ReplaceDirectoryMembershipsAsync( + // AD SIDs are compared case-insensitively; the authority is always the AD constant here. + private Task ReplaceDirectoryMembershipsAsync( Guid userId, IReadOnlyCollection groupKeys, - CancellationToken ct) - { - var now = DateTime.UtcNow; - var desired = groupKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); - var existing = await _db.DirectoryMemberships - .Where(m => m.UserId == userId - && m.Authority == ExternalIdentity.ActiveDirectoryAuthority) - .ToListAsync(ct); - - foreach (var membership in existing) - { - if (!desired.Contains(membership.GroupKey)) - _db.DirectoryMemberships.Remove(membership); - else - membership.LastSeenAt = now; - } - - var existingKeys = existing.Select(m => m.GroupKey) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var groupKey in desired.Where(key => !existingKeys.Contains(key))) - { - _db.DirectoryMemberships.Add(new DirectoryMembership - { - UserId = userId, - Authority = ExternalIdentity.ActiveDirectoryAuthority, - GroupKey = groupKey, - LastSeenAt = now, - }); - } - } + CancellationToken ct) => + DirectoryMembershipReconciler.ApplyAsync( + _db, + userId, + ExternalIdentity.ActiveDirectoryAuthority, + groupKeys.ToHashSet(StringComparer.OrdinalIgnoreCase), + DateTime.UtcNow, + StringComparer.OrdinalIgnoreCase, + ct); private static IReadOnlyList NormalizeGroupSids(IEnumerable groupSids) => groupSids diff --git a/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs b/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs index 02c00096..8a1f8b18 100644 --- a/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs +++ b/src/NodePilot.Api/Security/Oidc/OidcIdentityMapper.cs @@ -185,7 +185,9 @@ internal async Task MapAsync(ClaimsPrincipal principal, Cancellat LastSeenAt = now, }); db.Users.Add(user); - await SyncMembershipsAsync(user.Id, issuer!, groups, initialAuthorizationObservedAt, ct); + await DirectoryMembershipReconciler.ApplyAsync( + db, user.Id, issuer!, groups, initialAuthorizationObservedAt, + StringComparer.Ordinal, ct); try { @@ -358,9 +360,9 @@ private async Task ReconcileExistingAsync( { existing.LastDirectorySyncAt = authorizationObservedAt; existing.DirectorySyncStatus = targetAuthorizationStatus; - ApplyMembershipSnapshot( - existing.Id, issuer, groups, authorizationObservedAt!.Value, - oldMemberships); + DirectoryMembershipReconciler.Apply( + db, existing.Id, issuer, oldMemberships, groups, + authorizationObservedAt!.Value, StringComparer.Ordinal); } else { @@ -477,62 +479,6 @@ await ExternalExecutionCancellation.SignalAfterCommitAsync( return result; } - private void ApplyMembershipSnapshot( - Guid userId, - string authority, - IReadOnlySet groups, - DateTime timestamp, - IReadOnlyCollection existing) - { - db.DirectoryMemberships.RemoveRange(existing.Where(membership => - string.Equals(membership.Authority, authority, StringComparison.Ordinal) - && !groups.Contains(membership.GroupKey))); - foreach (var retained in existing.Where(membership => - string.Equals(membership.Authority, authority, StringComparison.Ordinal) - && groups.Contains(membership.GroupKey))) - { - retained.LastSeenAt = timestamp; - } - - var existingKeys = existing - .Where(membership => string.Equals( - membership.Authority, authority, StringComparison.Ordinal)) - .Select(membership => membership.GroupKey) - .ToHashSet(StringComparer.Ordinal); - db.DirectoryMemberships.AddRange(groups - .Where(group => !existingKeys.Contains(group)) - .Select(group => new DirectoryMembership - { - UserId = userId, - Authority = authority, - GroupKey = group, - LastSeenAt = timestamp, - })); - } - - private async Task SyncMembershipsAsync( - Guid userId, - string authority, - IReadOnlySet groups, - DateTime timestamp, - CancellationToken ct) - { - var existing = await db.DirectoryMemberships - .Where(x => x.UserId == userId && x.Authority == authority) - .ToListAsync(ct); - db.DirectoryMemberships.RemoveRange(existing.Where(x => !groups.Contains(x.GroupKey))); - foreach (var retained in existing.Where(x => groups.Contains(x.GroupKey))) - retained.LastSeenAt = timestamp; - var existingKeys = existing.Select(x => x.GroupKey).ToHashSet(StringComparer.Ordinal); - db.DirectoryMemberships.AddRange(groups.Where(x => !existingKeys.Contains(x)).Select(group => new DirectoryMembership - { - UserId = userId, - Authority = authority, - GroupKey = group, - LastSeenAt = timestamp, - })); - } - private static GroupSnapshot ReadGroupSnapshot( ClaimsPrincipal principal, string? claimType) diff --git a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs index b8daba1e..9b784618 100644 --- a/src/NodePilot.Api/Security/ResourceAuthorizationService.cs +++ b/src/NodePilot.Api/Security/ResourceAuthorizationService.cs @@ -91,26 +91,15 @@ public async Task GetAccessibleFolderIdsAsync(ClaimsPrincip // bounded (depth 5 means thousands max even at flat-org-of-thousands scale); the // cost of the in-memory walk is negligible compared to per-row DB hits. var allFolders = await GetAllFoldersAsync(ct); - var userKey = userId.Value.ToString("D"); var directoryGroups = await GetDirectoryGroupsAsync(userId.Value, ct); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); // Group-aware grant lookup: include any (PrincipalType=Group, PrincipalKey IN // userGroupSids) grant alongside the per-user grants. Local users have an empty // groupSids list so this branch is a no-op for them. var candidateGrants = await _db.SharedFolderPermissions .AsNoTracking() - .Where(p => - (p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority))) + .Where(DirectoryGroupPrincipal.GrantPredicate(userId.Value, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups) .Select(permission => new { permission.FolderId, permission.Role }) .ToList(); @@ -250,23 +239,12 @@ private async Task> GetAncestryGrantsAsync( return _ancestryGrantsCache[cacheKey]; } - var userKey = userId.ToString("D"); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); var candidateGrants = await _db.SharedFolderPermissions .AsNoTracking() - .Where(p => chain.Contains(p.FolderId) - && ((p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority)))) + .Where(p => chain.Contains(p.FolderId)) + .Where(DirectoryGroupPrincipal.GrantPredicate(userId, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) - .ToList(); + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups); _ancestryGrantsCache[cacheKey] = grants; return grants; } diff --git a/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs b/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs index 0da370d8..03bf5a42 100644 --- a/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs +++ b/src/NodePilot.Api/Security/Scim/ScimProvisioningService.cs @@ -876,30 +876,10 @@ private void ForwardCommittedAudit(AuditLogEntry entry) try { - using (logger.BeginScope(new Dictionary - { - ["support.event_type"] = "AUDIT", - ["support.message"] = $"{entry.Action} user={entry.Username ?? "-"} resource={entry.ResourceType ?? "-"}/{entry.ResourceId?.ToString() ?? "-"} ip={entry.IpAddress ?? "-"}", - ["event.action"] = entry.Action, - ["event.category"] = "iam", - ["event.kind"] = "event", - ["event.outcome"] = "success", - ["event.dataset"] = "nodepilot.audit", - ["event.id"] = entry.Id.ToString(), - ["event.original"] = entry.Details, - ["user.id"] = entry.UserId?.ToString(), - ["user.name"] = entry.Username, - ["source.ip"] = entry.IpAddress, - ["AuditResourceType"] = entry.ResourceType, - ["AuditResourceId"] = entry.ResourceId?.ToString(), - ["SupportLog"] = true, - })) - { - logger.LogInformation( - "AUDIT {Action} user={UserName} resource={ResourceType}/{ResourceId} ip={RemoteIp}", - entry.Action, entry.Username ?? "-", entry.ResourceType ?? "-", - entry.ResourceId?.ToString() ?? "-", entry.IpAddress ?? "-"); - } + // Shared ECS shape (category/outcome/support-log allowlist) — a hand-rolled copy here + // had SCIM_GROUP_* events landing in a different event.category than the same action + // forwarded by any other writer. + AuditEventForwarder.ForwardCommitted(logger, entry); } catch (Exception ex) { diff --git a/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs b/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs index 7d2987cc..860a0e70 100644 --- a/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs +++ b/src/NodePilot.Api/Security/SubWorkflowAuthorizationResolver.cs @@ -97,25 +97,15 @@ public SubWorkflowAuthorizationResolver( } if (chain.Count == 0) return $"sub-workflow folder chain unresolvable for '{childWorkflow.Name}'"; - var userKey = effectiveUserId.Value.ToString("D"); // Group-aware grant lookup uses the same normalized, server-side snapshot as HTTP // authorization. Scheduled/triggered work must never depend on group claims from an // old browser token or the legacy JSON cache on User. var directoryGroups = await DirectoryGroupPrincipal.LoadAsync(_db, user, ct); - var groupKeys = directoryGroups.Select(group => group.GroupKey).Distinct().ToList(); - var groupAuthorities = directoryGroups.Select(group => group.Authority).Distinct().ToList(); - if (groupAuthorities.Contains(ExternalIdentity.ActiveDirectoryAuthority, StringComparer.Ordinal)) - groupAuthorities.Add(string.Empty); var candidateGrants = await _db.SharedFolderPermissions.AsNoTracking() - .Where(p => chain.Contains(p.FolderId) - && ((p.PrincipalType == FolderPrincipalType.User && p.PrincipalKey == userKey) - || (p.PrincipalType == FolderPrincipalType.Group - && groupKeys.Contains(p.PrincipalKey) - && groupAuthorities.Contains(p.PrincipalAuthority)))) + .Where(p => chain.Contains(p.FolderId)) + .Where(DirectoryGroupPrincipal.GrantPredicate(effectiveUserId.Value, directoryGroups)) .ToListAsync(ct); - var grants = candidateGrants - .Where(permission => permission.PrincipalType == FolderPrincipalType.User - || directoryGroups.Any(group => group.Matches(permission))) + var grants = DirectoryGroupPrincipal.ExactMatches(candidateGrants, directoryGroups) .Select(permission => permission.Role) .ToList(); if (grants.Count == 0) diff --git a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs index 2fa7abe9..29c052f2 100644 --- a/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs +++ b/src/NodePilot.Api/Services/Backup/BackupRestoreService.cs @@ -444,34 +444,77 @@ private async Task RestoreDirectoryMembershipsAsync( db.DirectoryMemberships.AddRange(restored); } - private async Task RestoreFoldersAsync(RestoreState s, CancellationToken ct) + private Task RestoreFoldersAsync(RestoreState s, CancellationToken ct) => + RestoreFolderStructureAsync( + s, + BackupSections.Folders, + SharedWorkflowFolder.RootFolderId, + s.Folders, + s.ExistingFolderIds, + s.FolderMap, + // Root is represented by a null ParentFolderId here, and an unresolvable parent stays null. + parentSource => parentSource is null ? null : s.ResolveFolder(parentSource.Value), + FolderTrees.Shared, + folder => db.SharedWorkflowFolders.Add(folder), + ct); + + private Task RestoreGlobalFoldersAsync(RestoreState s, CancellationToken ct) => + RestoreFolderStructureAsync( + s, + BackupSections.GlobalVariableFolders, + GlobalVariableFolder.RootFolderId, + s.GlobalFolders, + s.ExistingGlobalFolderIds, + s.GlobalFolderMap, + // Unlike shared folders, a missing/unresolvable parent lands under the singleton Root. + parentSource => parentSource is null + ? GlobalVariableFolder.RootFolderId + : s.ResolveGlobalFolder(parentSource.Value) ?? GlobalVariableFolder.RootFolderId, + FolderTrees.Global, + folder => db.GlobalVariableFolders.Add(folder), + ct); + + /// + /// Restores one folder tree's structure array. Identical for both folder types + /// (see ); only the root id, the parent-resolution rule + /// and the target DbSet differ. + /// + private async Task RestoreFolderStructureAsync( + RestoreState s, + string section, + Guid rootId, + IDictionary byPath, + HashSet existingIds, + IDictionary folderMap, + Func resolveParent, + FolderTreeShape shape, + Action add, + CancellationToken ct) { - var policy = s.Policy(BackupSections.Folders); + var policy = s.Policy(section); int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var structure = (s.Reader.Sections[BackupSections.Folders] as JsonObject)?["structure"] as JsonArray ?? []; + var structure = (s.Reader.Sections[section] as JsonObject)?["structure"] as JsonArray ?? []; // Id -> restored Path, so a child derives its Path from the *restored* parent Path instead of // the stale backup path. The export orders folders by Depth (parents first), so every parent - // is already in this map when its children are processed. Seeded with the target DB's - // pre-existing folders — an existing folder reused as a parent (Skip policy) must expose its - // current Path to its restored children. Root is represented by a null ParentFolderId, so a - // null parentTarget maps to the "" prefix. Without this, a parent renamed on conflict left its - // children with the old backup Path while their ParentFolderId pointed at the renamed parent - // → inconsistent materialized Path for the whole subtree. - var pathById = new Dictionary(); - foreach (var f in s.Folders.Values) - pathById[f.Id] = f.Path == "/" ? "" : f.Path; - - s.FolderMap[SharedWorkflowFolder.RootFolderId] = SharedWorkflowFolder.RootFolderId; + // is already in this map when its children are processed. Seeded with Root (path prefix "") and + // the target DB's pre-existing folders — an existing folder reused as a parent (Skip policy) + // must expose its current Path to its restored children. Without this, a parent renamed on + // conflict left its children with the old backup Path while their ParentFolderId pointed at the + // renamed parent → inconsistent materialized Path for the whole subtree. + var pathById = new Dictionary { [rootId] = "" }; + foreach (var f in byPath.Values) + pathById[shape.Id(f)] = shape.Path(f) == "/" ? "" : shape.Path(f); + + folderMap[rootId] = rootId; foreach (var item in structure) { var sourceId = Gid(item!["sourceId"]); - if (sourceId == SharedWorkflowFolder.RootFolderId) { skipped++; continue; } // Root is fixed; never recreated. + if (sourceId == rootId) { skipped++; continue; } // Root is fixed; never recreated. var name = item["name"]!.GetValue(); var depth = item["depth"]?.GetValue() ?? 1; - var parentSource = GidN(item["parentFolderId"]); - var parentTarget = parentSource is null ? (Guid?)null : s.ResolveFolder(parentSource.Value); + var parentTarget = resolveParent(GidN(item["parentFolderId"])); var createdBy = ResolveUserOrNull(s, GidN(item["createdByUserId"])); // remaps the folder-creator's user id; null if it can't be resolved (K17) // Recompute the Path from the parent's restored Path + this folder's name. The backup path @@ -481,190 +524,174 @@ private async Task RestoreFoldersAsync(RestoreState s, Can var parentPath = parentTarget is null ? "" : pathById.GetValueOrDefault(parentTarget.Value, ""); var path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - if (s.Folders.TryGetValue(path, out var existing)) + if (byPath.TryGetValue(path, out var existing)) { - if (policy == RestoreConflictPolicy.Skip) { s.FolderMap[sourceId] = existing.Id; skipped++; continue; } + if (policy == RestoreConflictPolicy.Skip) { folderMap[sourceId] = shape.Id(existing); skipped++; continue; } if (policy == RestoreConflictPolicy.Overwrite) { - existing.Name = name; existing.ParentFolderId = parentTarget; existing.Depth = depth; existing.Path = path; existing.CreatedByUserId = createdBy; - pathById[existing.Id] = path; - s.FolderMap[sourceId] = existing.Id; overwritten++; continue; + shape.Apply(existing, name, path, depth, parentTarget, createdBy); + pathById[shape.Id(existing)] = path; + folderMap[sourceId] = shape.Id(existing); overwritten++; continue; } // Rename: the DB enforces unique(ParentFolderId, Name), so we MUST give the new // folder a sibling-unique name and recompute the Path from it so the in-memory lookup // key tracks the actual stored Path. var siblingNames = new HashSet( - s.Folders.Values.Where(f => f.ParentFolderId == parentTarget).Select(f => f.Name), StringComparer.Ordinal); + byPath.Values.Where(f => shape.ParentId(f) == parentTarget).Select(shape.Name), StringComparer.Ordinal); name = UniqueName(name, siblingNames); path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; renamed++; } else created++; - var id = s.ExistingFolderIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var folder = new SharedWorkflowFolder - { - Id = id, Name = name, Path = path, Depth = depth, ParentFolderId = parentTarget, CreatedByUserId = createdBy, - }; - db.SharedWorkflowFolders.Add(folder); - s.Folders[path] = folder; s.ExistingFolderIds.Add(id); s.FolderMap[sourceId] = id; + var id = existingIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; + var folder = shape.New(id); + shape.Apply(folder, name, path, depth, parentTarget, createdBy); + add(folder); + byPath[path] = folder; existingIds.Add(id); folderMap[sourceId] = id; pathById[id] = path; } await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Folders, created, overwritten, skipped, renamed); + return new SectionRestoreResult(section, created, overwritten, skipped, renamed); } - private async Task RestoreCredentialsAsync(RestoreState s, CancellationToken ct) + /// + /// One parsed item of a by-name section: its conflict key, the backup's source id, and the two + /// section-specific writes — apply onto the existing row, or materialize a new one under the + /// (possibly renamed) name. + /// + private sealed record NamedRestoreItem( + string Name, + Guid SourceId, + Action Overwrite, + Func Create); + + /// + /// The by-name conflict algorithm every "named row" section shares: counters, the taken-name + /// set, the Skip/Overwrite/Rename branch, the source-id collision remap (K3) and the section + /// result. parses an item BEFORE the conflict check — a section may + /// reject an item outright (decryption/validation) whatever the policy says, and it must see + /// the item's original name. + /// + private async Task RestoreNamedSectionAsync( + RestoreState s, + string section, + IDictionary byName, + Func idOf, + HashSet existingIds, + IDictionary? idMap, + Action add, + Func> read, + CancellationToken ct, + bool preserveSourceIds = true) { - var policy = s.Policy(BackupSections.Credentials); + var policy = s.Policy(section); int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Credentials.Keys, StringComparer.Ordinal); + var takenNames = new HashSet(byName.Keys, StringComparer.Ordinal); - foreach (var item in Items(s.Reader, BackupSections.Credentials)) + foreach (var node in Items(s.Reader, section)) { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var username = item["username"]?.GetValue() ?? ""; - var domain = item["domain"]?.GetValue(); - var expiresAt = DateTime.TryParse( - item["expiresAt"]?.GetValue(), null, - System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, - out var exp) ? exp : (DateTime?)null; - byte[] encrypted = EncryptedPasswordFor(item, s, name); - - if (s.Credentials.TryGetValue(name, out var existing)) + var item = read(node!); + var name = item.Name; + + if (byName.TryGetValue(name, out var existing)) { - if (policy == RestoreConflictPolicy.Skip) { s.CredentialMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) + if (policy == RestoreConflictPolicy.Skip) { - existing.Username = username; existing.Domain = domain; existing.EncryptedPassword = encrypted; - existing.ExpiresAt = expiresAt; - s.CredentialMap[sourceId] = existing.Id; overwritten++; continue; + if (idMap is not null) idMap[item.SourceId] = idOf(existing); + skipped++; continue; } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = s.ExistingCredentialIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var cred = new Credential { Id = id, Name = name, Username = username, Domain = domain, EncryptedPassword = encrypted, ExpiresAt = expiresAt }; - db.Credentials.Add(cred); - s.Credentials[name] = cred; s.ExistingCredentialIds.Add(id); s.CredentialMap[sourceId] = id; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Credentials, created, overwritten, skipped, renamed); - } - - private async Task RestoreMachinesAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Machines); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Machines.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Machines)) - { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var hostname = item["hostname"]?.GetValue() ?? ""; - var winRmPort = item["winRmPort"]?.GetValue() ?? 5985; - var useSsl = item["useSsl"]?.GetValue() ?? false; - var tags = item["tags"]?.GetValue(); - var credSource = GidN(item["defaultCredentialId"]); - var credTarget = credSource is null ? (Guid?)null : s.ResolveCredential(credSource.Value); - - if (s.Machines.TryGetValue(name, out var existing)) - { - if (policy == RestoreConflictPolicy.Skip) { s.MachineMap[sourceId] = existing.Id; skipped++; continue; } if (policy == RestoreConflictPolicy.Overwrite) { - existing.Hostname = hostname; existing.WinRmPort = winRmPort; existing.UseSsl = useSsl; - existing.Tags = tags; existing.DefaultCredentialId = credTarget; - s.MachineMap[sourceId] = existing.Id; overwritten++; continue; + item.Overwrite(existing); + if (idMap is not null) idMap[item.SourceId] = idOf(existing); + overwritten++; continue; } name = UniqueName(name, takenNames); renamed++; } else created++; takenNames.Add(name); - var id = s.ExistingMachineIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var machine = new ManagedMachine - { - Id = id, Name = name, Hostname = hostname, WinRmPort = winRmPort, UseSsl = useSsl, - Tags = tags, DefaultCredentialId = credTarget, - }; - db.ManagedMachines.Add(machine); - s.Machines[name] = machine; s.ExistingMachineIds.Add(id); s.MachineMap[sourceId] = id; + var id = preserveSourceIds && !existingIds.Contains(item.SourceId) ? item.SourceId : Guid.NewGuid(); + var entity = item.Create(id, name); + add(entity); + byName[name] = entity; existingIds.Add(id); + if (idMap is not null) idMap[item.SourceId] = id; } await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Machines, created, overwritten, skipped, renamed); + return new SectionRestoreResult(section, created, overwritten, skipped, renamed); } - private async Task RestoreGlobalFoldersAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.GlobalVariableFolders); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var structure = (s.Reader.Sections[BackupSections.GlobalVariableFolders] as JsonObject)?["structure"] as JsonArray ?? []; - - // Id -> restored Path, so a child derives its Path from the *restored* parent Path instead of - // the stale backup path. The export orders folders by Depth (parents first), so every parent - // is already in this map when its children are processed. Seeded with Root (path prefix "") and - // the target DB's pre-existing folders — an existing folder reused as a parent (Skip policy) - // must expose its current Path to its restored children. Without this, a parent renamed on - // conflict left its children with the old backup Path while their ParentFolderId pointed at the - // renamed parent → inconsistent materialized Path for the whole subtree. - var pathById = new Dictionary { [GlobalVariableFolder.RootFolderId] = "" }; - foreach (var f in s.GlobalFolders.Values) - pathById[f.Id] = f.Path == "/" ? "" : f.Path; - - s.GlobalFolderMap[GlobalVariableFolder.RootFolderId] = GlobalVariableFolder.RootFolderId; - foreach (var item in structure) - { - var sourceId = Gid(item!["sourceId"]); - if (sourceId == GlobalVariableFolder.RootFolderId) { skipped++; continue; } // Root is fixed; never recreated. - - var name = item["name"]!.GetValue(); - var depth = item["depth"]?.GetValue() ?? 1; - var parentSource = GidN(item["parentFolderId"]); - var parentTarget = parentSource is null ? GlobalVariableFolder.RootFolderId : (s.ResolveGlobalFolder(parentSource.Value) ?? GlobalVariableFolder.RootFolderId); - var createdBy = ResolveUserOrNull(s, GidN(item["createdByUserId"])); - - // Recompute the Path from the parent's restored Path + this folder's name. The backup path - // is only a serialization hint; the stored Path must follow the actual parent chain (which - // may have been renamed above). Conflict detection runs on this recomputed path so a folder - // clashes with whatever already lives at its true target position. - var parentPath = pathById.GetValueOrDefault(parentTarget, ""); - var path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - - if (s.GlobalFolders.TryGetValue(path, out var existing)) + private Task RestoreCredentialsAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Credentials, + s.Credentials, + credential => credential.Id, + s.ExistingCredentialIds, + s.CredentialMap, + credential => db.Credentials.Add(credential), + item => { - if (policy == RestoreConflictPolicy.Skip) { s.GlobalFolderMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - existing.Name = name; existing.ParentFolderId = parentTarget; existing.Depth = depth; existing.Path = path; existing.CreatedByUserId = createdBy; - pathById[existing.Id] = path; - s.GlobalFolderMap[sourceId] = existing.Id; overwritten++; continue; - } - // Rename: unique(ParentFolderId, Name) forces a sibling-unique name; recompute the Path from it. - var siblingNames = new HashSet( - s.GlobalFolders.Values.Where(f => f.ParentFolderId == parentTarget).Select(f => f.Name), StringComparer.Ordinal); - name = UniqueName(name, siblingNames); - path = parentPath.Length == 0 ? "/" + name : parentPath + "/" + name; - renamed++; - } - else created++; - - var id = s.ExistingGlobalFolderIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var folder = new GlobalVariableFolder + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var username = item["username"]?.GetValue() ?? ""; + var domain = item["domain"]?.GetValue(); + var expiresAt = DateTime.TryParse( + item["expiresAt"]?.GetValue(), null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, + out var exp) ? exp : (DateTime?)null; + byte[] encrypted = EncryptedPasswordFor(item, s, name); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + existing.Username = username; existing.Domain = domain; existing.EncryptedPassword = encrypted; + existing.ExpiresAt = expiresAt; + }, + (id, finalName) => new Credential + { + Id = id, Name = finalName, Username = username, Domain = domain, + EncryptedPassword = encrypted, ExpiresAt = expiresAt, + }); + }, + ct); + + private Task RestoreMachinesAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Machines, + s.Machines, + machine => machine.Id, + s.ExistingMachineIds, + s.MachineMap, + machine => db.ManagedMachines.Add(machine), + item => { - Id = id, Name = name, Path = path, Depth = depth, ParentFolderId = parentTarget, CreatedByUserId = createdBy, - }; - db.GlobalVariableFolders.Add(folder); - s.GlobalFolders[path] = folder; s.ExistingGlobalFolderIds.Add(id); s.GlobalFolderMap[sourceId] = id; - pathById[id] = path; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.GlobalVariableFolders, created, overwritten, skipped, renamed); - } + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var hostname = item["hostname"]?.GetValue() ?? ""; + var winRmPort = item["winRmPort"]?.GetValue() ?? 5985; + var useSsl = item["useSsl"]?.GetValue() ?? false; + var tags = item["tags"]?.GetValue(); + var credSource = GidN(item["defaultCredentialId"]); + var credTarget = credSource is null ? (Guid?)null : s.ResolveCredential(credSource.Value); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + existing.Hostname = hostname; existing.WinRmPort = winRmPort; existing.UseSsl = useSsl; + existing.Tags = tags; existing.DefaultCredentialId = credTarget; + }, + (id, finalName) => new ManagedMachine + { + Id = id, Name = finalName, Hostname = hostname, WinRmPort = winRmPort, UseSsl = useSsl, + Tags = tags, DefaultCredentialId = credTarget, + }); + }, + ct); private async Task RestoreGlobalsAsync(RestoreState s, CancellationToken ct) { @@ -770,94 +797,83 @@ private static void ApplyCustomActivityFields(CustomActivityDefinition def, Json def.Version = item["version"]?.GetValue() ?? 1; } - private async Task RestoreWorkflowsAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Workflows); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.Workflows.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Workflows)) - { - var sourceId = Gid(item!["sourceId"]); - var name = item["name"]!.GetValue(); - var description = item["description"]?.GetValue(); - var isEnabled = item["isEnabled"]?.GetValue() ?? false; - var version = item["version"]?.GetValue() ?? 1; - var folderTarget = s.ResolveFolder(GidN(item["folderId"]) ?? SharedWorkflowFolder.RootFolderId) - ?? SharedWorkflowFolder.RootFolderId; - var definitionJson = RestoreDefinitionJson(item["definition"], s); - - if (s.Workflows.TryGetValue(name, out var existing)) + private Task RestoreWorkflowsAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Workflows, + s.Workflows, + workflow => workflow.Id, + s.ExistingWorkflowIds, + s.WorkflowMap, + workflow => db.Workflows.Add(workflow), + item => { - if (policy == RestoreConflictPolicy.Skip) { s.WorkflowMap[sourceId] = existing.Id; skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - if (existing.CheckedOutByUserId is not null) - throw new BackupRestoreException( - $"Restore aborted: workflow '{existing.Name}' is locked for editing. Publish, unlock, or force-unlock it before overwrite restore."); - existing.Description = description; existing.DefinitionJson = definitionJson; - existing.IsEnabled = isEnabled; existing.FolderId = folderTarget; existing.UpdatedAt = DateTime.UtcNow; - s.WorkflowMap[sourceId] = existing.Id; overwritten++; continue; - } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = s.ExistingWorkflowIds.Contains(sourceId) ? Guid.NewGuid() : sourceId; - var wf = new Workflow - { - Id = id, Name = name, Description = description, DefinitionJson = definitionJson, - Version = version, IsEnabled = isEnabled, FolderId = folderTarget, - CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, - }; - db.Workflows.Add(wf); - s.Workflows[name] = wf; s.ExistingWorkflowIds.Add(id); s.WorkflowMap[sourceId] = id; - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Workflows, created, overwritten, skipped, renamed); - } - - private async Task RestoreAlertingAsync(RestoreState s, CancellationToken ct) - { - var policy = s.Policy(BackupSections.Alerting); - int created = 0, overwritten = 0, skipped = 0, renamed = 0; - var takenNames = new HashSet(s.NotificationRules.Keys, StringComparer.Ordinal); - - foreach (var item in Items(s.Reader, BackupSections.Alerting)) - { - var name = item!["name"]!.GetValue(); - var kind = Enum.TryParse(item["kind"]?.GetValue(), out var k) ? k : NotificationRuleKind.Custom; - var isEnabled = item["isEnabled"]?.GetValue() ?? false; - - if (s.NotificationRules.TryGetValue(name, out var existing)) + var sourceId = Gid(item["sourceId"]); + var name = item["name"]!.GetValue(); + var description = item["description"]?.GetValue(); + var isEnabled = item["isEnabled"]?.GetValue() ?? false; + var version = item["version"]?.GetValue() ?? 1; + var folderTarget = s.ResolveFolder(GidN(item["folderId"]) ?? SharedWorkflowFolder.RootFolderId) + ?? SharedWorkflowFolder.RootFolderId; + var definitionJson = RestoreDefinitionJson(item["definition"], s); + return new NamedRestoreItem( + name, + sourceId, + existing => + { + if (existing.CheckedOutByUserId is not null) + throw new BackupRestoreException( + $"Restore aborted: workflow '{existing.Name}' is locked for editing. Publish, unlock, or force-unlock it before overwrite restore."); + existing.Description = description; existing.DefinitionJson = definitionJson; + existing.IsEnabled = isEnabled; existing.FolderId = folderTarget; existing.UpdatedAt = DateTime.UtcNow; + }, + (id, finalName) => new Workflow + { + Id = id, Name = finalName, Description = description, DefinitionJson = definitionJson, + Version = version, IsEnabled = isEnabled, FolderId = folderTarget, + CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + }); + }, + ct); + + // Alerting rules carry no source-id map: a restored rule always gets a fresh id, and nothing + // in the envelope references a rule by id. + private Task RestoreAlertingAsync(RestoreState s, CancellationToken ct) => + RestoreNamedSectionAsync( + s, + BackupSections.Alerting, + s.NotificationRules, + rule => rule.Id, + s.ExistingNotificationRuleIds, + idMap: null, + rule => db.NotificationRules.Add(rule), + item => { - if (policy == RestoreConflictPolicy.Skip) { skipped++; continue; } - if (policy == RestoreConflictPolicy.Overwrite) - { - ApplyRuleScalars(existing, item, kind, isEnabled); - db.NotificationRoutes.RemoveRange(existing.Routes); - db.NotificationRuleTargets.RemoveRange(existing.Targets); - foreach (var r in RestoredRoutes(item, s, existing.Id)) db.NotificationRoutes.Add(r); - foreach (var tg in RestoredTargets(item, s, existing.Id)) db.NotificationRuleTargets.Add(tg); - overwritten++; continue; - } - name = UniqueName(name, takenNames); renamed++; - } - else created++; - - takenNames.Add(name); - var id = Guid.NewGuid(); - var rule = new NotificationRule { Id = id, Name = name }; - ApplyRuleScalars(rule, item, kind, isEnabled); - rule.Routes = RestoredRoutes(item, s, id); - rule.Targets = RestoredTargets(item, s, id); - db.NotificationRules.Add(rule); - s.NotificationRules[name] = rule; s.ExistingNotificationRuleIds.Add(id); - } - await db.SaveChangesAsync(ct); - return new SectionRestoreResult(BackupSections.Alerting, created, overwritten, skipped, renamed); - } + var name = item["name"]!.GetValue(); + var kind = Enum.TryParse(item["kind"]?.GetValue(), out var k) ? k : NotificationRuleKind.Custom; + var isEnabled = item["isEnabled"]?.GetValue() ?? false; + return new NamedRestoreItem( + name, + Guid.Empty, + existing => + { + ApplyRuleScalars(existing, item, kind, isEnabled); + db.NotificationRoutes.RemoveRange(existing.Routes); + db.NotificationRuleTargets.RemoveRange(existing.Targets); + foreach (var r in RestoredRoutes(item, s, existing.Id)) db.NotificationRoutes.Add(r); + foreach (var tg in RestoredTargets(item, s, existing.Id)) db.NotificationRuleTargets.Add(tg); + }, + (id, finalName) => + { + var rule = new NotificationRule { Id = id, Name = finalName }; + ApplyRuleScalars(rule, item, kind, isEnabled); + rule.Routes = RestoredRoutes(item, s, id); + rule.Targets = RestoredTargets(item, s, id); + return rule; + }); + }, + ct, + preserveSourceIds: false); private static void ApplyRuleScalars(NotificationRule rule, JsonNode item, NotificationRuleKind kind, bool isEnabled) { diff --git a/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs b/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs new file mode 100644 index 00000000..388cd723 --- /dev/null +++ b/src/NodePilot.Api/Services/Backup/FolderTreeShape.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Nodes; +using NodePilot.Core.Models; + +namespace NodePilot.Api.Services.Backup; + +/// +/// Accessors over the two structurally identical folder trees — +/// and . Both carry Id / ParentFolderId / Name / Path / Depth / +/// CreatedByUserId with the same semantics (singleton Root, materialized Path, sibling-unique Name), +/// but they share no interface in NodePilot.Core, so backup export and restore reach the fields +/// through delegates. Core follow-up: give both entities one folder-node interface and delete this +/// shim. Apply writes the mutable columns in one go: name, path, depth, parent id, creator. +/// +internal sealed record FolderTreeShape( + Func Id, + Func ParentId, + Func Name, + Func Path, + Func Depth, + Func CreatedBy, + Func New, + Action Apply); + +/// The concrete shapes plus the export projection both folder sections share. +internal static class FolderTrees +{ + public static readonly FolderTreeShape Shared = new( + folder => folder.Id, + folder => folder.ParentFolderId, + folder => folder.Name, + folder => folder.Path, + folder => folder.Depth, + folder => folder.CreatedByUserId, + id => new SharedWorkflowFolder { Id = id }, + (folder, name, path, depth, parentId, createdBy) => + { + folder.Name = name; + folder.Path = path; + folder.Depth = depth; + folder.ParentFolderId = parentId; + folder.CreatedByUserId = createdBy; + }); + + public static readonly FolderTreeShape Global = new( + folder => folder.Id, + folder => folder.ParentFolderId, + folder => folder.Name, + folder => folder.Path, + folder => folder.Depth, + folder => folder.CreatedByUserId, + id => new GlobalVariableFolder { Id = id }, + (folder, name, path, depth, parentId, createdBy) => + { + folder.Name = name; + folder.Path = path; + folder.Depth = depth; + folder.ParentFolderId = parentId; + folder.CreatedByUserId = createdBy; + }); + + /// + /// The structure array of a folder section. The caller supplies the folders already + /// ordered by Depth then Name — restore relies on every parent preceding its children. + /// + public static JsonArray Structure( + IEnumerable folders, + FolderTreeShape shape) + { + var structure = new JsonArray(); + foreach (var f in folders) + { + structure.Add(new JsonObject + { + ["sourceId"] = shape.Id(f).ToString(), + ["parentFolderId"] = shape.ParentId(f)?.ToString(), + ["name"] = shape.Name(f), + ["path"] = shape.Path(f), + ["depth"] = shape.Depth(f), + ["createdByUserId"] = shape.CreatedBy(f)?.ToString(), + }); + } + return structure; + } +} diff --git a/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs index 9090e753..3c075d60 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/FolderBackupPart.cs @@ -26,19 +26,7 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok var folders = await db.SharedWorkflowFolders.AsNoTracking().OrderBy(f => f.Depth).ThenBy(f => f.Name).ToListAsync(ct); var grants = await db.SharedFolderPermissions.AsNoTracking().ToListAsync(ct); - var structure = new JsonArray(); - foreach (var f in folders) - { - structure.Add(new JsonObject - { - ["sourceId"] = f.Id.ToString(), - ["parentFolderId"] = f.ParentFolderId?.ToString(), - ["name"] = f.Name, - ["path"] = f.Path, - ["depth"] = f.Depth, - ["createdByUserId"] = f.CreatedByUserId?.ToString(), - }); - } + var structure = FolderTrees.Structure(folders, FolderTrees.Shared); var grantArr = new JsonArray(); foreach (var g in grants) diff --git a/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs b/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs index 63151e42..e9025cc1 100644 --- a/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs +++ b/src/NodePilot.Api/Services/Backup/Parts/GlobalVariableFolderBackupPart.cs @@ -22,20 +22,9 @@ public async Task ExportAsync(BackupExportContext ctx, CancellationTok var folders = await db.GlobalVariableFolders.AsNoTracking() .OrderBy(f => f.Depth).ThenBy(f => f.Name).ToListAsync(ct); - var structure = new JsonArray(); - foreach (var f in folders) + return new JsonObject { - structure.Add(new JsonObject - { - ["sourceId"] = f.Id.ToString(), - ["parentFolderId"] = f.ParentFolderId?.ToString(), - ["name"] = f.Name, - ["path"] = f.Path, - ["depth"] = f.Depth, - ["createdByUserId"] = f.CreatedByUserId?.ToString(), - }); - } - - return new JsonObject { ["structure"] = structure }; + ["structure"] = FolderTrees.Structure(folders, FolderTrees.Global), + }; } } diff --git a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs index 21566e25..19a4d830 100644 --- a/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs +++ b/src/NodePilot.Api/Services/Backup/WorkflowDefinitionSecretRewriter.cs @@ -7,7 +7,7 @@ namespace NodePilot.Api.Services.Backup; /// /// How secret-bearing string values inside a workflow DefinitionJson are treated when /// the definition leaves the system (ADR 0001 K2). The same key list -/// () drives all three modes so the +/// () drives both modes so the /// contextual "share one workflow" export and the system backup never disagree about what's a secret. /// public enum SecretHandling @@ -18,9 +18,6 @@ public enum SecretHandling /// Replace secret values with an {"$enc":"<base64>"} object encrypted under /// the backup passphrase — the DR backup path. EncryptForBackup, - - /// Leave values untouched. Internal/test only — never sent over the wire. - PlainInternal, } /// @@ -58,7 +55,7 @@ public static JsonNode Rewrite(JsonElement root, SecretHandling handling, Passph var node = JsonNode.Parse(root.GetRawText()) ?? throw new InvalidOperationException("Workflow definition is not valid JSON."); - return Walk(node, parentName: null, isHttpHeaderValue: false, handling, protector); + return Walk(node, parentName: null, isHttpHeaderValue: false, protector); } /// @@ -130,8 +127,10 @@ private static JsonNode RestoreWalk( } } + // Only reached for EncryptForBackup: Rewrite short-circuits Redact to Core's + // WorkflowSecretRedactor, and the protector was null-checked there. private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeaderValue, - SecretHandling handling, PassphraseSecretProtector? protector) + PassphraseSecretProtector? protector) { switch (node) { @@ -140,14 +139,14 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade var result = new JsonObject(); var isHeadersObject = string.Equals(parentName, "headers", StringComparison.OrdinalIgnoreCase); foreach (var (name, value) in obj) - result[name] = value is null ? null : Walk(value, name, isHeadersObject, handling, protector); + result[name] = value is null ? null : Walk(value, name, isHeadersObject, protector); return result; } case JsonArray arr: { var result = new JsonArray(); foreach (var item in arr) - result.Add(item is null ? null : Walk(item, parentName, isHttpHeaderValue, handling, protector)); + result.Add(item is null ? null : Walk(item, parentName, isHttpHeaderValue, protector)); return result; } case JsonValue val when val.TryGetValue(out string? s) && s is not null: @@ -155,14 +154,9 @@ private static JsonNode Walk(JsonNode node, string? parentName, bool isHttpHeade if (!NodePilot.Core.WorkflowDefinitions.WorkflowSecretKeys.IsSecretValue(parentName, s, isHttpHeaderValue)) return JsonValue.Create(s); - return handling switch + return new JsonObject { - SecretHandling.Redact => JsonValue.Create("***"), - SecretHandling.EncryptForBackup => new JsonObject - { - [EncKey] = Convert.ToBase64String(protector!.Protect(s)), - }, - _ => JsonValue.Create(s), // PlainInternal + [EncKey] = Convert.ToBase64String(protector!.Protect(s)), }; } default: diff --git a/src/NodePilot.Cli/Api/ApiException.cs b/src/NodePilot.Cli/Api/ApiException.cs deleted file mode 100644 index 43acbe23..00000000 --- a/src/NodePilot.Cli/Api/ApiException.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Net; - -namespace NodePilot.Cli.Api; - -/// -/// Thrown by on every non-2xx response. Captures both -/// the HTTP status (so commands can branch on 401/403/423) and the parsed -/// ProblemDetails payload when the server returned one. -/// -public sealed class ApiException : Exception -{ - public HttpStatusCode StatusCode { get; } - public string? Title { get; } - public string? Detail { get; } - public string? RawBody { get; } - - public ApiException(HttpStatusCode statusCode, string? title, string? detail, string? rawBody) - : base(BuildMessage(statusCode, title, detail, rawBody)) - { - StatusCode = statusCode; - Title = title; - Detail = detail; - RawBody = rawBody; - } - - private static string BuildMessage(HttpStatusCode status, string? title, string? detail, string? body) - { - var label = title ?? status.ToString(); - if (!string.IsNullOrWhiteSpace(detail)) return $"{(int)status} {label}: {detail}"; - if (!string.IsNullOrWhiteSpace(body) && body.Length < 400) return $"{(int)status} {label}: {body}"; - return $"{(int)status} {label}"; - } - - /// True for 401 — caller must re-authenticate. - public bool IsUnauthorized => StatusCode == HttpStatusCode.Unauthorized; - - /// True for 403 — caller authenticated but lacks the required role. - public bool IsForbidden => StatusCode == HttpStatusCode.Forbidden; - - /// True for 423 — workflow is checked out by another user. - public bool IsLocked => (int)StatusCode == 423; - - /// True for 409 — conflicting state (e.g. lock contention, idempotency). - public bool IsConflict => StatusCode == HttpStatusCode.Conflict; -} diff --git a/src/NodePilot.Cli/Api/NodePilotApiClient.cs b/src/NodePilot.Cli/Api/NodePilotApiClient.cs index b93ea452..bffc234e 100644 --- a/src/NodePilot.Cli/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Cli/Api/NodePilotApiClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using NodePilot.Cli.Api.Dtos; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Api; @@ -1008,33 +1009,10 @@ public async Task ExecuteDbAdminQueryAsync(string sql, // ---- Plumbing ----------------------------------------------------------- - private static async Task ParseAsync(HttpResponseMessage res, CancellationToken ct) - { - await EnsureSuccessAsync(res, ct); - if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) - return default!; - var stream = await res.Content.ReadAsStreamAsync(ct); - var value = await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); - if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); - return value; - } + // Response plumbing is shared with the MCP client — see ApiResponseReader in Core. + private static Task ParseAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.ParseAsync(res, JsonOptions, ct); - private static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) - { - if (res.IsSuccessStatusCode) return; - var body = await res.Content.ReadAsStringAsync(ct); - string? title = null, detail = null; - if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); - if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); - if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); - } - catch (JsonException) { /* leave body as raw */ } - } - throw new ApiException(res.StatusCode, title, detail, body); - } + private static Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.EnsureSuccessAsync(res, ct); } diff --git a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs index dce5af0e..12e6ab9d 100644 --- a/src/NodePilot.Cli/Api/TokenRefreshHandler.cs +++ b/src/NodePilot.Cli/Api/TokenRefreshHandler.cs @@ -4,6 +4,7 @@ using System.Runtime.Versioning; using NodePilot.Cli.Api.Dtos; using NodePilot.Cli.Auth; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Api; diff --git a/src/NodePilot.Cli/CLAUDE.md b/src/NodePilot.Cli/CLAUDE.md index 1f78d896..8fec47d5 100644 --- a/src/NodePilot.Cli/CLAUDE.md +++ b/src/NodePilot.Cli/CLAUDE.md @@ -5,3 +5,5 @@ Gilt für `src/NodePilot.Cli/`. Projektweite Regeln stehen in der Root-`CLAUDE.m Reiner HTTP-Client gegen die REST-Endpoints (Spectre.Console.Cli), ausgeliefert per `dotnet publish` — **kein** `dotnet global tool` (`PackAsTool` × `net10.0-windows` = NETSDK1146). Befehlsbereiche: `auth`, `workflow`, `exec`, `machine`, `credential`, `globals`, `user`, `shared-folder`, `maintenance`, `alerting`, `system-alert`, `audit`, `backup`, `db`, `cron`, `health`, `dashboard`, `operations`, `observability`, `settings`, `secrets`, `config`. Details: `docs/claude-reference.md`. **Architektur-Konvention:** Neuer API-Endpoint → parallel Methode in `NodePilotApiClient.cs` + Command anlegen. DTOs in `Cli/Api/Dtos/` duplizieren (kein ProjectReference). + +**Geteilte Client-Infrastruktur:** `ApiException`, das Response-Plumbing (`ApiResponseReader`) und die Lese-Seite der `config.json` (`ClientConfigStore` + `CliConfig`) liegen in `NodePilot.Core.Clients` — gemeinsam mit dem MCP-Server. **Nur die DTOs bleiben bewusst dupliziert** (siehe oben, `ApiDtoParityTests`); neue Infrastruktur nicht erneut kopieren. diff --git a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs index de6de210..041add5e 100644 --- a/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs +++ b/src/NodePilot.Cli/Commands/Auth/AuthCommands.cs @@ -5,6 +5,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console; using Spectre.Console.Cli; diff --git a/src/NodePilot.Cli/Commands/BaseCommand.cs b/src/NodePilot.Cli/Commands/BaseCommand.cs index 3c0d840a..e79c9c76 100644 --- a/src/NodePilot.Cli/Commands/BaseCommand.cs +++ b/src/NodePilot.Cli/Commands/BaseCommand.cs @@ -3,6 +3,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console.Cli; namespace NodePilot.Cli.Commands; diff --git a/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs b/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs index aab7335c..caba26a2 100644 --- a/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs +++ b/src/NodePilot.Cli/Commands/Config/ConfigCommands.cs @@ -2,6 +2,7 @@ using System.Runtime.Versioning; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console; using Spectre.Console.Cli; diff --git a/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs b/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs index 1cf32035..a9f91eee 100644 --- a/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs +++ b/src/NodePilot.Cli/Commands/Workflow/WorkflowTriggerCommand.cs @@ -4,6 +4,7 @@ using NodePilot.Cli.Auth; using NodePilot.Cli.Output; using NodePilot.Cli.Settings; +using NodePilot.Core.Clients; using Spectre.Console.Cli; namespace NodePilot.Cli.Commands.Workflow; diff --git a/src/NodePilot.Cli/Settings/ConfigStore.cs b/src/NodePilot.Cli/Settings/ConfigStore.cs index e3309137..9c2d5572 100644 --- a/src/NodePilot.Cli/Settings/ConfigStore.cs +++ b/src/NodePilot.Cli/Settings/ConfigStore.cs @@ -1,54 +1,29 @@ using System.Text.Json; using System.Text.Json.Serialization; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Settings; /// /// Plain-JSON config under %APPDATA%\NodePilot\config.json. Holds non-secret connection /// settings only — tokens live in Auth/TokenStore (DPAPI-encrypted) so a config -/// backup never carries a usable session. +/// backup never carries a usable session. The CLI is the only writer; the read side +/// (path, Load, CliConfig) lives in so the +/// MCP server reads exactly the same file the same way. /// -public sealed class ConfigStore +public sealed class ConfigStore : ClientConfigStore { + // Write-side only: indentation and null-skipping shape the file we emit. Reading goes + // through the base store, which needs neither. private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; - public string ConfigDir { get; } - public string ConfigPath => Path.Combine(ConfigDir, "config.json"); + public ConfigStore() : base(DefaultConfigDir()) { } - public ConfigStore() : this(DefaultConfigDir()) { } - - public ConfigStore(string configDir) - { - ConfigDir = configDir; - Directory.CreateDirectory(ConfigDir); - } - - public static string DefaultConfigDir() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - return Path.Combine(appData, "NodePilot"); - } - - public CliConfig Load() - { - if (!File.Exists(ConfigPath)) - return new CliConfig(); - try - { - using var stream = File.OpenRead(ConfigPath); - return JsonSerializer.Deserialize(stream, JsonOptions) ?? new CliConfig(); - } - catch (JsonException) - { - // Corrupt config → treat as empty rather than blocking the user. They can - // always `np config set server …` to repair, and `np auth login` re-creates. - return new CliConfig(); - } - } + public ConfigStore(string configDir) : base(configDir) { } public void Save(CliConfig config) { @@ -78,14 +53,3 @@ public string ResolveProfileName(string? requested, CliConfig? config = null) return null; } } - -public sealed class CliConfig -{ - public string DefaultProfile { get; set; } = "default"; - public Dictionary Profiles { get; set; } = new(StringComparer.OrdinalIgnoreCase); -} - -public sealed class ProfileEntry -{ - public string? Server { get; set; } -} diff --git a/src/NodePilot.Mcp/Api/ApiException.cs b/src/NodePilot.Core/Clients/ApiException.cs similarity index 79% rename from src/NodePilot.Mcp/Api/ApiException.cs rename to src/NodePilot.Core/Clients/ApiException.cs index d6422b39..b1f40581 100644 --- a/src/NodePilot.Mcp/Api/ApiException.cs +++ b/src/NodePilot.Core/Clients/ApiException.cs @@ -1,11 +1,13 @@ using System.Net; -namespace NodePilot.Mcp.Api; +namespace NodePilot.Core.Clients; /// -/// Thrown by on every non-2xx response. Captures both -/// the HTTP status (so tools can branch on 401/403/409/423) and the parsed -/// ProblemDetails payload when the server returned one. Copied from the CLI client. +/// Thrown by the HTTP-only clients (the np CLI and the nodepilot-mcp server) +/// on every non-2xx response. Captures both the HTTP status (so commands and tools can +/// branch on 401/403/404/409/423) and the parsed ProblemDetails payload when the +/// server returned one. Shared here for the same reason as +/// : the two executables must not drift apart. /// public sealed class ApiException : Exception { diff --git a/src/NodePilot.Core/Clients/ApiResponseReader.cs b/src/NodePilot.Core/Clients/ApiResponseReader.cs new file mode 100644 index 00000000..e144f64f --- /dev/null +++ b/src/NodePilot.Core/Clients/ApiResponseReader.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Text.Json; + +namespace NodePilot.Core.Clients; + +/// +/// Response plumbing shared by the two HTTP-only clients: every non-2xx becomes an +/// carrying the ProblemDetails title/detail, so callers +/// branch on a single exception type. Each client passes its own +/// — the deserializer settings stay client-owned. +/// +public static class ApiResponseReader +{ + public static async Task ParseAsync(HttpResponseMessage res, JsonSerializerOptions jsonOptions, CancellationToken ct) + { + await EnsureSuccessAsync(res, ct); + if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) + return default!; + var stream = await res.Content.ReadAsStreamAsync(ct); + var value = await JsonSerializer.DeserializeAsync(stream, jsonOptions, ct); + if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); + return value; + } + + public static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + { + if (res.IsSuccessStatusCode) return; + var body = await res.Content.ReadAsStringAsync(ct); + string? title = null, detail = null; + if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); + if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); + if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); + } + catch (JsonException) { /* leave body as raw */ } + } + throw new ApiException(res.StatusCode, title, detail, body); + } +} diff --git a/src/NodePilot.Mcp/Config/ConfigStore.cs b/src/NodePilot.Core/Clients/ClientConfigStore.cs similarity index 57% rename from src/NodePilot.Mcp/Config/ConfigStore.cs rename to src/NodePilot.Core/Clients/ClientConfigStore.cs index c8589f58..b684046b 100644 --- a/src/NodePilot.Mcp/Config/ConfigStore.cs +++ b/src/NodePilot.Core/Clients/ClientConfigStore.cs @@ -1,22 +1,24 @@ using System.Text.Json; -namespace NodePilot.Mcp.Config; +namespace NodePilot.Core.Clients; /// -/// Reads the same %APPDATA%\NodePilot\config.json the np CLI writes, so the -/// MCP server can fall back to a CLI-configured profile server URL. Read-only here — the -/// MCP server never writes config; the operator manages it via np config. +/// Reads the plain-JSON config under %APPDATA%\NodePilot\config.json that both +/// HTTP-only clients share: the np CLI owns it (its ConfigStore adds the write +/// side), the nodepilot-mcp server only reads it to fall back to a CLI-configured +/// profile server. Non-secret connection settings only — tokens live in the DPAPI session +/// store so a config backup never carries a usable session. /// -public sealed class ConfigStore +public class ClientConfigStore { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public string ConfigDir { get; } public string ConfigPath => Path.Combine(ConfigDir, "config.json"); - public ConfigStore() : this(DefaultConfigDir()) { } + public ClientConfigStore() : this(DefaultConfigDir()) { } - public ConfigStore(string configDir) + public ClientConfigStore(string configDir) { ConfigDir = configDir; Directory.CreateDirectory(ConfigDir); @@ -30,7 +32,8 @@ public static string DefaultConfigDir() public CliConfig Load() { - if (!File.Exists(ConfigPath)) return new CliConfig(); + if (!File.Exists(ConfigPath)) + return new CliConfig(); try { using var stream = File.OpenRead(ConfigPath); @@ -38,6 +41,8 @@ public CliConfig Load() } catch (JsonException) { + // Corrupt config → treat as empty rather than blocking the user. They can + // always `np config set server …` to repair, and `np auth login` re-creates. return new CliConfig(); } } diff --git a/src/NodePilot.Data/DataMetrics.cs b/src/NodePilot.Data/DataMetrics.cs index d1c8c446..4628f150 100644 --- a/src/NodePilot.Data/DataMetrics.cs +++ b/src/NodePilot.Data/DataMetrics.cs @@ -20,6 +20,40 @@ public static class DataMetrics "nodepilot.credential.crypto.duration", unit: "ms", description: "DPAPI encrypt/decrypt latency, tagged by operation."); + /// + /// Runs one crypto operation and records call-count + latency for it, tagged + /// success/failure. Every routes through + /// here so the two instrumented providers cannot drift into different tag sets — a + /// dashboard splitting on provider would otherwise silently lose a series. + /// Exceptions are recorded as a failure and rethrown unchanged. + /// + public static T MeasureCrypto(string operation, string provider, Func body) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + var result = body(); + Record(operation, provider, "success", sw); + return result; + } + catch + { + Record(operation, provider, "failure", sw); + throw; + } + } + + private static void Record(string operation, string provider, string result, System.Diagnostics.Stopwatch sw) + { + sw.Stop(); + CredentialCryptoCalls.Add(1, + new("operation", operation), + new("provider", provider), + new("result", result)); + var tags = new System.Diagnostics.TagList { new("operation", operation), new("provider", provider) }; + CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); + } + /// /// Counts decrypts served by the *legacy* protector when MigratingSecretProtector is /// wired. Operators watch this drop to zero after a re-encrypt sweep — at that point diff --git a/src/NodePilot.Data/DbErrorClassifier.cs b/src/NodePilot.Data/DbErrorClassifier.cs index 2ebf809e..f7f6c48c 100644 --- a/src/NodePilot.Data/DbErrorClassifier.cs +++ b/src/NodePilot.Data/DbErrorClassifier.cs @@ -186,15 +186,6 @@ public static DbFailureKind Classify(Exception? exception) /// public static bool IsCommandTimeout(Exception? exception) => Classify(exception) is DbFailureKind.CommandTimeout; - /// True when nothing is listening or the transport died. See . - public static bool IsConnectionFailure(Exception? exception) => Classify(exception) is DbFailureKind.ConnectionFailure; - - /// True when the server answered and refused. See . - public static bool IsConnectionRejectedByServer(Exception? exception) => Classify(exception) is DbFailureKind.ConnectionRejected; - - /// True when the server is alive but out of a resource. See . - public static bool IsCapacityBackpressure(Exception? exception) => Classify(exception) is DbFailureKind.CapacityBackpressure; - private static DbFailureKind ClassifyOne(Exception exception) { // Pool exhaustion is checked before anything else about the exception, because both providers diff --git a/src/NodePilot.Data/Security/AesGcmSecretProtector.cs b/src/NodePilot.Data/Security/AesGcmSecretProtector.cs index 08df1f41..e2b045e6 100644 --- a/src/NodePilot.Data/Security/AesGcmSecretProtector.cs +++ b/src/NodePilot.Data/Security/AesGcmSecretProtector.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Security.Cryptography; using System.Text; using NodePilot.Core.Interfaces; @@ -25,9 +24,6 @@ namespace NodePilot.Data.Security; public sealed class AesGcmSecretProtector : ISecretProtector { private readonly byte[] _key; - private const byte Version = 0x01; - private const int NonceSize = 12; // GCM standard - private const int TagSize = 16; // 128-bit GCM tag public string ProviderName => "AesGcm"; @@ -41,95 +37,23 @@ public AesGcmSecretProtector(byte[] masterKey) _key = masterKey; } - public byte[] Protect(string plaintext) - { - var sw = Stopwatch.StartNew(); - try - { - var plain = Encoding.UTF8.GetBytes(plaintext); - var nonce = new byte[NonceSize]; - RandomNumberGenerator.Fill(nonce); - var ciphertext = new byte[plain.Length]; - var tag = new byte[TagSize]; - - // .NET 10's AesGcm constructor takes the key + tag size — the latter is required - // since 9.0 (was inferable before; explicit prevents later breaking-change pain). - using var gcm = new AesGcm(_key, TagSize); - gcm.Encrypt(nonce, plain, ciphertext, tag); + public byte[] Protect(string plaintext) => DataMetrics.MeasureCrypto("encrypt", ProviderName, () => + SecretEnvelope.Seal(Encoding.UTF8.GetBytes(plaintext), _key)); - var blob = new byte[1 + NonceSize + ciphertext.Length + TagSize]; - blob[0] = Version; - Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); - Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); - Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); + private const string TooShortMessage = "AES-GCM blob is shorter than the minimum envelope (header + nonce + tag)."; - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return blob; - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + private static string UnknownVersionMessage(byte actual) => + $"Unknown AES-GCM envelope version 0x{actual:X2}. Expected {SecretEnvelope.ExpectedVersionHex}. " + + "Was the row written by a different ISecretProtector?"; public string Unprotect(byte[] blob) { - ArgumentNullException.ThrowIfNull(blob); - if (blob.Length < 1 + NonceSize + TagSize) - throw new CryptographicException("AES-GCM blob is shorter than the minimum envelope (header + nonce + tag)."); - if (blob[0] != Version) - throw new CryptographicException( - $"Unknown AES-GCM envelope version 0x{blob[0]:X2}. Expected 0x{Version:X2}. " + - "Was the row written by a different ISecretProtector?"); + // Header validation runs OUTSIDE the measured region, as it always has: a malformed blob + // is a caller error, not a crypto failure, and must not land in the failed-decrypt series. + SecretEnvelope.ValidateHeader(blob, TooShortMessage, UnknownVersionMessage); - var sw = Stopwatch.StartNew(); - try - { - var ciphertextLength = blob.Length - 1 - NonceSize - TagSize; - var nonce = new byte[NonceSize]; - var ciphertext = new byte[ciphertextLength]; - var tag = new byte[TagSize]; - Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); - Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); - Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); - - var plain = new byte[ciphertextLength]; - using var gcm = new AesGcm(_key, TagSize); - gcm.Decrypt(nonce, ciphertext, tag, plain); - - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return Encoding.UTF8.GetString(plain); - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } + return DataMetrics.MeasureCrypto("decrypt", ProviderName, () => + Encoding.UTF8.GetString(SecretEnvelope.Open(blob, _key, TooShortMessage, UnknownVersionMessage))); } /// diff --git a/src/NodePilot.Data/Security/DpapiSecretProtector.cs b/src/NodePilot.Data/Security/DpapiSecretProtector.cs index 1cce6c9c..04fc2c1d 100644 --- a/src/NodePilot.Data/Security/DpapiSecretProtector.cs +++ b/src/NodePilot.Data/Security/DpapiSecretProtector.cs @@ -27,60 +27,9 @@ public DpapiSecretProtector(DataProtectionScope scope) _scope = scope; } - public byte[] Protect(string plaintext) - { - var sw = Stopwatch.StartNew(); - try - { - var bytes = Encoding.UTF8.GetBytes(plaintext); - var result = ProtectedData.Protect(bytes, null, _scope); - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return result; - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "encrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "encrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + public byte[] Protect(string plaintext) => DataMetrics.MeasureCrypto("encrypt", ProviderName, () => + ProtectedData.Protect(Encoding.UTF8.GetBytes(plaintext), null, _scope)); - public string Unprotect(byte[] blob) - { - var sw = Stopwatch.StartNew(); - try - { - var bytes = ProtectedData.Unprotect(blob, null, _scope); - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "success")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - return Encoding.UTF8.GetString(bytes); - } - catch - { - sw.Stop(); - DataMetrics.CredentialCryptoCalls.Add(1, - new("operation", "decrypt"), - new("provider", ProviderName), - new("result", "failure")); - var tags = new TagList { new("operation", "decrypt"), new("provider", ProviderName) }; - DataMetrics.CredentialCryptoDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - throw; - } - } + public string Unprotect(byte[] blob) => DataMetrics.MeasureCrypto("decrypt", ProviderName, () => + Encoding.UTF8.GetString(ProtectedData.Unprotect(blob, null, _scope))); } diff --git a/src/NodePilot.Data/Security/PassphraseSecretProtector.cs b/src/NodePilot.Data/Security/PassphraseSecretProtector.cs index e49becea..da2615b8 100644 --- a/src/NodePilot.Data/Security/PassphraseSecretProtector.cs +++ b/src/NodePilot.Data/Security/PassphraseSecretProtector.cs @@ -35,9 +35,6 @@ public sealed class PassphraseSecretProtector : ISecretProtector public const int DefaultIterations = 600_000; public const int SaltSize = 16; - private const byte Version = 0x01; - private const int NonceSize = 12; // GCM standard - private const int TagSize = 16; // 128-bit GCM tag private const int KeySize = 32; // 256-bit subkeys // Distinct HKDF info labels keep the three derived subkeys cryptographically independent. @@ -143,42 +140,10 @@ public bool VerifyPassphrase(byte[] verifierBlob) } } - private static byte[] EncryptGcm(byte[] key, byte[] plain) - { - var nonce = new byte[NonceSize]; - RandomNumberGenerator.Fill(nonce); - var ciphertext = new byte[plain.Length]; - var tag = new byte[TagSize]; - using (var gcm = new AesGcm(key, TagSize)) - gcm.Encrypt(nonce, plain, ciphertext, tag); - - var blob = new byte[1 + NonceSize + ciphertext.Length + TagSize]; - blob[0] = Version; - Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); - Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); - Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); - return blob; - } + private static byte[] EncryptGcm(byte[] key, byte[] plain) => SecretEnvelope.Seal(plain, key); - private static byte[] DecryptGcm(byte[] key, byte[] blob) - { - ArgumentNullException.ThrowIfNull(blob); - if (blob.Length < 1 + NonceSize + TagSize) - throw new CryptographicException("Backup secret blob is shorter than the minimum envelope."); - if (blob[0] != Version) - throw new CryptographicException($"Unknown backup secret envelope version 0x{blob[0]:X2}."); - - var ciphertextLength = blob.Length - 1 - NonceSize - TagSize; - var nonce = new byte[NonceSize]; - var ciphertext = new byte[ciphertextLength]; - var tag = new byte[TagSize]; - Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); - Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); - Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); - - var plain = new byte[ciphertextLength]; - using var gcm = new AesGcm(key, TagSize); - gcm.Decrypt(nonce, ciphertext, tag, plain); - return plain; - } + private static byte[] DecryptGcm(byte[] key, byte[] blob) => SecretEnvelope.Open( + blob, key, + "Backup secret blob is shorter than the minimum envelope.", + actual => $"Unknown backup secret envelope version 0x{actual:X2}."); } diff --git a/src/NodePilot.Data/Security/SecretEnvelope.cs b/src/NodePilot.Data/Security/SecretEnvelope.cs new file mode 100644 index 00000000..12f25890 --- /dev/null +++ b/src/NodePilot.Data/Security/SecretEnvelope.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; + +namespace NodePilot.Data.Security; + +/// +/// The on-disk/in-column AES-GCM envelope every NodePilot secret is wrapped in: +/// [version:1][nonce:12][ciphertext:n][tag:16]. +/// +/// This is a persisted wire format — a stored credential written today must still open years +/// later. It therefore lives in exactly one place: (credential +/// column) and (backup rewrap) previously carried +/// byte-compatible copies of the layout, where any edit to one would have silently produced blobs +/// the other could not read. +/// +/// +internal static class SecretEnvelope +{ + private const byte Version = 0x01; + private const int NonceSize = 12; // GCM standard + private const int TagSize = 16; // 128-bit GCM tag + + /// Smallest possible envelope: header + nonce + tag, with empty ciphertext. + public const int MinLength = 1 + NonceSize + TagSize; + + public static byte[] Seal(byte[] plain, byte[] key) + { + var nonce = new byte[NonceSize]; + RandomNumberGenerator.Fill(nonce); + var ciphertext = new byte[plain.Length]; + var tag = new byte[TagSize]; + + // .NET 10's AesGcm constructor takes the key + tag size — the latter is required + // since 9.0 (was inferable before; explicit prevents later breaking-change pain). + using (var gcm = new AesGcm(key, TagSize)) + gcm.Encrypt(nonce, plain, ciphertext, tag); + + var blob = new byte[MinLength + ciphertext.Length]; + blob[0] = Version; + Buffer.BlockCopy(nonce, 0, blob, 1, NonceSize); + Buffer.BlockCopy(ciphertext, 0, blob, 1 + NonceSize, ciphertext.Length); + Buffer.BlockCopy(tag, 0, blob, 1 + NonceSize + ciphertext.Length, TagSize); + return blob; + } + + /// + /// Rejects a null, truncated or foreign-versioned blob. Separate from so + /// a caller can run it outside its metrics scope — a malformed blob is a caller error, not a + /// crypto failure, and must not land in the failed-decrypt series. + /// and keep each + /// caller's diagnostic wording: the credential path points at a mismatched ISecretProtector, + /// the backup path at a foreign archive, and an operator needs to know which they are holding. + /// + public static void ValidateHeader(byte[] blob, string tooShortMessage, Func unknownVersionMessage) + { + ArgumentNullException.ThrowIfNull(blob); + if (blob.Length < MinLength) + throw new CryptographicException(tooShortMessage); + if (blob[0] != Version) + throw new CryptographicException(unknownVersionMessage(blob[0])); + } + + /// Validates the header (see ) and decrypts. + public static byte[] Open(byte[] blob, byte[] key, string tooShortMessage, Func unknownVersionMessage) + { + ValidateHeader(blob, tooShortMessage, unknownVersionMessage); + + var ciphertextLength = blob.Length - MinLength; + var nonce = new byte[NonceSize]; + var ciphertext = new byte[ciphertextLength]; + var tag = new byte[TagSize]; + Buffer.BlockCopy(blob, 1, nonce, 0, NonceSize); + Buffer.BlockCopy(blob, 1 + NonceSize, ciphertext, 0, ciphertextLength); + Buffer.BlockCopy(blob, 1 + NonceSize + ciphertextLength, tag, 0, TagSize); + + var plain = new byte[ciphertextLength]; + using var gcm = new AesGcm(key, TagSize); + gcm.Decrypt(nonce, ciphertext, tag, plain); + return plain; + } + + /// Renders the expected version byte for a caller's diagnostic message. + public static string ExpectedVersionHex => $"0x{Version:X2}"; +} diff --git a/src/NodePilot.Engine/Activities/BaseActivity.cs b/src/NodePilot.Engine/Activities/BaseActivity.cs index d6630c7c..e94b85fe 100644 --- a/src/NodePilot.Engine/Activities/BaseActivity.cs +++ b/src/NodePilot.Engine/Activities/BaseActivity.cs @@ -191,10 +191,52 @@ internal static bool IsLoopbackHostname(string? hostname) protected virtual ActivityResult PostProcess(ActivityResult raw, JsonElement config) => raw; /// - /// Helper for PostProcess overrides: copies a string field from the JSON object into the - /// target dictionary. A ValueKind.String is copied via GetString(), Null/Undefined - /// is stored as an empty string, and every other kind is serialized via GetRawText(). + /// Shared preamble of every marker-envelope PostProcess override: parses the JSON block the + /// script wrote between the result markers. Returns false when the caller must return + /// instead of projecting — either the untouched raw result (no + /// envelope in the output, e.g. the script died before reaching it) or a failure carrying + /// ": could not parse result JSON: …" for a malformed envelope. /// - protected static void CopyStringField(JsonElement obj, string sourceKey, IDictionary dest, string destKey) - => PowerShellOperation.CopyStringField(obj, sourceKey, dest, destKey); + private protected static bool TryParseResultEnvelope( + ActivityResult raw, + PowerShellOperationMarkers markers, + string label, + out JsonDocument? document, + out ActivityResult? passthrough) + { + if (PowerShellOperation.TryParseJsonBlock(raw.Output, markers, out document, out var parseError)) + { + passthrough = null; + return true; + } + + passthrough = parseError is null + ? raw + : new ActivityResult + { + Success = false, + Output = raw.Output, + ErrorOutput = $"{label}: could not parse result JSON: {parseError}", + Duration = raw.Duration, + }; + return false; + } + + /// + /// Returns unchanged when nothing was projected, otherwise a copy that + /// carries the parameters. Success/Output/ErrorOutput/Duration are passed through verbatim. + /// + protected static ActivityResult WithOutputParameters(ActivityResult raw, Dictionary parameters) + { + if (parameters.Count == 0) return raw; + + return new ActivityResult + { + Success = raw.Success, + Output = raw.Output, + ErrorOutput = raw.ErrorOutput, + Duration = raw.Duration, + OutputParameters = parameters, + }; + } } diff --git a/src/NodePilot.Engine/Activities/FileHashActivity.cs b/src/NodePilot.Engine/Activities/FileHashActivity.cs index 3511e605..5706965b 100644 --- a/src/NodePilot.Engine/Activities/FileHashActivity.cs +++ b/src/NodePilot.Engine/Activities/FileHashActivity.cs @@ -76,17 +76,8 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co { if (!raw.Success) return raw; - if (!PowerShellOperation.TryParseJsonBlock(raw.Output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"File Hash: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "File Hash", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/FileOperationActivity.cs b/src/NodePilot.Engine/Activities/FileOperationActivity.cs index 5a2f4d3a..38af6f9d 100644 --- a/src/NodePilot.Engine/Activities/FileOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FileOperationActivity.cs @@ -1,8 +1,6 @@ -using System.Text.Json; using Microsoft.Extensions.Configuration; using NodePilot.Core.Interfaces; using NodePilot.Engine.PowerShell; -using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -16,15 +14,18 @@ namespace NodePilot.Engine.Activities; /// PostProcess projects into OutputParameters (param.operation, param.path, param.destination, /// param.newPath, param.exists, param.fullName — depending on the operation). This guarantees /// that {{step.param.exists}} is always "true"/"false" and downstream steps can rely on -/// a consistent set of keys. +/// a consistent set of keys. Validation, envelope and projection live in +/// . /// -public class FileOperationActivity : BaseRemoteActivity +public class FileOperationActivity : FileSystemOperationActivityBase { public override string ActivityType => "fileOperation"; - private static readonly PowerShellOperationMarkers ResultMarkers = PowerShellOperation.Markers("FILEOP"); + protected override string OperationLabel => "File Operation"; - private readonly IConfiguration _config; + protected override string SupportedOperations => "copy, move, delete, exists, create, rename"; + + protected override int ResultJsonDepth => 4; public FileOperationActivity( IRemoteSessionFactory sessionFactory, @@ -32,66 +33,20 @@ public FileOperationActivity( NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, IConfiguration config) - : base(sessionFactory, credentialStore, db, engineFactory, config) + : base(sessionFactory, credentialStore, db, engineFactory, config, "FILEOP") { - _config = config; } - protected override string BuildScript(JsonElement config, StepExecutionContext context) + protected override string BuildOperationBody(string operation) => operation switch { - var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(operation)) - throw new InvalidOperationException("File Operation: 'operation' is required (copy, move, delete, exists, create, rename)"); - - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - throw new InvalidOperationException("File Operation: 'path' is required"); - - var destination = config.GetStringOrNull("destination"); - var newName = config.GetStringOrNull("newName"); - - PathGuard.Validate(_config, path, allowWildcards: false); - if (!string.IsNullOrWhiteSpace(destination)) - PathGuard.Validate(_config, destination, allowWildcards: false); - if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) - PathGuard.ValidateSiblingRenameTarget(_config, path, newName); - - if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) - throw new InvalidOperationException($"File Operation '{operation}' requires 'destination'"); - if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("File Operation 'rename' requires 'newName'"); - - var qPath = PowerShellOperation.Literal(path); - var qDest = PowerShellOperation.Literal(destination); - var qNewName = PowerShellOperation.Literal(newName); - - var opBody = operation switch - { - "copy" => BuildCopy(), - "move" => BuildMove(), - "delete" => BuildDelete(), - "exists" => BuildExists(), - "create" => BuildCreate(), - "rename" => BuildRename(), - _ => throw new InvalidOperationException($"Unknown file operation: {operation}") - }; - - // operation is whitelisted (any other variant throws above), so direct interpolation is safe. - return $$""" - $ErrorActionPreference = 'Stop' - $__path = {{qPath}} - $__destination = {{qDest}} - $__newName = {{qNewName}} - $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } - try { - {{opBody}} - } catch { - $__result.ok = $false - $__result.error = $_.Exception.Message - } - {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 4)}} - """; - } + "copy" => BuildCopy(), + "move" => BuildMove(), + "delete" => BuildDelete(), + "exists" => BuildExists(), + "create" => BuildCreate(), + "rename" => BuildRename(), + _ => throw new InvalidOperationException($"Unknown file operation: {operation}") + }; // Leaf-Assertion: ensures the path is a file before mutation, so a folder typed here // by mistake throws cleanly instead of being copied/moved/deleted as if it were a file. @@ -142,92 +97,4 @@ private static string BuildRename() => $$""" $__result.newPath = $__target $__result.newName = $__newName """; - - protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) - { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"File Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } - - using (doc!) - { - var root = doc!.RootElement; - var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); - var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; - - if (!ok) - { - var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; - return new ActivityResult - { - Success = false, - Output = null, - ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, - Duration = raw.Duration, - }; - } - - var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["operation"] = operation, - }; - if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) - parameters["path"] = pathEl.GetString() ?? ""; - - string display; - switch (operation) - { - case "copy": - case "move": - if (root.TryGetProperty("destination", out var destEl)) - parameters["destination"] = destEl.GetString() ?? ""; - display = $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; - break; - - case "exists": - var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); - parameters["exists"] = exists ? "true" : "false"; - display = exists ? "True" : "False"; - break; - - case "create": - if (root.TryGetProperty("fullName", out var fnEl)) - parameters["fullName"] = fnEl.GetString() ?? ""; - if (root.TryGetProperty("creationTime", out var ctEl)) - parameters["creationTime"] = ctEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("fullName") ?? ""; - break; - - case "rename": - if (root.TryGetProperty("newPath", out var npEl)) - parameters["newPath"] = npEl.GetString() ?? ""; - if (root.TryGetProperty("newName", out var nnEl)) - parameters["newName"] = nnEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("newPath") ?? ""; - break; - - default: - display = "OK"; - break; - } - - return new ActivityResult - { - Success = true, - Output = display, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = parameters, - }; - } - } } diff --git a/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs new file mode 100644 index 00000000..969c33b0 --- /dev/null +++ b/src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Interfaces; +using NodePilot.Engine.PowerShell; +using NodePilot.Engine.Security; + +namespace NodePilot.Engine.Activities; + +/// +/// Shared skeleton of the two path-scoped activities — and +/// . Both validate the same config surface (operation, path, +/// destination, newName), emit the same JSON result envelope and project it into the same +/// OutputParameters; only the PowerShell bodies, the wording of the user-facing strings and the +/// extra list operation of the folder variant differ. Everything user-visible is therefore +/// supplied by the subclass — label, operation list, envelope depth, marker token. +/// +public abstract class FileSystemOperationActivityBase : BaseRemoteActivity +{ + private readonly IConfiguration _config; + private readonly PowerShellOperationMarkers _resultMarkers; + + protected FileSystemOperationActivityBase( + IRemoteSessionFactory sessionFactory, + ICredentialStore credentialStore, + NodePilot.Data.NodePilotDbContext db, + PowerShellEngineFactory engineFactory, + IConfiguration config, + string markerToken) + : base(sessionFactory, credentialStore, db, engineFactory, config) + { + _config = config; + _resultMarkers = PowerShellOperation.Markers(markerToken); + } + + /// Prefix of every user-facing message — "File Operation" / "Folder Operation". + protected abstract string OperationLabel { get; } + + /// Operations named in the "'operation' is required" message, in UI order. + protected abstract string SupportedOperations { get; } + + /// ConvertTo-Json -Depth for the result envelope. + protected abstract int ResultJsonDepth { get; } + + /// + /// PowerShell body for the (already lower-cased and validated-as-present) operation. The + /// subclass owns the unknown-operation throw, because its wording names the scope. + /// + protected abstract string BuildOperationBody(string operation); + + protected override string BuildScript(JsonElement config, StepExecutionContext context) + { + var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(operation)) + throw new InvalidOperationException($"{OperationLabel}: 'operation' is required ({SupportedOperations})"); + + var path = config.GetStringOrNull("path"); + if (string.IsNullOrWhiteSpace(path)) + throw new InvalidOperationException($"{OperationLabel}: 'path' is required"); + + var destination = config.GetStringOrNull("destination"); + var newName = config.GetStringOrNull("newName"); + + PathGuard.Validate(_config, path, allowWildcards: false); + if (!string.IsNullOrWhiteSpace(destination)) + PathGuard.Validate(_config, destination, allowWildcards: false); + if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) + PathGuard.ValidateSiblingRenameTarget(_config, path, newName); + + if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) + throw new InvalidOperationException($"{OperationLabel} '{operation}' requires 'destination'"); + if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) + throw new InvalidOperationException($"{OperationLabel} 'rename' requires 'newName'"); + + var qPath = PowerShellOperation.Literal(path); + var qDest = PowerShellOperation.Literal(destination); + var qNewName = PowerShellOperation.Literal(newName); + + var opBody = BuildOperationBody(operation); + + // operation is whitelisted (any other variant throws above), so direct interpolation is safe. + return $$""" + $ErrorActionPreference = 'Stop' + $__path = {{qPath}} + $__destination = {{qDest}} + $__newName = {{qNewName}} + $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } + try { + {{opBody}} + } catch { + $__result.ok = $false + $__result.error = $_.Exception.Message + } + {{_resultMarkers.RenderJsonEnvelope("$__result", depth: ResultJsonDepth)}} + """; + } + + protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) + { + if (!TryParseResultEnvelope(raw, _resultMarkers, OperationLabel, out var doc, out var passthrough)) + return passthrough!; + + using (doc!) + { + var root = doc!.RootElement; + var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); + var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; + + if (!ok) + { + var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; + return new ActivityResult + { + Success = false, + Output = null, + ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, + Duration = raw.Duration, + }; + } + + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["operation"] = operation, + }; + if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) + parameters["path"] = pathEl.GetString() ?? ""; + + var display = ProjectOperationOutputs(operation, root, parameters); + + return new ActivityResult + { + Success = true, + Output = display, + ErrorOutput = raw.ErrorOutput, + Duration = raw.Duration, + OutputParameters = parameters, + }; + } + } + + private string ProjectOperationOutputs(string operation, JsonElement root, Dictionary parameters) + { + switch (operation) + { + case "copy": + case "move": + if (root.TryGetProperty("destination", out var destEl)) + parameters["destination"] = destEl.GetString() ?? ""; + return $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; + + case "exists": + var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); + parameters["exists"] = exists ? "true" : "false"; + return exists ? "True" : "False"; + + case "create": + if (root.TryGetProperty("fullName", out var fnEl)) + parameters["fullName"] = fnEl.GetString() ?? ""; + if (root.TryGetProperty("creationTime", out var ctEl)) + parameters["creationTime"] = ctEl.GetString() ?? ""; + return parameters.GetValueOrDefault("fullName") ?? ""; + + case "rename": + if (root.TryGetProperty("newPath", out var npEl)) + parameters["newPath"] = npEl.GetString() ?? ""; + if (root.TryGetProperty("newName", out var nnEl)) + parameters["newName"] = nnEl.GetString() ?? ""; + return parameters.GetValueOrDefault("newPath") ?? ""; + + default: + return ProjectExtraOperation(operation, root, parameters) ?? "OK"; + } + } + + /// + /// Hook for an operation only one of the two activities offers (folder: list). Returns + /// the display string, or null to fall back to the shared default. + /// + protected virtual string? ProjectExtraOperation( + string operation, + JsonElement root, + Dictionary parameters) => null; +} diff --git a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs index 15b01d3d..e9d8e5b0 100644 --- a/src/NodePilot.Engine/Activities/FolderOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/FolderOperationActivity.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Configuration; using NodePilot.Core.Interfaces; using NodePilot.Engine.PowerShell; -using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -16,15 +15,18 @@ namespace NodePilot.Engine.Activities; /// PostProcess projects into OutputParameters (param.operation, param.path, param.destination, /// param.newPath, param.exists, param.fullName, param.items, param.count — depending on the /// operation). This guarantees that {{step.param.exists}} is always "true"/"false" and -/// {{step.param.items}} is always a JSON array. +/// {{step.param.items}} is always a JSON array. Validation, envelope and projection live +/// in ; only list is folder-specific. /// -public class FolderOperationActivity : BaseRemoteActivity +public class FolderOperationActivity : FileSystemOperationActivityBase { public override string ActivityType => "folderOperation"; - private static readonly PowerShellOperationMarkers ResultMarkers = PowerShellOperation.Markers("FOLDEROP"); + protected override string OperationLabel => "Folder Operation"; - private readonly IConfiguration _config; + protected override string SupportedOperations => "copy, move, delete, exists, list, create, rename"; + + protected override int ResultJsonDepth => 6; public FolderOperationActivity( IRemoteSessionFactory sessionFactory, @@ -32,66 +34,21 @@ public FolderOperationActivity( NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, IConfiguration config) - : base(sessionFactory, credentialStore, db, engineFactory, config) + : base(sessionFactory, credentialStore, db, engineFactory, config, "FOLDEROP") { - _config = config; } - protected override string BuildScript(JsonElement config, StepExecutionContext context) + protected override string BuildOperationBody(string operation) => operation switch { - var operation = config.GetStringOrNull("operation")?.ToLowerInvariant(); - if (string.IsNullOrWhiteSpace(operation)) - throw new InvalidOperationException("Folder Operation: 'operation' is required (copy, move, delete, exists, list, create, rename)"); - - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - throw new InvalidOperationException("Folder Operation: 'path' is required"); - - var destination = config.GetStringOrNull("destination"); - var newName = config.GetStringOrNull("newName"); - - PathGuard.Validate(_config, path, allowWildcards: false); - if (!string.IsNullOrWhiteSpace(destination)) - PathGuard.Validate(_config, destination, allowWildcards: false); - if (string.Equals(operation, "rename", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(newName)) - PathGuard.ValidateSiblingRenameTarget(_config, path, newName); - - if ((operation == "copy" || operation == "move") && string.IsNullOrWhiteSpace(destination)) - throw new InvalidOperationException($"Folder Operation '{operation}' requires 'destination'"); - if (operation == "rename" && string.IsNullOrWhiteSpace(newName)) - throw new InvalidOperationException("Folder Operation 'rename' requires 'newName'"); - - var qPath = PowerShellOperation.Literal(path); - var qDest = PowerShellOperation.Literal(destination); - var qNewName = PowerShellOperation.Literal(newName); - - var opBody = operation switch - { - "copy" => BuildCopy(), - "move" => BuildMove(), - "delete" => BuildDelete(), - "exists" => BuildExists(), - "list" => BuildList(), - "create" => BuildCreate(), - "rename" => BuildRename(), - _ => throw new InvalidOperationException($"Unknown folder operation: {operation}") - }; - - return $$""" - $ErrorActionPreference = 'Stop' - $__path = {{qPath}} - $__destination = {{qDest}} - $__newName = {{qNewName}} - $__result = [ordered]@{ operation = '{{operation}}'; path = $__path; ok = $true } - try { - {{opBody}} - } catch { - $__result.ok = $false - $__result.error = $_.Exception.Message - } - {{ResultMarkers.RenderJsonEnvelope("$__result", depth: 6)}} - """; - } + "copy" => BuildCopy(), + "move" => BuildMove(), + "delete" => BuildDelete(), + "exists" => BuildExists(), + "list" => BuildList(), + "create" => BuildCreate(), + "rename" => BuildRename(), + _ => throw new InvalidOperationException($"Unknown folder operation: {operation}") + }; // Container-Assertion: ensures the path is a folder before mutation, so a file typed // here by mistake throws cleanly instead of being copied/moved/deleted as if it were @@ -163,101 +120,19 @@ private static string BuildRename() => $$""" $__result.newName = $__newName """; - protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) + protected override string? ProjectExtraOperation( + string operation, + JsonElement root, + Dictionary parameters) { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Folder Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } - - using (doc!) - { - var root = doc!.RootElement; - var ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean(); - var operation = root.TryGetProperty("operation", out var opEl) ? opEl.GetString() ?? "" : ""; - - if (!ok) - { - var err = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null; - return new ActivityResult - { - Success = false, - Output = null, - ErrorOutput = string.IsNullOrEmpty(err) ? raw.ErrorOutput : err, - Duration = raw.Duration, - }; - } - - var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["operation"] = operation, - }; - if (root.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String) - parameters["path"] = pathEl.GetString() ?? ""; - - string display; - switch (operation) - { - case "copy": - case "move": - if (root.TryGetProperty("destination", out var destEl)) - parameters["destination"] = destEl.GetString() ?? ""; - display = $"{operation}: {parameters.GetValueOrDefault("path")} -> {parameters.GetValueOrDefault("destination")}"; - break; - - case "exists": - var exists = root.TryGetProperty("exists", out var eEl) && eEl.GetBoolean(); - parameters["exists"] = exists ? "true" : "false"; - display = exists ? "True" : "False"; - break; - - case "list": - if (root.TryGetProperty("items", out var itemsEl)) - parameters["items"] = itemsEl.GetRawText(); - var count = root.TryGetProperty("count", out var cEl) ? cEl.GetInt32() : 0; - parameters["count"] = count.ToString(); - if (root.TryGetProperty("truncated", out var truncEl) && truncEl.ValueKind != JsonValueKind.Null) - parameters["truncated"] = truncEl.GetBoolean() ? "true" : "false"; - display = root.TryGetProperty("items", out var itemsEl2) ? itemsEl2.GetRawText() : "[]"; - break; - - case "create": - if (root.TryGetProperty("fullName", out var fnEl)) - parameters["fullName"] = fnEl.GetString() ?? ""; - if (root.TryGetProperty("creationTime", out var ctEl)) - parameters["creationTime"] = ctEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("fullName") ?? ""; - break; - - case "rename": - if (root.TryGetProperty("newPath", out var npEl)) - parameters["newPath"] = npEl.GetString() ?? ""; - if (root.TryGetProperty("newName", out var nnEl)) - parameters["newName"] = nnEl.GetString() ?? ""; - display = parameters.GetValueOrDefault("newPath") ?? ""; - break; - - default: - display = "OK"; - break; - } - - return new ActivityResult - { - Success = true, - Output = display, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = parameters, - }; - } + if (operation != "list") return null; + + if (root.TryGetProperty("items", out var itemsEl)) + parameters["items"] = itemsEl.GetRawText(); + var count = root.TryGetProperty("count", out var cEl) ? cEl.GetInt32() : 0; + parameters["count"] = count.ToString(); + if (root.TryGetProperty("truncated", out var truncEl) && truncEl.ValueKind != JsonValueKind.Null) + parameters["truncated"] = truncEl.GetBoolean() ? "true" : "false"; + return root.TryGetProperty("items", out var itemsEl2) ? itemsEl2.GetRawText() : "[]"; } } diff --git a/src/NodePilot.Engine/Activities/ForEachActivity.cs b/src/NodePilot.Engine/Activities/ForEachActivity.cs index fe834e95..4773320c 100644 --- a/src/NodePilot.Engine/Activities/ForEachActivity.cs +++ b/src/NodePilot.Engine/Activities/ForEachActivity.cs @@ -1,12 +1,12 @@ using System.Diagnostics; using System.Text.Json; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NodePilot.Core.Enums; using NodePilot.Core.Interfaces; using NodePilot.Core.Models; using NodePilot.Data; using NodePilot.Engine.Execution; +using NodePilot.Engine.PowerShell; namespace NodePilot.Engine.Activities; @@ -207,34 +207,25 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, private async Task<(Workflow? Workflow, string? Error)> ResolveChildWorkflowAsync(string nameOrId, CancellationToken ct) { - Workflow? workflow; - if (Guid.TryParse(nameOrId, out var id)) + // Exact-case wins, then case-insensitive; ambiguous names fail the step. + var (outcome, workflow) = await SubWorkflowInvocation.ResolveChildWorkflowAsync(_db, nameOrId, ct); + return outcome switch { - workflow = await _db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); - } - else - { - // Exact-case wins, then case-insensitive; ambiguous names fail the step. - var resolved = await WorkflowNameResolver.ResolveByNameAsync(_db.Workflows, nameOrId, ct); - if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) - return (null, $"forEach: multiple workflows named '{nameOrId}' — disambiguate with the GUID"); - workflow = resolved.Workflow; - } - - if (workflow is null) - return (null, $"forEach: child workflow '{nameOrId}' not found"); - if (!workflow.IsEnabled) - return (null, $"forEach: child workflow '{workflow.Name}' is disabled"); - return (workflow, null); + SubWorkflowInvocation.ChildOutcome.Ambiguous => + (null, $"forEach: multiple workflows named '{nameOrId}' — disambiguate with the GUID"), + SubWorkflowInvocation.ChildOutcome.NotFound => + (null, $"forEach: child workflow '{nameOrId}' not found"), + SubWorkflowInvocation.ChildOutcome.Disabled => + (null, $"forEach: child workflow '{workflow!.Name}' is disabled"), + _ => (workflow, null), + }; } private async Task<(int CurrentDepth, string? Error)> ValidateCallContextAsync(StepExecutionContext context, Workflow childWorkflow, CancellationToken ct) { // Self-invocation guard — identical to startWorkflow. - var parentExec = await _db.WorkflowExecutions - .AsNoTracking() - .FirstOrDefaultAsync(e => e.Id == context.WorkflowExecutionId, ct); - if (parentExec is not null && parentExec.WorkflowId == childWorkflow.Id) + var parentExec = await SubWorkflowInvocation.LoadParentExecutionAsync(_db, context.WorkflowExecutionId, ct); + if (SubWorkflowInvocation.IsSelfInvocation(parentExec, childWorkflow)) return (0, "forEach: self-invocation is not allowed (direct recursion)"); // RBAC sub-workflow runtime check — identical model to StartWorkflowActivity. @@ -242,20 +233,13 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, // still iterate a forEach over a child workflow they no longer have permission to // start. Defense in depth: PrePublishChecklist enforces the same check at save // time, but folder permissions can be revoked or rotated mid-flight. - if (_subWorkflowAuthz is not null && parentExec is not null) - { - var blocked = await _subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); - if (blocked is not null) - return (0, $"forEach: {blocked}"); - } + var blocked = await SubWorkflowInvocation.GetAuthorizationBlockAsync( + _subWorkflowAuthz, parentExec, childWorkflow, ct); + if (blocked is not null) + return (0, $"forEach: {blocked}"); // Call-depth guard. - var currentDepth = 0; - if (context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) - && int.TryParse(depthStr, out var parsed)) - { - currentDepth = parsed; - } + var currentDepth = SubWorkflowInvocation.CurrentCallDepth(context); if (currentDepth >= WorkflowRecursion.MaxCallDepth) return (currentDepth, $"forEach: call depth limit ({WorkflowRecursion.MaxCallDepth}) exceeded"); @@ -264,25 +248,10 @@ private static (List? List, string? Error) ParseItemsOrError(string raw, private static Dictionary CollectStaticParams(JsonElement config, out string? error) { - error = null; - var staticParams = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (!config.TryGetProperty("parameters", out var paramsEl) || paramsEl.ValueKind != JsonValueKind.Object) - return staticParams; - - foreach (var prop in paramsEl.EnumerateObject()) - { - if (prop.Name.StartsWith("__", StringComparison.OrdinalIgnoreCase)) - { - error = $"forEach: parameter name '{prop.Name}' is reserved ('__'-prefix)"; - return staticParams; - } - staticParams[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; - } + var staticParams = SubWorkflowInvocation.CollectParameters(config, out var reservedKey); + error = reservedKey is null + ? null + : $"forEach: parameter name '{reservedKey}' is reserved ('__'-prefix)"; return staticParams; } @@ -485,12 +454,7 @@ private static List ParseItems(string raw, string format) var list = new List(); foreach (var el in doc.RootElement.EnumerateArray()) { - list.Add(el.ValueKind switch - { - JsonValueKind.String => el.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => el.GetRawText(), - }); + list.Add(PowerShellOperation.JsonElementToScalarString(el)); } return list; } diff --git a/src/NodePilot.Engine/Activities/JsonQueryActivity.cs b/src/NodePilot.Engine/Activities/JsonQueryActivity.cs index a58113fa..bdf7a388 100644 --- a/src/NodePilot.Engine/Activities/JsonQueryActivity.cs +++ b/src/NodePilot.Engine/Activities/JsonQueryActivity.cs @@ -60,7 +60,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var loaded = await LoadJsonAsync(source, config, ct); if (loaded.Error is not null) return loaded.Error; - var json = loaded.Json!; + var json = loaded.Content!; if (json.Length > MaxJsonBytes) return Fail($"input is {json.Length} chars; exceeds limit of {MaxJsonBytes}."); @@ -83,41 +83,15 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme }; }, ex => $"JsonQuery error: {ex.Message}"); - private async Task<(string? Json, ActivityResult? Error)> LoadJsonAsync(string source, JsonElement config, CancellationToken ct) - { - if (source == "file") - { - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - return (null, Fail("'path' is required when source=file")); - - // M-8: apply the same PathGuard config the FileSystemOperation activity uses, so ops - // can restrict file-mode JsonQuery to allow-listed roots / block traversal. - if (_config is not null) - { - try { PathGuard.Validate(_config, path); } - catch (InvalidOperationException ex) - { - return (null, Fail($"file access denied: {ex.Message}")); - } - } - - if (!File.Exists(path)) - return (null, Fail($"file not found: {path}")); - - // M-7: check size before reading so a 10 GiB file doesn't pin the managed heap. - var fi = new FileInfo(path); - if (fi.Length > MaxJsonBytes) - return (null, Fail($"file '{path}' is {fi.Length} bytes; exceeds limit of {MaxJsonBytes}.")); - - return (await File.ReadAllTextAsync(path, ct), null); - } - - var inline = config.GetString("content", ""); - if (string.IsNullOrWhiteSpace(inline)) - return (null, Fail("'content' is required when source=inline")); - return (inline, null); - } + private Task<(string? Content, ActivityResult? Error)> LoadJsonAsync(string source, JsonElement config, CancellationToken ct) + => QueryPayloadSource.LoadAsync( + source, + config, + _config, + MaxJsonBytes, + Fail, + (path, length) => $"file '{path}' is {length} bytes; exceeds limit of {MaxJsonBytes}.", + ct); private static (JToken? Root, ActivityResult? Error) ParseJson(string json) { diff --git a/src/NodePilot.Engine/Activities/QueryPayloadSource.cs b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs new file mode 100644 index 00000000..6f14df32 --- /dev/null +++ b/src/NodePilot.Engine/Activities/QueryPayloadSource.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Interfaces; +using NodePilot.Engine.Security; + +namespace NodePilot.Engine.Activities; + +/// +/// Resolves the source = file | inline payload that and +/// both accept. The two differ only in their size cap and in the +/// wording of the oversize message, so both are supplied by the caller — as is the Fail +/// projection that carries the activity prefix. +/// +internal static class QueryPayloadSource +{ + public static async Task<(string? Content, ActivityResult? Error)> LoadAsync( + string source, + JsonElement config, + IConfiguration? pathGuardConfig, + long maxBytes, + Func fail, + Func oversizeMessage, + CancellationToken ct) + { + if (source == "file") + { + var path = config.GetStringOrNull("path"); + if (string.IsNullOrWhiteSpace(path)) + return (null, fail("'path' is required when source=file")); + + // M-8: apply the same PathGuard config the FileSystemOperation activity uses, so ops + // can restrict file-mode queries to allow-listed roots / block traversal. + if (pathGuardConfig is not null) + { + try { PathGuard.Validate(pathGuardConfig, path); } + catch (InvalidOperationException ex) + { + return (null, fail($"file access denied: {ex.Message}")); + } + } + + if (!File.Exists(path)) + return (null, fail($"file not found: {path}")); + + // M-7: check size before reading so a 10 GiB file doesn't pin the managed heap. + var fileInfo = new FileInfo(path); + if (fileInfo.Length > maxBytes) + return (null, fail(oversizeMessage(path, fileInfo.Length))); + + return (await File.ReadAllTextAsync(path, ct), null); + } + + var inline = config.GetString("content", ""); + if (string.IsNullOrWhiteSpace(inline)) + return (null, fail("'content' is required when source=inline")); + return (inline, null); + } +} diff --git a/src/NodePilot.Engine/Activities/RegistryActivity.cs b/src/NodePilot.Engine/Activities/RegistryActivity.cs index bed6c04c..fedcf49b 100644 --- a/src/NodePilot.Engine/Activities/RegistryActivity.cs +++ b/src/NodePilot.Engine/Activities/RegistryActivity.cs @@ -236,35 +236,14 @@ private static string BuildListSubKeys() => """ $__result.count = $names.Count """; - private static string BuildListValues() => """ - $key = Get-Item -LiteralPath $__keyPath - $items = New-Object System.Collections.ArrayList - foreach ($n in $key.Property) { - [void]$items.Add([ordered]@{ - name = $n - type = $key.GetValueKind($n).ToString() - value = $key.GetValue($n) - }) - } - $__result.values = @($items) - $__result.count = $items.Count - """; + // listValues is a valueName-less read under another name: same inventory, same JSON shape + // (values/count), so it emits the exact same snippet instead of a second copy of it. + private static string BuildListValues() => BuildReadAllValues(); protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) { - var output = raw.Output ?? ""; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Registry: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Registry", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs index e4571a08..031f6626 100644 --- a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs +++ b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using NodePilot.Core.Interfaces; using NodePilot.Data; +using NodePilot.Engine.PowerShell; using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -77,12 +78,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso var outputParams = new Dictionary(); foreach (var prop in dataEl.EnumerateObject()) { - var raw = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; + var raw = PowerShellOperation.JsonElementToScalarString(prop.Value); outputParams[prop.Name] = raw.Length > MaxPerValueChars ? raw[..MaxPerValueChars] + PerValueTruncationMarker : raw; diff --git a/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs b/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs index bf95e4fe..d2bcfed4 100644 --- a/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs +++ b/src/NodePilot.Engine/Activities/ScheduledTaskActivity.cs @@ -344,42 +344,18 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co if (action != "get" || !raw.Success || string.IsNullOrWhiteSpace(raw.Output)) return raw; - var op = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (PowerShellOperation.TryParseJson(raw.Output!, out var parsedDoc, out _)) - { - using var doc = parsedDoc!; - var root = doc.RootElement; - // ConvertTo-Json wraps single-result objects in {} but multi-result in []. Get-ScheduledTask - // for a single name is single-result, but we still defensively unwrap a 1-array. - if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) - root = root[0]; - - if (root.ValueKind == JsonValueKind.Object) - { - PowerShellOperation.CopyStringField(root, "TaskName", op, "taskName"); - PowerShellOperation.CopyStringField(root, "State", op, "state"); - PowerShellOperation.CopyStringField(root, "LastRunTime", op, "lastRunTime"); - PowerShellOperation.CopyStringField(root, "LastTaskResult", op, "lastTaskResult"); - PowerShellOperation.CopyStringField(root, "NextRunTime", op, "nextRunTime"); - } - } - else - { - // Output wasn't JSON (e.g. because the task was missing and PowerShell printed an - // error instead) — we leave Success/Output unchanged and return empty - // OutputParameters; the caller sees ErrorOutput anyway. - } - - if (op.Count == 0) return raw; - - return new ActivityResult - { - Success = raw.Success, - Output = raw.Output, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = op, - }; + // Non-JSON output (e.g. because the task was missing and PowerShell printed an error + // instead) projects to nothing — Success/Output stay unchanged and the caller sees + // ErrorOutput anyway. + var op = PowerShellOperation.MapStatusJsonFields( + raw.Output, + ("TaskName", "taskName"), + ("State", "state"), + ("LastRunTime", "lastRunTime"), + ("LastTaskResult", "lastTaskResult"), + ("NextRunTime", "nextRunTime")); + + return WithOutputParameters(raw, op); } } diff --git a/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs b/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs index 3cfaf0af..49793a83 100644 --- a/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs +++ b/src/NodePilot.Engine/Activities/ServiceManagementActivity.cs @@ -165,39 +165,15 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co if (action != "status" || !raw.Success || string.IsNullOrWhiteSpace(raw.Output)) return raw; - var op = new Dictionary(StringComparer.OrdinalIgnoreCase); - try - { - using var doc = JsonDocument.Parse(raw.Output!); - var root = doc.RootElement; - // ConvertTo-Json wraps single-result objects in {} but multi-result in []. Get-Service - // for a single service is single-result, but we defensively unwrap a 1-element array too. - if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) - root = root[0]; - - if (root.ValueKind == JsonValueKind.Object) - { - CopyStringField(root, "Name", op, "name"); - CopyStringField(root, "Status", op, "status"); - CopyStringField(root, "StartType", op, "startType"); - } - } - catch (JsonException) - { - // Output wasn't JSON (e.g. because the service was missing and PowerShell printed an - // error instead) — we leave Success/Output unchanged and return empty - // OutputParameters; the caller sees ErrorOutput anyway. - } - - if (op.Count == 0) return raw; - - return new ActivityResult - { - Success = raw.Success, - Output = raw.Output, - ErrorOutput = raw.ErrorOutput, - Duration = raw.Duration, - OutputParameters = op, - }; + // Non-JSON output (e.g. because the service was missing and PowerShell printed an error + // instead) projects to nothing — Success/Output stay unchanged and the caller sees + // ErrorOutput anyway. + var op = PowerShellOperation.MapStatusJsonFields( + raw.Output, + ("Name", "name"), + ("Status", "status"), + ("StartType", "startType")); + + return WithOutputParameters(raw, op); } } diff --git a/src/NodePilot.Engine/Activities/StartProgramActivity.cs b/src/NodePilot.Engine/Activities/StartProgramActivity.cs index 992a0e8d..25ef9fd8 100644 --- a/src/NodePilot.Engine/Activities/StartProgramActivity.cs +++ b/src/NodePilot.Engine/Activities/StartProgramActivity.cs @@ -312,17 +312,11 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co }; } + // Same comma-separated allow-list runScript parses, except that "unset" means {0} here + // (a program's exit code is always gated) instead of "no gate at all". private static HashSet ParseSuccessExitCodes(JsonElement config) - { - var raw = config.GetStringOrNull("successExitCodes"); - if (string.IsNullOrWhiteSpace(raw)) return new HashSet { 0 }; - var set = new HashSet(); - foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (int.TryParse(part, out var n)) set.Add(n); - } - return set.Count == 0 ? new HashSet { 0 } : set; - } + => PowerShellActivitySupport.ParseSuccessExitCodes(config.GetStringOrNull("successExitCodes")) + ?? new HashSet { 0 }; private void ValidateLocalAbsolutePath(string fieldName, string path) { diff --git a/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs b/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs index 780184f6..26c1fc93 100644 --- a/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs +++ b/src/NodePilot.Engine/Activities/StartWorkflowActivity.cs @@ -8,6 +8,7 @@ using NodePilot.Core.Models; using NodePilot.Data; using NodePilot.Engine.Execution; +using NodePilot.Engine.PowerShell; namespace NodePilot.Engine.Activities; @@ -111,27 +112,17 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso var waitForCompletion = config.GetBool("waitForCompletion", true); // Locate child workflow — GUID first; by name exact-case wins, then case-insensitive. - Workflow? childWorkflow; - if (Guid.TryParse(workflowNameOrId, out var id)) + var (outcome, resolvedWorkflow) = await SubWorkflowInvocation.ResolveChildWorkflowAsync(_db, workflowNameOrId, ct); + if (outcome == SubWorkflowInvocation.ChildOutcome.Ambiguous) { - childWorkflow = await _db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); - } - else - { - var resolved = await WorkflowNameResolver.ResolveByNameAsync(_db.Workflows, workflowNameOrId, ct); - if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) + return new ActivityResult { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: multiple workflows named '{workflowNameOrId}' — disambiguate with the GUID", - Duration = sw.Elapsed, - }; - } - childWorkflow = resolved.Workflow; + Success = false, + ErrorOutput = $"startWorkflow: multiple workflows named '{workflowNameOrId}' — disambiguate with the GUID", + Duration = sw.Elapsed, + }; } - - if (childWorkflow is null) + if (outcome == SubWorkflowInvocation.ChildOutcome.NotFound) { return new ActivityResult { @@ -140,7 +131,8 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso Duration = sw.Elapsed, }; } - if (!childWorkflow.IsEnabled) + var childWorkflow = resolvedWorkflow!; + if (outcome == SubWorkflowInvocation.ChildOutcome.Disabled) { return new ActivityResult { @@ -152,10 +144,8 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // Self-call guard - requires the parent workflow id. It is not on the context directly, // so we derive it from the current execution row. - var parentExec = await _db.WorkflowExecutions - .AsNoTracking() - .FirstOrDefaultAsync(e => e.Id == context.WorkflowExecutionId, ct); - if (parentExec is not null && parentExec.WorkflowId == childWorkflow.Id) + var parentExec = await SubWorkflowInvocation.LoadParentExecutionAsync(_db, context.WorkflowExecutionId, ct); + if (SubWorkflowInvocation.IsSelfInvocation(parentExec, childWorkflow)) { return new ActivityResult { @@ -173,28 +163,21 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // - manual run: parentExec.StartedByUserId // - trigger-driven run: parent workflow's LastModifiedByUserId (best proxy V1) // If neither resolves, the run lacks a principal — refuse the cross-folder call. - if (_subWorkflowAuthz is not null && parentExec is not null) + var blocked = await SubWorkflowInvocation.GetAuthorizationBlockAsync( + _subWorkflowAuthz, parentExec, childWorkflow, ct); + if (blocked is not null) { - var blocked = await _subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); - if (blocked is not null) + return new ActivityResult { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: {blocked}", - Duration = sw.Elapsed, - }; - } + Success = false, + ErrorOutput = $"startWorkflow: {blocked}", + Duration = sw.Elapsed, + }; } // Call-depth guard - read from the reserved variable the engine places into context.Variables // ("manual.__callDepth" when passed via inputParameters). - var currentDepth = 0; - if (context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) - && int.TryParse(depthStr, out var parsed)) - { - currentDepth = parsed; - } + var currentDepth = SubWorkflowInvocation.CurrentCallDepth(context); if (currentDepth >= WorkflowRecursion.MaxCallDepth) { _subWorkflowDepthExceeded.Add(1); @@ -226,31 +209,18 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso // who supplies "__callDepth", "__CALLDEPTH", "__CallDepth", etc. as a user parameter // and resets the counter. Any key starting with "__" is reserved for engine bookkeeping // and rejected on ingest, case-insensitively. - var childParams = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (config.TryGetProperty("parameters", out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) + // Reserved-prefix guard (H5): case-insensitive. Previously the reserved key was + // seeded first and then overwritten by user params because the user-loop came + // second. Reject ALL "__"-prefixed keys from user input - they're ours. + var childParams = SubWorkflowInvocation.CollectParameters(config, out var reservedKey); + if (reservedKey is not null) { - foreach (var prop in paramsEl.EnumerateObject()) + return new ActivityResult { - // Reserved-prefix guard (H5): case-insensitive. Previously the reserved key was - // seeded first and then overwritten by user params because the user-loop came - // second. Reject ALL "__"-prefixed keys from user input - they're ours. - if (prop.Name.StartsWith("__", StringComparison.OrdinalIgnoreCase)) - { - return new ActivityResult - { - Success = false, - ErrorOutput = $"startWorkflow: parameter name '{prop.Name}' is reserved (keys starting with '__' are used by the engine). Rename the parameter.", - Duration = sw.Elapsed, - }; - } - - childParams[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; - } + Success = false, + ErrorOutput = $"startWorkflow: parameter name '{reservedKey}' is reserved (keys starting with '__' are used by the engine). Rename the parameter.", + Duration = sw.Elapsed, + }; } // Seed the reserved depth counter AFTER the user loop so even if the reject above were // bypassed, the engine's value always wins. Belt-and-suspenders on H5. @@ -387,12 +357,7 @@ async Task ExecuteSynchronousChildAsync() { foreach (var prop in doc.RootElement.EnumerateObject()) { - returned[prop.Name] = prop.Value.ValueKind switch - { - JsonValueKind.String => prop.Value.GetString() ?? string.Empty, - JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, - _ => prop.Value.GetRawText(), - }; + returned[prop.Name] = PowerShellOperation.JsonElementToScalarString(prop.Value); } } } diff --git a/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs b/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs new file mode 100644 index 00000000..73ea7287 --- /dev/null +++ b/src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.PowerShell; + +namespace NodePilot.Engine.Activities; + +/// +/// The guards every child-workflow-spawning activity runs before it hands work to the engine — +/// shared by and : child +/// resolution, self-invocation, runtime RBAC, call depth and the user-parameter ingest. +/// +/// Only the mechanics live here. Each activity formats its OWN error strings (their wording is +/// part of the step's observable output) and keeps its own instrumentation — startWorkflow, for +/// instance, counts and tags a depth violation before returning. +/// +internal static class SubWorkflowInvocation +{ + internal enum ChildOutcome + { + Found, + Ambiguous, + NotFound, + Disabled, + } + + /// + /// Locates the child workflow: GUID first, then by name (exact-case wins, then + /// case-insensitive). Ambiguous names, a missing workflow and a disabled workflow are + /// reported as outcomes — the caller turns them into its own error message. + /// + public static async Task<(ChildOutcome Outcome, Workflow? Workflow)> ResolveChildWorkflowAsync( + NodePilotDbContext db, + string nameOrId, + CancellationToken ct) + { + Workflow? workflow; + if (Guid.TryParse(nameOrId, out var id)) + { + workflow = await db.Workflows.FirstOrDefaultAsync(wf => wf.Id == id, ct); + } + else + { + var resolved = await WorkflowNameResolver.ResolveByNameAsync(db.Workflows, nameOrId, ct); + if (resolved.Outcome == WorkflowNameResolver.Outcome.Ambiguous) + return (ChildOutcome.Ambiguous, null); + workflow = resolved.Workflow; + } + + if (workflow is null) return (ChildOutcome.NotFound, null); + if (!workflow.IsEnabled) return (ChildOutcome.Disabled, workflow); + return (ChildOutcome.Found, workflow); + } + + /// + /// The parent execution row behind the current step. Needed for the self-invocation guard and + /// the RBAC re-check; the parent workflow id is not on the step context directly. + /// + public static Task LoadParentExecutionAsync( + NodePilotDbContext db, + Guid workflowExecutionId, + CancellationToken ct) + => db.WorkflowExecutions + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == workflowExecutionId, ct); + + /// True when the step would start the workflow it is running in (direct recursion). + public static bool IsSelfInvocation(WorkflowExecution? parentExec, Workflow childWorkflow) + => parentExec is not null && parentExec.WorkflowId == childWorkflow.Id; + + /// + /// Runtime RBAC re-check (Defense-in-Depth — folder permissions can be revoked between Publish + /// and Run). Returns the block reason WITHOUT an activity prefix, or null when allowed. + /// + public static async Task GetAuthorizationBlockAsync( + ISubWorkflowAuthorizationResolver? subWorkflowAuthz, + WorkflowExecution? parentExec, + Workflow childWorkflow, + CancellationToken ct) + { + if (subWorkflowAuthz is null || parentExec is null) return null; + return await subWorkflowAuthz.IsBlockedAsync(parentExec, childWorkflow, ct); + } + + /// + /// Current call depth, read from the reserved variable the engine places into + /// context.Variables ("manual.__callDepth" when passed via inputParameters). Missing or + /// unparsable means depth 0. + /// + public static int CurrentCallDepth(StepExecutionContext context) + => context.Variables.TryGetValue($"manual.{WorkflowRecursion.CallDepthKey}", out var depthStr) + && int.TryParse(depthStr, out var parsed) + ? parsed + : 0; + + /// + /// Reads the optional parameters object into a case-insensitive dictionary — the same + /// comparer the template resolver and PowerShell use, so Foo and foo collide. + /// Stops at the first "__"-prefixed key and reports it via : + /// that namespace belongs to engine bookkeeping (see __callDepth) and letting a user + /// steer it would bypass the recursion guard. + /// + public static Dictionary CollectParameters(JsonElement config, out string? reservedKey) + { + reservedKey = null; + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!config.TryGetProperty("parameters", out var paramsEl) || paramsEl.ValueKind != JsonValueKind.Object) + return parameters; + + foreach (var prop in paramsEl.EnumerateObject()) + { + if (WorkflowRecursion.IsReservedParameterName(prop.Name)) + { + reservedKey = prop.Name; + return parameters; + } + parameters[prop.Name] = PowerShellOperation.JsonElementToScalarString(prop.Value); + } + return parameters; + } +} diff --git a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs index d8b7fa12..1d64f804 100644 --- a/src/NodePilot.Engine/Activities/TextFileEditActivity.cs +++ b/src/NodePilot.Engine/Activities/TextFileEditActivity.cs @@ -274,18 +274,8 @@ private static string SyntheticLeafName(string path, string backupSuffix) protected override ActivityResult PostProcess(ActivityResult raw, JsonElement config) { - var output = raw.Output ?? string.Empty; - if (!PowerShellOperation.TryParseJsonBlock(output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Text File Edit: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Text File Edit", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Activities/XmlQueryActivity.cs b/src/NodePilot.Engine/Activities/XmlQueryActivity.cs index a881ebf6..51d4c2ca 100644 --- a/src/NodePilot.Engine/Activities/XmlQueryActivity.cs +++ b/src/NodePilot.Engine/Activities/XmlQueryActivity.cs @@ -53,7 +53,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var loaded = await LoadXmlAsync(source, config, ct); if (loaded.Error is not null) return loaded.Error; - var xml = loaded.Xml!; + var xml = loaded.Content!; // Hard caps to stop a malicious inline XML from DoS-ing the engine: reject // payloads larger than 8 MiB outright, and load via XmlReader with DTD @@ -80,38 +80,15 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme }; }, ex => $"XmlQuery error: {ex.Message}"); - private async Task<(string? Xml, ActivityResult? Error)> LoadXmlAsync(string source, JsonElement config, CancellationToken ct) - { - if (source == "file") - { - var path = config.GetStringOrNull("path"); - if (string.IsNullOrWhiteSpace(path)) - return (null, Fail("'path' is required when source=file")); - - // M-8: apply the same PathGuard config FileOperationActivity uses — ops can - // restrict file-mode XmlQuery to allow-listed roots / reject `..` traversal. - if (_config is not null) - { - try { PathGuard.Validate(_config, path); } - catch (InvalidOperationException ex) - { - return (null, Fail($"file access denied: {ex.Message}")); - } - } - - if (!File.Exists(path)) - return (null, Fail($"file not found: {path}")); - var fileInfo = new FileInfo(path); - if (fileInfo.Length > MaxXmlBytes) - return (null, Fail($"file exceeds {MaxXmlBytes} bytes")); - return (await File.ReadAllTextAsync(path, ct), null); - } - - var inline = config.GetString("content", ""); - if (string.IsNullOrWhiteSpace(inline)) - return (null, Fail("'content' is required when source=inline")); - return (inline, null); - } + private Task<(string? Content, ActivityResult? Error)> LoadXmlAsync(string source, JsonElement config, CancellationToken ct) + => QueryPayloadSource.LoadAsync( + source, + config, + _config, + MaxXmlBytes, + Fail, + (_, _) => $"file exceeds {MaxXmlBytes} bytes", + ct); private static XmlDocument LoadXmlDocument(string xml) { diff --git a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs index 6ef56da3..cac733be 100644 --- a/src/NodePilot.Engine/Activities/ZipOperationActivity.cs +++ b/src/NodePilot.Engine/Activities/ZipOperationActivity.cs @@ -131,17 +131,8 @@ protected override ActivityResult PostProcess(ActivityResult raw, JsonElement co { if (!raw.Success) return raw; - if (!PowerShellOperation.TryParseJsonBlock(raw.Output, ResultMarkers, out var doc, out var parseError)) - { - if (parseError is null) return raw; - return new ActivityResult - { - Success = false, - Output = raw.Output, - ErrorOutput = $"Zip Operation: could not parse result JSON: {parseError}", - Duration = raw.Duration, - }; - } + if (!TryParseResultEnvelope(raw, ResultMarkers, "Zip Operation", out var doc, out var passthrough)) + return passthrough!; using (doc!) { diff --git a/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs b/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs index db138a83..c8dc5cc4 100644 --- a/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs +++ b/src/NodePilot.Engine/Conditions/ConditionEvaluator.cs @@ -111,12 +111,10 @@ private static bool EvaluateComparison(JsonElement cmp, ConditionContext ctx) }; } - // Step-output template: matches the regex used by VariableResolver.StepPattern (kept - // independent so this evaluator can be reused without dragging the Engine.Execution - // namespace in). globals/manual run as a separate pre-pass because they don't carry a - // step-shaped tail. - private static readonly Regex TemplateRegex = new(@"\{\{([\w-]+)\.(output|error|success|param\.([\w-]+))\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - private static readonly Regex GlobalsTemplateRegex = new(@"\{\{globals\.([A-Za-z0-9_\-]+)\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + // Step-output and globals templates reuse VariableResolver's compiled patterns — this + // evaluator already depends on Engine.Execution (see EvaluateEdge below), so keeping a + // second copy of the same two patterns only risked them drifting apart. `manual.` has no + // counterpart there and runs as its own pre-pass; like globals it carries no step-shaped tail. private static readonly Regex ManualTemplateRegex = new(@"\{\{manual\.([A-Za-z0-9_\-]+)\}\}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); private static string ResolveOperand(JsonElement operand, ConditionContext ctx) @@ -262,7 +260,7 @@ private static string ResolveTemplates(string raw, ConditionContext ctx) // mis-classified as an unresolved step (and silently return ""). if (ctx.GlobalVariables is not null && ctx.GlobalVariables.Count > 0) { - raw = GlobalsTemplateRegex.Replace(raw, m => + raw = VariableResolver.GlobalsPattern.Replace(raw, m => ctx.GlobalVariables.TryGetValue(m.Groups[1].Value, out var gv) ? gv : m.Value); } @@ -274,7 +272,11 @@ private static string ResolveTemplates(string raw, ConditionContext ctx) ctx.InputParameters.TryGetValue(m.Groups[1].Value, out var mv) ? mv : m.Value); } - return TemplateRegex.Replace(raw, m => + // The substitution body stays local: it resolves against ctx.Results with THAT dict's + // own comparer (ordinal for the engine's result map), whereas VariableResolver merges + // results + aliases into an OrdinalIgnoreCase map. Routing through it would newly + // resolve differently-cased step ids in conditions. + return VariableResolver.StepPattern.Replace(raw, m => { var name = m.Groups[1].Value; if (!ctx.Results.ContainsKey(name) && ctx.OutputVariableToStepId is not null diff --git a/src/NodePilot.Engine/Debug/DebugCoordinator.cs b/src/NodePilot.Engine/Debug/DebugCoordinator.cs index f8b943f6..4bdb5177 100644 --- a/src/NodePilot.Engine/Debug/DebugCoordinator.cs +++ b/src/NodePilot.Engine/Debug/DebugCoordinator.cs @@ -53,9 +53,7 @@ internal async Task HandlePauseAsync( var redactedVariables = new Dictionary(variables.Count, StringComparer.OrdinalIgnoreCase); foreach (var (k, v) in variables) redactedVariables[k] = _redactor.RedactNamedValue(k, v) ?? v; - var snapshotJson = JsonSerializer.Serialize(redactedVariables); - if (snapshotJson.Length > MaxSnapshotChars) - snapshotJson = snapshotJson.Substring(0, MaxSnapshotChars) + "... [truncated]"; + var snapshotJson = OutputRedactor.Cap(JsonSerializer.Serialize(redactedVariables), MaxSnapshotChars); stepExecution.Status = ExecutionStatus.Paused; stepExecution.PausedAt = DateTime.UtcNow; diff --git a/src/NodePilot.Engine/Execution/StepRunner.cs b/src/NodePilot.Engine/Execution/StepRunner.cs index a21169f6..a7b6aac4 100644 --- a/src/NodePilot.Engine/Execution/StepRunner.cs +++ b/src/NodePilot.Engine/Execution/StepRunner.cs @@ -618,10 +618,7 @@ private void LogStepDetail(WorkflowExecution execution, WorkflowNode node, Activ } private static string Truncate(string? s, int max) - { - if (string.IsNullOrEmpty(s)) return ""; - return s.Length > max ? s.Substring(0, max) + "... [truncated]" : s; - } + => string.IsNullOrEmpty(s) ? "" : OutputRedactor.Cap(s, max); /// /// Emits a compact line to the support log (a second Serilog sink) when a step diff --git a/src/NodePilot.Engine/Execution/VariableResolver.cs b/src/NodePilot.Engine/Execution/VariableResolver.cs index 7e63b572..a4a79913 100644 --- a/src/NodePilot.Engine/Execution/VariableResolver.cs +++ b/src/NodePilot.Engine/Execution/VariableResolver.cs @@ -50,18 +50,9 @@ internal static class VariableResolver /// 3. previous-step OutputParameters: fully-qualified {stepVar}.param.{key} /// always wins; the short-name alias is only added if not already present and /// not in . - /// - internal static Dictionary BuildStepVariables( - Dictionary? inputParameters, - IReadOnlyDictionary globalVariables, - IReadOnlyDictionary previousResults, - List allNodes) - => BuildStepVariables(inputParameters, globalVariables, previousResults, BuildOutputNameByStepId(allNodes)); - - /// - /// Hot-path overload: callers who already built an output-name index once per execution - /// (see ) pass it through directly to skip per-call - /// node scans. + /// + /// Callers pass the output-name index they already built once per execution + /// (see ) so nothing rescans the node list per call. /// internal static Dictionary BuildStepVariables( Dictionary? inputParameters, @@ -316,17 +307,6 @@ private static IReadOnlyDictionary BuildVariableMap( return map; } - /// - /// Materialises an id → node lookup for one call-site's batch of resolves. Called from - /// the legacy List<WorkflowNode> overloads; the hot path in - /// constructs the dict once per execution and reuses it. - /// - internal static Dictionary BuildNodesById(List allNodes) - => WorkflowDefinitionDocument.BuildNodesById(allNodes); - - internal static Dictionary BuildOutputNameByStepId(IReadOnlyList allNodes) - => WorkflowDefinitionDocument.BuildOutputNameByStepId(allNodes); - internal static Dictionary BuildOutputVariableAliasMap(IReadOnlyList allNodes) => WorkflowDefinitionDocument.BuildOutputVariableAliasMap(allNodes); @@ -358,22 +338,20 @@ internal static Dictionary BuildOutputVariableAliasMap(IReadOnly return StepPattern.Replace(raw!, match => { - var varNameStr = match.Groups[1].Value; - var propertyStr = match.Groups[2].Value; + var varName = match.Groups[1].Value; + var property = match.Groups[2].Value; + + if (!variableMap.TryGetValue(varName, out var result)) + return match.Value; - if (variableMap.TryGetValue(varNameStr, out var res) && propertyStr.StartsWith("param.") && match.Groups[3].Success) + if (property.StartsWith("param.") && match.Groups[3].Success) { // No Trim — keeps the value byte-identical to ResolveVariables' JSON-config // pass. Mismatched trim semantics caused subtle bugs where the same template // resolved one way in restApi.url (string-path) and another way in restApi.body // (JSON-path). - return res.OutputParameters.TryGetValue(match.Groups[3].Value, out var pv) ? pv : match.Value; + return result.OutputParameters.TryGetValue(match.Groups[3].Value, out var pv) ? pv : match.Value; } - var varName = match.Groups[1].Value; - var property = match.Groups[2].Value; - - if (!variableMap.TryGetValue(varName, out var result)) - return match.Value; return property.ToLowerInvariant() switch { diff --git a/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs b/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs index 735249a1..e25845fc 100644 --- a/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs +++ b/src/NodePilot.Engine/PowerShell/PowerShellOperation.cs @@ -128,6 +128,29 @@ public static Dictionary MapObjectFields( return result; } + /// + /// Projects a bare ConvertTo-Json status object (no marker envelope) into the + /// requested fields. ConvertTo-Json wraps a single result in {} but multiple results + /// in [], so a 1-element array is defensively unwrapped. Non-JSON output — e.g. the + /// cmdlet printed an error instead — yields an empty dictionary rather than throwing. + /// + public static Dictionary MapStatusJsonFields( + string? json, + params (string SourceKey, string DestKey)[] fields) + { + if (json is null || !TryParseJson(json, out var parsedDoc, out _)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var doc = parsedDoc!; + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() == 1) + root = root[0]; + + return root.ValueKind == JsonValueKind.Object + ? MapObjectFields(root, fields) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + public static string JsonElementToScalarString(JsonElement value) => value.ValueKind switch { JsonValueKind.String => value.GetString() ?? string.Empty, diff --git a/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs b/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs index 5a7ce00b..f2c453d3 100644 --- a/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs +++ b/src/NodePilot.Engine/PowerShell/ProcessExecutionEngine.cs @@ -159,13 +159,7 @@ public async Task ExecuteAsync(PowerShellExecutionReq catch (Exception ex) { sw.Stop(); - return new PowerShellExecutionResult - { - Success = false, - ExitCode = -1, - Error = $"Failed to start {_executable}: {ex.Message}", - Duration = sw.Elapsed, - }; + return EngineFailure($"Failed to start {_executable}: {ex.Message}", sw.Elapsed); } finally { @@ -300,13 +294,7 @@ private async Task ExecuteIsolatedWindowsAsync(PowerS catch (Exception ex) { sw.Stop(); - return new PowerShellExecutionResult - { - Success = false, - ExitCode = -1, - Error = $"Isolated execution failed: {ex.Message}", - Duration = sw.Elapsed, - }; + return EngineFailure($"Isolated execution failed: {ex.Message}", sw.Elapsed); } finally { @@ -339,6 +327,19 @@ private async Task ExecuteIsolatedWindowsAsync(PowerS } } + /// + /// The engine never got far enough to have a script result: no exit code, no output, just + /// the reason. Callers stop the stopwatch first so the reported duration excludes the + /// message formatting. + /// + private static PowerShellExecutionResult EngineFailure(string error, TimeSpan duration) => new() + { + Success = false, + ExitCode = -1, + Error = error, + Duration = duration, + }; + /// /// Returns a completed read's text, or empty for a read still blocked by a leaked inherited pipe /// handle. A still-pending read is observed via a continuation so its eventual fault diff --git a/src/NodePilot.Engine/Scorch/ScorchImporter.cs b/src/NodePilot.Engine/Scorch/ScorchImporter.cs index 55a5631c..481d8803 100644 --- a/src/NodePilot.Engine/Scorch/ScorchImporter.cs +++ b/src/NodePilot.Engine/Scorch/ScorchImporter.cs @@ -70,23 +70,7 @@ public sealed class ScorchImporter }; public ScorchImportResult Parse(string xml) - { - var result = new ScorchImportResult(); - - XDocument doc; - try - { - using var reader = XmlReader.Create(new StringReader(xml), HardenedReaderSettings); - doc = XDocument.Load(reader); - } - catch (Exception ex) - { - result.Errors.Add($"Failed to parse XML: {ex.Message}"); - return result; - } - - return ParseFromDocument(doc, result); - } + => ParseFromReader(() => XmlReader.Create(new StringReader(xml), HardenedReaderSettings)); /// /// M-14: Stream-based overload. Preferred over for large @@ -95,13 +79,18 @@ public ScorchImportResult Parse(string xml) /// tree). Streaming straight into the XmlReader lets the XML parser hold only one copy. /// public ScorchImportResult Parse(Stream xmlStream) + => ParseFromReader(() => XmlReader.Create(xmlStream, HardenedReaderSettings)); + + // The factory is invoked INSIDE the try: XmlReader.Create itself can throw on a bad + // source, and that has always been reported as a parse error rather than propagated. + private ScorchImportResult ParseFromReader(Func createReader) { var result = new ScorchImportResult(); XDocument doc; try { - using var reader = XmlReader.Create(xmlStream, HardenedReaderSettings); + using var reader = createReader(); doc = XDocument.Load(reader); } catch (Exception ex) diff --git a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs index 53926068..090f1b4c 100644 --- a/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs +++ b/src/NodePilot.Engine/Security/FileWatcherPathGuard.cs @@ -42,19 +42,14 @@ public static void Validate(IConfiguration config, string dir) { foreach (var blocked in HardBlockedWindowsRoots) { - if (normalized.Equals(blocked, StringComparison.OrdinalIgnoreCase) - || normalized.StartsWith(blocked + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + if (PathGuard.IsWithinRoot(normalized, blocked)) throw new InvalidOperationException( $"FileWatcherTrigger: directory '{dir}' is under a system path ('{blocked}'). " + "Set Trigger:FileWatcher:AllowSystemPaths=true and add it to AllowedRoots to override."); } } - var roots = config.GetSection("Trigger:FileWatcher:AllowedRoots").GetChildren() - .Select(c => c.Value) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Cast() - .ToArray(); + var roots = PathGuard.ReadConfiguredRoots(config, "Trigger:FileWatcher:AllowedRoots"); if (roots.Length == 0) return; var allowed = roots.Any(root => @@ -62,8 +57,7 @@ public static void Validate(IConfiguration config, string dir) string rFull; try { rFull = Path.GetFullPath(root); } catch { return false; } var r = rFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return normalized.Equals(r, StringComparison.OrdinalIgnoreCase) - || normalized.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + return PathGuard.IsWithinRoot(normalized, r); }); if (!allowed) throw new InvalidOperationException( diff --git a/src/NodePilot.Engine/Security/OutputRedactor.cs b/src/NodePilot.Engine/Security/OutputRedactor.cs index fd945049..445de146 100644 --- a/src/NodePilot.Engine/Security/OutputRedactor.cs +++ b/src/NodePilot.Engine/Security/OutputRedactor.cs @@ -165,6 +165,25 @@ public OutputRedactor(IConfiguration? configuration = null, ILogger + /// Length cap for untrusted text before it lands in a DB row or a log line, so a single + /// leaked blob can't blow the row or the audit log. The "... [truncated]" marker is + /// part of the persisted value — the inspector UI shows it as "this was truncated". + /// + public static string Cap(string value, int maxChars) + => value.Length > maxChars ? value.Substring(0, maxChars) + "... [truncated]" : value; + + /// + /// Apply the redactor to untrusted text and cap the length. Used for ErrorMessage, + /// InputParametersJson, ReturnData. + /// + public string? RedactAndCap(string? value, int maxChars) + { + if (string.IsNullOrEmpty(value)) return value; + var redacted = Redact(value) ?? value; + return Cap(redacted, maxChars); + } + /// /// Redacts a value with awareness of the field/variable name. Value-only regexes cannot /// recognize opaque secrets such as dbPassword = hunter2 once the key and value have diff --git a/src/NodePilot.Engine/Security/PathGuard.cs b/src/NodePilot.Engine/Security/PathGuard.cs index 90ee18ca..ac11ff83 100644 --- a/src/NodePilot.Engine/Security/PathGuard.cs +++ b/src/NodePilot.Engine/Security/PathGuard.cs @@ -71,12 +71,7 @@ public static void Validate(IConfiguration config, string path, bool allowWildca if (rejectTraversal && ContainsTraversal(path)) throw new InvalidOperationException($"File System Operation: path '{path}' contains '..' traversal (blocked by FileSystemOperation:RejectTraversal)"); - var roots = config.GetSection("FileSystemOperation:AllowedRoots") - .GetChildren() - .Select(c => c.Value) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Cast() - .ToArray(); + var roots = ReadConfiguredRoots(config, "FileSystemOperation:AllowedRoots"); if (roots.Length > 0) { string fullPath; @@ -93,18 +88,34 @@ public static void Validate(IConfiguration config, string path, bool allowWildca throw new InvalidOperationException($"File System Operation: path '{path}' final path could not be resolved: {ex.Message}"); } - var allowed = roots.Any(root => - { - var r = ResolveLocalFinalPath(Path.GetFullPath(root)); - return fullNormalized.Equals(r, StringComparison.OrdinalIgnoreCase) - || fullNormalized.StartsWith(r + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); - }); + var allowed = roots.Any(root => IsWithinRoot(fullNormalized, ResolveLocalFinalPath(Path.GetFullPath(root)))); if (!allowed) throw new InvalidOperationException($"File System Operation: path '{path}' is not within any configured FileSystemOperation:AllowedRoots"); } } + /// + /// Reads an allow-list section as a string array, dropping blank entries. Shared with + /// so both guards read their roots the same way. + /// + internal static string[] ReadConfiguredRoots(IConfiguration config, string sectionPath) + => config.GetSection(sectionPath) + .GetChildren() + .Select(c => c.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Cast() + .ToArray(); + + /// + /// Root-containment test shared by both path guards: the path is the root itself, or sits + /// below it. Both arguments must already be normalized (full path, no trailing separator) — + /// the separator suffix is what stops C:\Data2 from matching the root C:\Data. + /// + internal static bool IsWithinRoot(string normalizedPath, string normalizedRoot) + => normalizedPath.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) + || normalizedPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + public static void ValidateSiblingRenameTarget(IConfiguration config, string currentPath, string newName) { ValidateLeafName(newName); diff --git a/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs b/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs index d0cd9704..0a23806e 100644 --- a/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs +++ b/src/NodePilot.Engine/Security/RestApiHttpClientProvider.cs @@ -205,17 +205,9 @@ private static SocketsHttpHandler BuildHandler( ConnectCallback = (ctx, ct) => ConnectWithSsrfGuardAsync(ctx, configuration, ct), }; + // The string overload owns the http(s)-URL validation (same check, same message). if (useProxy && !string.IsNullOrWhiteSpace(address)) - { - if (!Uri.TryCreate(address, UriKind.Absolute, out var proxyUri) - || (proxyUri.Scheme != Uri.UriSchemeHttp && proxyUri.Scheme != Uri.UriSchemeHttps)) - { - throw new InvalidOperationException( - $"REST API: proxy address '{address}' is not a valid http(s) URL."); - } - - handler.Proxy = CreateProxy(proxyUri, bypassPatterns, username, password); - } + handler.Proxy = CreateProxy(address, bypassPatterns, username, password); return handler; } diff --git a/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs b/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs index d99b2c12..5504748d 100644 --- a/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs +++ b/src/NodePilot.Engine/Triggers/DatabaseTrigger.cs @@ -37,10 +37,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso { // If the orchestrator's polling source fired this trigger, it already ran the query and // just needs to surface the change-detection data to downstream steps. - var orchestratorParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - orchestratorParams[k["manual.".Length..]] = v; + var orchestratorParams = TriggerVariables.ExtractManualParams(context.Variables); if (orchestratorParams.TryGetValue("dbSentinel", out var dbSentinel)) { return new ActivityResult diff --git a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs index 1da39489..c747e90c 100644 --- a/src/NodePilot.Engine/Triggers/EventLogTrigger.cs +++ b/src/NodePilot.Engine/Triggers/EventLogTrigger.cs @@ -38,10 +38,7 @@ public EventLogTrigger(IConfiguration? config = null) public Task ExecuteAsync(StepExecutionContext context, JsonElement config, CancellationToken ct) { // If the orchestrator fired this trigger, event metadata is in context.Variables as manual.* - var orchestratorParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - orchestratorParams[k["manual.".Length..]] = v; + var orchestratorParams = TriggerVariables.ExtractManualParams(context.Variables); if (orchestratorParams.TryGetValue("eventId", out var triggeredEventId)) { diff --git a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs index 41760dc9..4b4a25e9 100644 --- a/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs +++ b/src/NodePilot.Engine/Triggers/FileWatcherTrigger.cs @@ -34,10 +34,7 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso } // If the orchestrator fired this trigger, event data is in context.Variables as manual.* - var outputParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - outputParams[k["manual.".Length..]] = v; + var outputParams = TriggerVariables.ExtractManualParams(context.Variables); if (outputParams.TryGetValue("filePath", out var triggeredFile)) { diff --git a/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs b/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs index fc19ef7e..bdd37e18 100644 --- a/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs +++ b/src/NodePilot.Engine/Triggers/ScheduleTrigger.cs @@ -30,16 +30,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme { Success = true, Output = $"Schedule trigger fired at {DateTime.UtcNow:O}\nCron: {cron}\nDescription: {description ?? "N/A"}", - OutputParameters = ExtractManualParams(context.Variables), + OutputParameters = TriggerVariables.ExtractManualParams(context.Variables), }); } - - private static Dictionary ExtractManualParams(Dictionary vars) - { - var result = new Dictionary(); - foreach (var (k, v) in vars) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - result[k["manual.".Length..]] = v; - return result; - } } diff --git a/src/NodePilot.Engine/Triggers/TriggerVariables.cs b/src/NodePilot.Engine/Triggers/TriggerVariables.cs new file mode 100644 index 00000000..9b37c329 --- /dev/null +++ b/src/NodePilot.Engine/Triggers/TriggerVariables.cs @@ -0,0 +1,23 @@ +namespace NodePilot.Engine.Triggers; + +/// +/// Shared helpers for the trigger node-executors. When a background source fires a workflow, +/// the trigger payload arrives in context.Variables under the manual. prefix +/// (there is no trigger.* namespace); every trigger node surfaces the same flat, +/// prefix-stripped view of it as its OutputParameters. +/// +internal static class TriggerVariables +{ + /// + /// Copies every manual.* entry into a flat dictionary with the prefix stripped. + /// Returns an empty dictionary when the node runs manually without trigger payload. + /// + internal static Dictionary ExtractManualParams(IReadOnlyDictionary variables) + { + var result = new Dictionary(); + foreach (var (k, v) in variables) + if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) + result[k["manual.".Length..]] = v; + return result; + } +} diff --git a/src/NodePilot.Engine/Triggers/WebhookTrigger.cs b/src/NodePilot.Engine/Triggers/WebhookTrigger.cs index 921ec61e..379cb689 100644 --- a/src/NodePilot.Engine/Triggers/WebhookTrigger.cs +++ b/src/NodePilot.Engine/Triggers/WebhookTrigger.cs @@ -17,10 +17,7 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme var path = config.TryGetProperty("path", out var p) ? p.GetString() : null; var method = config.TryGetProperty("method", out var m) ? m.GetString() : "POST"; - var outputParams = new Dictionary(); - foreach (var (k, v) in context.Variables) - if (k.StartsWith("manual.", StringComparison.OrdinalIgnoreCase)) - outputParams[k["manual.".Length..]] = v; + var outputParams = TriggerVariables.ExtractManualParams(context.Variables); var body = outputParams.GetValueOrDefault("webhookBody", "(no body)"); return Task.FromResult(new ActivityResult diff --git a/src/NodePilot.Engine/WorkflowEngine.cs b/src/NodePilot.Engine/WorkflowEngine.cs index a8f22188..da25e1d8 100644 --- a/src/NodePilot.Engine/WorkflowEngine.cs +++ b/src/NodePilot.Engine/WorkflowEngine.cs @@ -70,20 +70,6 @@ public static bool TryGetExecutionCancellation(Guid executionId, out Cancellatio return true; } - /// - /// Apply the redactor to untrusted text and cap the length so a single leaked blob - /// can't blow the DB row or the audit log. Internal helper used for ErrorMessage, - /// InputParametersJson, ReturnData. - /// - private string? RedactAndCap(string? value, int maxChars) - { - if (string.IsNullOrEmpty(value)) return value; - var redacted = _redactor.Redact(value) ?? value; - return redacted.Length > maxChars - ? redacted.Substring(0, maxChars) + "... [truncated]" - : redacted; - } - // Per-execution debug state: breakpoint pauses + step-over control. Runs parallel to // the _runningExecutions dict (same executionId key), so the cancel path can also // reach any paused steps (otherwise the engine thread would hang forever if the user @@ -575,7 +561,7 @@ private WorkflowExecution CreateOrResetExecution( // H-8 (security-audit finding): input parameter JSON may contain values // resolved from secrets/globals — run it through the redactor and cap it to // 32 KiB so a runaway caller can't blow up the DB row or the audit log. - InputParametersJson = RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024), + InputParametersJson = _redactor.RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024), StartedByUserId = startedByUserId, ParentExecutionId = parentExecutionId, CallDepth = callDepth, @@ -595,7 +581,7 @@ private WorkflowExecution CreateOrResetExecution( execution.ErrorMessage = null; execution.TraceId = activity?.TraceId.ToString(); execution.SpanId = activity?.SpanId.ToString(); - execution.InputParametersJson = RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024); + execution.InputParametersJson = _redactor.RedactAndCap(SerializeInputParameters(inputParameters), 32 * 1024); execution.StartedByUserId = startedByUserId; execution.ParentExecutionId = parentExecutionId; execution.CallDepth = callDepth; @@ -1033,15 +1019,7 @@ await PersistTerminalStateResilientAsync( errorMessage, cancelledBy: null, "execution.no_roots"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync(execution.Id, execution.WorkflowId, - execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); return execution; } @@ -1147,7 +1125,7 @@ await WorkflowScheduler.RunAsync(rootNodes, nodesById, adjacency, reverseAdjacen ? $" (+{failedStepCount - 1} more failed activities)" : string.Empty; - failureSummary = RedactAndCap( + failureSummary = _redactor.RedactAndCap( $"Activity \"{stepLabel}\" failed{errorDetail}{additionalFailures}", 32 * 1024); } @@ -1173,15 +1151,7 @@ await _notifier.StepCompletedAsync(execution.Id, execution.WorkflowId, id, label ExecutionStatus.Skipped, null, null, DateTime.UtcNow, stepType: type); } - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); if (execution.Status == ExecutionStatus.Failed) @@ -1214,15 +1184,7 @@ await PersistTerminalStateResilientAsync( errorMessage: null, cancelledBy, "execution.cancelled"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); run.Activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); run.Activity?.SetStatus(ActivityStatusCode.Error, "cancelled"); EngineMetrics.Cancellations.Add(1, run.WorkflowIdTag, run.WorkflowNameTag, new KeyValuePair("reason", "user_or_token")); @@ -1242,22 +1204,14 @@ private async Task CompleteAsFailedAsync(ExecutionRun run, Ex // H-8/H-9 (security-audit findings): redact + cap — the exception may carry a // leaked secret from a child activity (e.g. an HTTP body echoed back in a // deserialization error). - var errorMessage = RedactAndCap(ex.Message, 32 * 1024); + var errorMessage = _redactor.RedactAndCap(ex.Message, 32 * 1024); await PersistTerminalStateResilientAsync( run, ExecutionStatus.Failed, errorMessage, cancelledBy: null, "execution.failed"); - if (IsTerminalStatus(execution.Status)) - { - await _notifier.ExecutionStatusChangedAsync( - execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); - } - else - { - LogTerminalWriteFenced(run); - } + await NotifyTerminalStateAsync(run); run.Activity?.SetTag(TelemetryConstants.Attributes.ExecutionStatus, execution.Status.ToString()); run.Activity?.SetStatus(ActivityStatusCode.Error, execution.Status == ExecutionStatus.Cancelled ? "cancelled" : ex.Message); @@ -1270,6 +1224,25 @@ await _notifier.ExecutionStatusChangedAsync( private static bool IsTerminalStatus(ExecutionStatus status) => status is ExecutionStatus.Succeeded or ExecutionStatus.Failed or ExecutionStatus.Cancelled; + /// + /// Emits the terminal SignalR event — but only if the terminal write actually landed. + /// A fenced write (execution ownership / leader lease lost) leaves the row non-terminal; + /// announcing a status this node no longer owns would race the owning node, so we log instead. + /// + private async Task NotifyTerminalStateAsync(ExecutionRun run) + { + var execution = run.Execution; + if (IsTerminalStatus(execution.Status)) + { + await _notifier.ExecutionStatusChangedAsync( + execution.Id, execution.WorkflowId, execution.Status, execution.ErrorMessage, execution.CompletedAt); + } + else + { + LogTerminalWriteFenced(run); + } + } + private void LogTerminalWriteFenced(ExecutionRun run) { _logger.LogWarning( diff --git a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs index 29e61caf..6bffe84e 100644 --- a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs @@ -2,6 +2,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api.Dtos; using NodePilot.Mcp.Config; @@ -728,35 +729,12 @@ public async Task ExecuteDbReadQueryAsync(string sql, Canc return await ParseAsync(res, ct); } - private static async Task ParseAsync(HttpResponseMessage res, CancellationToken ct) - { - await EnsureSuccessAsync(res, ct); - if (res.StatusCode == HttpStatusCode.NoContent || res.Content.Headers.ContentLength == 0) - return default!; - var stream = await res.Content.ReadAsStreamAsync(ct); - var value = await JsonSerializer.DeserializeAsync(stream, JsonOptions, ct); - if (value is null) throw new ApiException(res.StatusCode, "EmptyBody", "Server returned empty body.", null); - return value; - } + // Response plumbing is shared with the CLI client — see ApiResponseReader in Core. + private static Task ParseAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.ParseAsync(res, JsonOptions, ct); - private static async Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) - { - if (res.IsSuccessStatusCode) return; - var body = await res.Content.ReadAsStringAsync(ct); - string? title = null, detail = null; - if (!string.IsNullOrWhiteSpace(body) && body.TrimStart().StartsWith('{')) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("title", out var t)) title = t.GetString(); - if (doc.RootElement.TryGetProperty("detail", out var d)) detail = d.GetString(); - if (detail is null && doc.RootElement.TryGetProperty("error", out var e)) detail = e.GetString(); - } - catch (JsonException) { /* leave body as raw */ } - } - throw new ApiException(res.StatusCode, title, detail, body); - } + private static Task EnsureSuccessAsync(HttpResponseMessage res, CancellationToken ct) + => ApiResponseReader.EnsureSuccessAsync(res, ct); } /// Thrown when the server URL is not configured — distinct from an HTTP error. diff --git a/src/NodePilot.Mcp/Auth/TokenStore.cs b/src/NodePilot.Mcp/Auth/TokenStore.cs index 7baaeee9..cadfd6a0 100644 --- a/src/NodePilot.Mcp/Auth/TokenStore.cs +++ b/src/NodePilot.Mcp/Auth/TokenStore.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using NodePilot.Mcp.Config; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Auth; @@ -19,7 +19,7 @@ public sealed class TokenStore private readonly string _baseDir; - public TokenStore() : this(ConfigStore.DefaultConfigDir()) { } + public TokenStore() : this(ClientConfigStore.DefaultConfigDir()) { } public TokenStore(string baseDir) { diff --git a/src/NodePilot.Mcp/CLAUDE.md b/src/NodePilot.Mcp/CLAUDE.md index fad77881..69e32576 100644 --- a/src/NodePilot.Mcp/CLAUDE.md +++ b/src/NodePilot.Mcp/CLAUDE.md @@ -6,4 +6,6 @@ Reiner HTTP-Client gegen die REST-API (wie die CLI) + In-Proc-Analyse gegen `Nod **Architektur-Konvention:** Neuer API-Endpoint → Methode in `Api/NodePilotApiClient.cs` (DTOs in `Api/Dtos/` dupliziert) + `[McpServerTool]`-Methode in der passenden `Tools/*Tools.cs` (destruktiv → `DestructiveTools` + `get_safety_status`-Liste pflegen), ggf. Klasse in `Program.cs` via `WithTools()` registrieren (**nie** `WithToolsFromAssembly`), WireMock-Test ergänzen. Frontend-Databus-/Lint-Logik wird in `Analysis/` gespiegelt (`upstreamVariables.ts`, `activityConfigFacts.ts`, `workflowLint.ts`). +**Geteilte Client-Infrastruktur:** `ApiException`, das Response-Plumbing (`ApiResponseReader`) und die Lese-Seite der `config.json` (`ClientConfigStore` + `CliConfig`) liegen in `NodePilot.Core.Clients` — gemeinsam mit der CLI. **Nur die DTOs bleiben bewusst dupliziert** (`Api/Dtos/`); neue Infrastruktur nicht erneut kopieren. + **Activity-Config-Reference:** liegt **nicht** unter `Resources/Embedded/`, sondern in `NodePilot.Core` (`Activities/Embedded/activity-config-reference.json`, gelesen über `ActivityConfigReference`) — `NodePilot.Ai` rendert daraus den Activity-Katalog der AI-Prompts. Neue/geänderte Config-Keys dort pflegen; `ActivityConfigReferenceTests` prüft, dass jeder dokumentierte Key vom Executor wirklich gelesen wird. diff --git a/src/NodePilot.Mcp/Config/McpServerConfig.cs b/src/NodePilot.Mcp/Config/McpServerConfig.cs index f900fa3a..4e2e5170 100644 --- a/src/NodePilot.Mcp/Config/McpServerConfig.cs +++ b/src/NodePilot.Mcp/Config/McpServerConfig.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using NodePilot.Core.Clients; using NodePilot.Mcp.Auth; namespace NodePilot.Mcp.Config; @@ -15,10 +16,10 @@ namespace NodePilot.Mcp.Config; [SupportedOSPlatform("windows")] public sealed class McpServerConfig { - private readonly ConfigStore _config; + private readonly ClientConfigStore _config; private readonly TokenStore _tokens; - public McpServerConfig(ConfigStore config, TokenStore tokens) + public McpServerConfig(ClientConfigStore config, TokenStore tokens) { _config = config; _tokens = tokens; diff --git a/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs b/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs index 729a9959..1a3e4f71 100644 --- a/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs +++ b/src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs @@ -1,4 +1,5 @@ using ModelContextProtocol; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api; namespace NodePilot.Mcp.Mapping; diff --git a/src/NodePilot.Mcp/Program.cs b/src/NodePilot.Mcp/Program.cs index 003032cc..16b68ff7 100644 --- a/src/NodePilot.Mcp/Program.cs +++ b/src/NodePilot.Mcp/Program.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NodePilot.Core.Clients; using NodePilot.Mcp.Api; using NodePilot.Mcp.Auth; using NodePilot.Mcp.Config; @@ -20,7 +21,7 @@ // Connection plumbing — singletons. The NodePilotApiClient is built once from the resolved // session (env-first; falls back to the CLI's DPAPI session + config.json). -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/NodePilot.Scheduler/AuditLogRetentionService.cs b/src/NodePilot.Scheduler/AuditLogRetentionService.cs index 323f320b..5ce2cbe1 100644 --- a/src/NodePilot.Scheduler/AuditLogRetentionService.cs +++ b/src/NodePilot.Scheduler/AuditLogRetentionService.cs @@ -4,7 +4,6 @@ using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -45,73 +44,28 @@ namespace NodePilot.Scheduler; /// can't drift-check without their original hash. /// /// -public class AuditLogRetentionService : BackgroundService +public class AuditLogRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:AuditLog:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private AuditLogRetentionOptions Opts => _opts.CurrentValue.AuditLog; private DateTime _lastVerifyUtc = DateTime.MinValue; - private readonly IDatabaseAvailability _availability; - public AuditLogRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("AuditLogRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: only the leader sweeps audit log so two nodes don't race on DELETEs. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(5, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("AuditLogRetentionService stopped."); - } + protected override string ServiceName => nameof(AuditLogRetentionService); + protected override string MetricServiceTag => "audit_log"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 5; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -120,7 +74,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// Internal so unit tests can drive a single pass (incl. the hot-reload Enabled-toggle path) /// without the 60-second warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -143,14 +97,11 @@ internal async Task RunIterationAsync(CancellationToken ct) if (deleted > 0) _logger.LogInformation("AuditLog retention pass deleted {Count} old entries.", deleted); - var tags = new TagList { new("nodepilot.retention.service", "audit_log") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "AuditLogRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: $"ok: {deleted} deleted", ct: ct); + await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); // Archive integrity verify pass — independent cadence (default daily) so a // 12h-interval retention doesn't pay the SHA-256 walk twice per day. The @@ -174,7 +125,7 @@ await SystemHealthWriter.BeatAsync(db, "AuditLogRetentionService", catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "audit_log") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "AuditLog retention pass failed — will retry on next interval."); } diff --git a/src/NodePilot.Scheduler/ExecutionRetentionService.cs b/src/NodePilot.Scheduler/ExecutionRetentionService.cs index 1e30d6b4..c5403558 100644 --- a/src/NodePilot.Scheduler/ExecutionRetentionService.cs +++ b/src/NodePilot.Scheduler/ExecutionRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Core.Enums; @@ -37,74 +36,28 @@ namespace NodePilot.Scheduler; /// - Deletes in bounded batches (BatchSize, default 500) so one pass never holds /// a long-running transaction on SQLite. /// -public class ExecutionRetentionService : BackgroundService +public class ExecutionRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:Executions:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private ExecutionsRetentionOptions Opts => _opts.CurrentValue.Executions; - private readonly IDatabaseAvailability _availability; - public ExecutionRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Small initial delay so the patcher + trigger orchestrator have time to settle on a - // cold start before we start issuing DELETEs. - try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("ExecutionRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: only the leader may run retention sweeps. Otherwise a follower would - // contend on the same DELETEs and double the IO cost on the shared DB. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("ExecutionRetentionService stopped."); - } + protected override string ServiceName => nameof(ExecutionRetentionService); + protected override string MetricServiceTag => "execution"; + // Small initial delay so the patcher + trigger orchestrator have time to settle on a + // cold start before we start issuing DELETEs. + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(30); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -112,7 +65,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the 30-second warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. The outer @@ -135,7 +88,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Retention pass deleted {Count} old executions (and cascaded step rows).", deleted); - var tags = new TagList { new("nodepilot.retention.service", "execution") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); @@ -143,20 +96,12 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "execution") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Retention pass failed — will retry on next interval."); } } - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "ExecutionRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } - // Exposed as internal so unit tests can drive a single pass without waiting the 30-second // warm-up or a full interval. internal async Task PurgeOnceAsync(int maxAgeDays, int batchSize, CancellationToken ct) diff --git a/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs b/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs new file mode 100644 index 00000000..08d482c3 --- /dev/null +++ b/src/NodePilot.Scheduler/LeaderGatedRetentionService.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NodePilot.Core.Interfaces; +using NodePilot.Data; +using NodePilot.Data.Availability; +using NodePilot.Scheduler.Options; + +namespace NodePilot.Scheduler; + +/// +/// Shared frame of the leader-gated retention sweeps (executions, audit log, workflow versions, +/// support events, notification ledger). It owns exactly the part that was identical in all five +/// services: the cold-start warm-up, the started/stopped log pair, and the pass loop with its +/// availability gate → leader gate → one iteration → interval delay. +/// +/// +/// The sweep itself stays in the derived , deliberately: its broad +/// catch (Exception) must remain one level BELOW the host-fatal boundary, because +/// HostOptions.BackgroundServiceExceptionBehavior is left at StopHost and anything +/// escaping this loop takes the host with it. The only catch this class owns is the +/// that turns a shutdown into a clean loop exit. +/// +/// +public abstract class LeaderGatedRetentionService : BackgroundService +{ + protected readonly IServiceScopeFactory _scopeFactory; + // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of the sweep's + // Retention:* section takes effect on the next pass without a restart. + protected readonly IOptionsMonitor _opts; + // Non-generic on purpose: each derived service passes its own ILogger, so the log + // category stays the concrete service. + protected readonly ILogger _logger; + + private readonly IClusterStateProvider _cluster; + private readonly IDatabaseAvailability _availability; + + protected LeaderGatedRetentionService( + IServiceScopeFactory scopeFactory, + IOptionsMonitor opts, + IClusterStateProvider cluster, + ILogger logger, + IDatabaseAvailability availability) + { + _scopeFactory = scopeFactory; + _opts = opts; + _cluster = cluster; + _availability = availability; + _logger = logger; + } + + /// + /// The concrete service name — used verbatim in the two lifecycle log lines and as the + /// heartbeat key in SystemHealth. + /// + protected abstract string ServiceName { get; } + + /// Cold-start grace before the first pass. + protected abstract TimeSpan WarmUpDelay { get; } + + /// Lower bound (minutes) for the inter-pass delay. + protected abstract int MinIntervalMinutes { get; } + + /// Live IntervalMinutes of this sweep's options section — read per pass. + protected abstract int ConfiguredIntervalMinutes { get; } + + /// Value of the nodepilot.retention.service metric tag. + protected abstract string MetricServiceTag { get; } + + protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(WarmUpDelay, stoppingToken); } + catch (OperationCanceledException) { return; } + + // Composed instead of a literal so every service keeps the exact message (and template) + // it logged before the five loops were merged into this base. +#pragma warning disable CA2254 + _logger.LogInformation(ServiceName + " started (hot-reload: per-pass config)."); +#pragma warning restore CA2254 + + while (!stoppingToken.IsCancellationRequested) + { + // Availability gate, deliberately ABOVE the leader check: during a database outage no + // node can renew its cluster lease, so every node reads as a follower - gating on + // IsLeader first would park for the right reason and log the wrong one. + // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior + // is left at its default StopHost, so an escaping cancellation would stop the host). + if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; + + // HA gate: only the leader sweeps. Otherwise a follower would contend on the same + // DELETEs and double the IO cost on the shared DB. + if (!_cluster.IsLeader) + { + try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } + catch (OperationCanceledException) { break; } + continue; + } + + try + { + await RunIterationAsync(stoppingToken); + } + catch (OperationCanceledException) { break; } + + var interval = TimeSpan.FromMinutes(Math.Max(MinIntervalMinutes, ConfiguredIntervalMinutes)); + try { await Task.Delay(interval, stoppingToken); } + catch (OperationCanceledException) { break; } + } + +#pragma warning disable CA2254 + _logger.LogInformation(ServiceName + " stopped."); +#pragma warning restore CA2254 + } + + /// + /// Exactly one sweep iteration. Implemented by the derived service — including its own broad + /// catch, which must stay below the host-fatal boundary (see the class remarks). No + /// Task.Delay in there: this class owns the inter-pass spacing. + /// + internal abstract Task RunIterationAsync(CancellationToken ct); + + /// Metric tags for this sweep: the single nodepilot.retention.service tag. + protected TagList RetentionTags() => new TagList { new("nodepilot.retention.service", MetricServiceTag) }; + + /// Liveness beat recorded under . + protected async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await SystemHealthWriter.BeatAsync(db, ServiceName, + expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); + } +} diff --git a/src/NodePilot.Scheduler/NotificationDispatcher.cs b/src/NodePilot.Scheduler/NotificationDispatcher.cs index e2962268..748a5f6e 100644 --- a/src/NodePilot.Scheduler/NotificationDispatcher.cs +++ b/src/NodePilot.Scheduler/NotificationDispatcher.cs @@ -219,18 +219,7 @@ private async Task DispatchSystemAlertsAsync( } } - // Always save — flushes evaluator-staged match/episode state even when nothing fires this pass. - await db.SaveChangesAsync(ct); - - var sent = 0; - foreach (var (attempt, route, ctx) in toSend) - { - if (!StillOwnsLease(leaseEpoch)) break; - await SendOneAsync(db, store, attempt, route, ctx, now, leaseEpoch, ct); - if (attempt.Status == NotificationDeliveryStatus.Sent) sent++; - } - if (toSend.Count > 0) await db.SaveChangesAsync(ct); - return sent; + return await PersistAndSendAsync(db, store, toSend, now, leaseEpoch, ct); } /// @@ -284,8 +273,24 @@ private async Task MatchAndSendAsync( } } - // Always save once — flushes the collector's staged state (watermark / signal-states) even - // when nothing matched, plus the Pending attempts. + return await PersistAndSendAsync(db, store, toSend, now, leaseEpoch, ct); + } + + /// + /// Shared tail of both delivery paths. One unconditional SaveChanges flushes whatever the caller + /// staged on the tracked context — the collector's watermark / signal-states, or the system-alert + /// evaluator's match/episode state — together with the Pending attempts, even when nothing fired + /// this pass; that ordering is what preserves persist-before-send crash-safety. Then every attempt + /// is sent under the lease fence and the resulting statuses are saved. + /// + private async Task PersistAndSendAsync( + NodePilotDbContext db, + INotificationRuleStore store, + List<(NotificationDeliveryAttempt attempt, NotificationRoute route, NotificationContext ctx)> toSend, + DateTime now, + long leaseEpoch, + CancellationToken ct) + { await db.SaveChangesAsync(ct); var sent = 0; diff --git a/src/NodePilot.Scheduler/NotificationRetentionService.cs b/src/NodePilot.Scheduler/NotificationRetentionService.cs index 4105a672..be7ce242 100644 --- a/src/NodePilot.Scheduler/NotificationRetentionService.cs +++ b/src/NodePilot.Scheduler/NotificationRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Core.Enums; @@ -26,71 +25,26 @@ namespace NodePilot.Scheduler; /// Config: (Retention:Notifications:* — /// Enabled=true, MaxAgeDays=90, IntervalMinutes=360). /// -public class NotificationRetentionService : BackgroundService +public class NotificationRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:Notifications:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private NotificationsRetentionOptions Opts => _opts.CurrentValue.Notifications; - private readonly IDatabaseAvailability _availability; - public NotificationRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("NotificationRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("NotificationRetentionService stopped."); - } + protected override string ServiceName => nameof(NotificationRetentionService); + protected override string MetricServiceTag => "notification_deliveries"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -98,7 +52,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -119,7 +73,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Pruned {Count} notification delivery/suppression rows older than {Days}d.", deleted, maxAgeDays); - var tags = new TagList { new("nodepilot.retention.service", "notification_deliveries") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} pruned", ct); @@ -127,7 +81,7 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "notification_deliveries") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Notification retention sweep failed — retrying on next interval."); } @@ -159,12 +113,4 @@ internal async Task PurgeOnceAsync(int maxAgeDays, CancellationToken ct) return deletedAttempts + deletedSuppressions + deletedPolicyStates; } - - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "NotificationRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } } diff --git a/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs new file mode 100644 index 00000000..6185e5ba --- /dev/null +++ b/src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs @@ -0,0 +1,123 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NodePilot.Core.Enums; +using NodePilot.Core.Models; +using NodePilot.Data; +using NodePilot.Engine.Notifications; + +namespace NodePilot.Scheduler.Notifications; + +/// +/// Shared body of the in-flight execution-age collectors (long-running, queued-long). Both scan +/// executions that have been sitting in ONE non-terminal status longer than +/// and emit one execution-scoped context per row; they differ only in the config key, the event type, +/// the scanned status and the EventKey prefix. Each flavour stays its own collector instance so its +/// EventKey shape keeps exactly one owner across collection AND crash recovery. +/// +internal abstract class ElapsedExecutionCollector : INotificationCollector +{ + private readonly IConfiguration _configuration; + private readonly string _thresholdKey; + private readonly NotificationEventType _eventType; + private readonly ExecutionStatus _status; + private readonly string _eventKeyPrefix; + private readonly string _titlePrefix; + private readonly string _elapsedVerb; + + // An execution older than this fires the flavour's event (once per execution). + // Initialised from the flavour's config key; hot-reload overlaid per pass; + // settable in tests via the dispatcher's forwarding property. + internal TimeSpan Threshold { get; set; } + + protected ElapsedExecutionCollector( + IConfiguration configuration, + string thresholdKey, + int defaultSeconds, + NotificationEventType eventType, + ExecutionStatus status, + string eventKeyPrefix, + string titlePrefix, + string elapsedVerb) + { + _configuration = configuration; + _thresholdKey = thresholdKey; + _eventType = eventType; + _status = status; + _eventKeyPrefix = eventKeyPrefix; + _titlePrefix = titlePrefix; + _elapsedVerb = elapsedVerb; + Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue(thresholdKey, defaultSeconds))); + } + + public async Task CollectAsync( + NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) + { + // Hot-reload: overlay the threshold every pass so a live edit takes effect without a + // restart. Only overlay when the key is explicitly set — tests that set the property + // directly with an empty config keep their value. + var seconds = _configuration.GetValue(_thresholdKey); + if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); + + var rules = enabledRules + .Where(r => NotificationRuleSemantics.RuleWants(r, _eventType)) + .ToList(); + if (rules.Count == 0) return null; // nothing to alert on → skip the scan entirely + + var cutoff = now - Threshold; + // Local copy: the query closure must capture a local, not this collector instance. + var status = _status; + var batch = await db.WorkflowExecutions.AsNoTracking() + .Where(e => e.Status == status && e.StartedAt <= cutoff) + .OrderBy(e => e.StartedAt) + .Take(ExecutionEventSupport.ScanBatchSize) + .Select(ExecutionEventSupport.Projection) + .ToListAsync(ct); + if (batch.Count == 0) return null; + + var contexts = batch.Select(r => BuildContext(r, now)).ToList(); + return new NotificationCollection(rules, contexts); + } + + public async Task TryReconstructContextAsync( + NodePilotDbContext db, string eventKey, CancellationToken ct) + { + // Shape: {prefix}:{guidN}. Re-derive from the (still in-flight) row. Without this branch a + // crash-orphaned attempt would match no collector and be failed out (lost alert). + var parts = eventKey.Split(':'); + if (parts.Length != 2 || parts[0] != _eventKeyPrefix || !Guid.TryParse(parts[1], out var execId)) return null; + + var row = await db.WorkflowExecutions.AsNoTracking() + .Where(e => e.Id == execId) + .Select(ExecutionEventSupport.Projection) + .FirstOrDefaultAsync(ct); + return row is null ? null : BuildContext(row, DateTime.UtcNow); + } + + private NotificationContext BuildContext(ExecRow row, DateTime now) + { + var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; + return new NotificationContext( + EventType: _eventType, + Severity: NotificationSeverity.Warning, + // No time/type segment → one occurrence per execution; the existence-check dedups across passes + // so a still-in-flight job never re-alerts every 30s. + EventKey: $"{_eventKeyPrefix}:{row.Id:N}", + WorkflowId: row.WorkflowId, + WorkflowName: row.WorkflowName, + FolderId: row.FolderId, + FolderPath: row.FolderPath, + ExecutionId: row.Id, + Status: _status.ToString(), + ErrorMessage: null, + DurationMs: elapsedMs, + OccurredAt: now, + TriggeredBy: row.TriggeredBy, + CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, + IsSubWorkflow: row.ParentExecutionId.HasValue, + TargetMachine: null, + SourceKey: null, + Title: $"{_titlePrefix}: {row.WorkflowName}", + Summary: $"Execution has been {_elapsedVerb} for ~{(long)(now - row.StartedAt).TotalMinutes} min.", + DeepLinkPath: $"/executions/{row.Id}"); + } +} diff --git a/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs b/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs index 44fa0466..ca1c8dea 100644 --- a/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/ExecutionEventCollector.cs @@ -47,19 +47,7 @@ internal sealed class ExecutionEventCollector : INotificationCollector && (e.CompletedAt > lastTs || (e.CompletedAt == lastTs && e.Id.CompareTo(lastId) > 0))) .OrderBy(e => e.CompletedAt).ThenBy(e => e.Id) .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - // Gate on Failed in SQL: for Succeeded/Cancelled rows (the common case on a - // healthy instance) the value is null anyway — don't pay a per-row ORDER BY - // subquery over StepExecutions for information that gets discarded. - e.Status == ExecutionStatus.Failed - ? e.Steps - .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) - .OrderByDescending(s => s.CompletedAt) - .Select(s => s.TargetMachine) - .FirstOrDefault() - : null)) + .Select(ExecutionEventSupport.ProjectionWithFailedStepMachine) .ToListAsync(ct); if (batch.Count == 0) return null; @@ -92,15 +80,7 @@ internal sealed class ExecutionEventCollector : INotificationCollector var row = await db.WorkflowExecutions.AsNoTracking() .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - e.Status == ExecutionStatus.Failed - ? e.Steps - .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) - .OrderByDescending(s => s.CompletedAt) - .Select(s => s.TargetMachine) - .FirstOrDefault() - : null)) + .Select(ExecutionEventSupport.ProjectionWithFailedStepMachine) .FirstOrDefaultAsync(ct); if (row is null) return null; row = (await ExecutionEventSupport.ResolveTargetMachineNamesAsync(db, [row], ct))[0]; diff --git a/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs b/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs index a262e4d4..8d3ac44f 100644 --- a/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs +++ b/src/NodePilot.Scheduler/Notifications/ExecutionEventSupport.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Enums; using NodePilot.Core.Models; @@ -25,6 +26,33 @@ internal static class ExecutionEventSupport /// Per-pass scan cap shared by the execution-shaped collectors. public const int ScanBatchSize = 200; + /// + /// The projection without a failed-step machine. Used by the + /// long-running and queued-long collectors, where the execution is still in flight and a + /// failed-step join would be meaningless. + /// + public static readonly Expression> Projection = e => new ExecRow( + e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, + e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, + null); + + /// + /// The projection for terminal scans, resolving the machine of the + /// last failing step. Gated on Failed in SQL: for Succeeded/Cancelled rows (the common case + /// on a healthy instance) the value is null anyway — don't pay a per-row ORDER BY subquery + /// over StepExecutions for information that gets discarded. + /// + public static readonly Expression> ProjectionWithFailedStepMachine = e => new ExecRow( + e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, + e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, + e.Status == ExecutionStatus.Failed + ? e.Steps + .Where(s => s.Status == ExecutionStatus.Failed && s.TargetMachine != null) + .OrderByDescending(s => s.CompletedAt) + .Select(s => s.TargetMachine) + .FirstOrDefault() + : null); + private static readonly string[] CredentialFailureNeedles = [ "credential", diff --git a/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs index 601358f6..914eaf47 100644 --- a/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/LongRunningExecutionCollector.cs @@ -1,14 +1,10 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using NodePilot.Core.Enums; -using NodePilot.Core.Models; -using NodePilot.Data; -using NodePilot.Engine.Notifications; namespace NodePilot.Scheduler.Notifications; /// -/// Long-running collector: scans STILL-RUNNING executions older than +/// Long-running collector: scans STILL-RUNNING executions older than Threshold /// and emits an context per execution. /// It is execution-scoped (carries a real WorkflowId → Global/Folders/Workflows scope), NOT a /// gauge — the per-(rule,route,EventKey) existence-check on runlong:{execId} fires each @@ -16,93 +12,17 @@ namespace NodePilot.Scheduler.Notifications; /// pass so a newly-crossed execution is picked up; finished executions simply drop out of the /// RUNNING scan. /// -internal sealed class LongRunningExecutionCollector : INotificationCollector +internal sealed class LongRunningExecutionCollector : ElapsedExecutionCollector { - private readonly IConfiguration _configuration; - - // A still-running execution older than this fires ExecutionRunningLong (once per execution). - // Initialised from Alerting:LongRunningSeconds (default 600); hot-reload overlaid per pass; - // settable in tests via the dispatcher's forwarding property. - internal TimeSpan Threshold { get; set; } - public LongRunningExecutionCollector(IConfiguration configuration) + : base(configuration, + thresholdKey: "Alerting:LongRunningSeconds", + defaultSeconds: 600, + eventType: NotificationEventType.ExecutionRunningLong, + status: ExecutionStatus.Running, + eventKeyPrefix: "runlong", + titlePrefix: "Execution running long", + elapsedVerb: "running") { - _configuration = configuration; - Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue("Alerting:LongRunningSeconds", 600))); - } - - public async Task CollectAsync( - NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) - { - // Hot-reload: overlay the threshold every pass so a live edit takes effect without a - // restart. Only overlay when the key is explicitly set — tests that set the property - // directly with an empty config keep their value. - var seconds = _configuration.GetValue("Alerting:LongRunningSeconds"); - if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); - - var rules = enabledRules - .Where(r => NotificationRuleSemantics.RuleWants(r, NotificationEventType.ExecutionRunningLong)) - .ToList(); - if (rules.Count == 0) return null; // nothing to alert on → skip the running scan entirely - - var cutoff = now - Threshold; - var batch = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Status == ExecutionStatus.Running && e.StartedAt <= cutoff) - .OrderBy(e => e.StartedAt) - .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .ToListAsync(ct); - if (batch.Count == 0) return null; - - var contexts = batch.Select(r => BuildContext(r, now)).ToList(); - return new NotificationCollection(rules, contexts); - } - - public async Task TryReconstructContextAsync( - NodePilotDbContext db, string eventKey, CancellationToken ct) - { - // Shape: runlong:{guidN}. Re-derive from the (still-running) row. Without this branch a - // crash-orphaned runlong: attempt would match no collector and be failed out (lost alert). - var parts = eventKey.Split(':'); - if (parts.Length != 2 || parts[0] != "runlong" || !Guid.TryParse(parts[1], out var execId)) return null; - - var row = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .FirstOrDefaultAsync(ct); - return row is null ? null : BuildContext(row, DateTime.UtcNow); - } - - private static NotificationContext BuildContext(ExecRow row, DateTime now) - { - var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; - return new NotificationContext( - EventType: NotificationEventType.ExecutionRunningLong, - Severity: NotificationSeverity.Warning, - // No time/type segment → one occurrence per execution; the existence-check dedups across passes - // so a still-running job never re-alerts every 30s. - EventKey: $"runlong:{row.Id:N}", - WorkflowId: row.WorkflowId, - WorkflowName: row.WorkflowName, - FolderId: row.FolderId, - FolderPath: row.FolderPath, - ExecutionId: row.Id, - Status: "Running", - ErrorMessage: null, - DurationMs: elapsedMs, - OccurredAt: now, - TriggeredBy: row.TriggeredBy, - CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, - IsSubWorkflow: row.ParentExecutionId.HasValue, - TargetMachine: null, - SourceKey: null, - Title: $"Execution running long: {row.WorkflowName}", - Summary: $"Execution has been running for ~{(long)(now - row.StartedAt).TotalMinutes} min.", - DeepLinkPath: $"/executions/{row.Id}"); } } diff --git a/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs b/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs index a7146e4f..aea446c1 100644 --- a/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs +++ b/src/NodePilot.Scheduler/Notifications/QueuedLongExecutionCollector.cs @@ -1,99 +1,24 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using NodePilot.Core.Enums; -using NodePilot.Core.Models; -using NodePilot.Data; -using NodePilot.Engine.Notifications; namespace NodePilot.Scheduler.Notifications; /// -/// Queued-long collector: scans still-pending executions older than +/// Queued-long collector: scans still-pending executions older than Threshold /// and emits one workflow-scoped per /// execution. EventKey shape: queuedlong:{guidN} (existence-check dedups across passes). /// -internal sealed class QueuedLongExecutionCollector : INotificationCollector +internal sealed class QueuedLongExecutionCollector : ElapsedExecutionCollector { - private readonly IConfiguration _configuration; - - // A pending execution older than this fires ExecutionQueuedLong (once per execution). - // Initialised from Alerting:QueuedLongSeconds (default 300); hot-reload overlaid per pass; - // settable in tests via the dispatcher's forwarding property. - internal TimeSpan Threshold { get; set; } - public QueuedLongExecutionCollector(IConfiguration configuration) + : base(configuration, + thresholdKey: "Alerting:QueuedLongSeconds", + defaultSeconds: 300, + eventType: NotificationEventType.ExecutionQueuedLong, + status: ExecutionStatus.Pending, + eventKeyPrefix: "queuedlong", + titlePrefix: "Execution queued long", + elapsedVerb: "pending") { - _configuration = configuration; - Threshold = TimeSpan.FromSeconds(Math.Max(1, configuration.GetValue("Alerting:QueuedLongSeconds", 300))); - } - - public async Task CollectAsync( - NodePilotDbContext db, IReadOnlyList enabledRules, DateTime now, CancellationToken ct) - { - // Hot-reload: overlay the threshold every pass (only when the key is explicitly set). - var seconds = _configuration.GetValue("Alerting:QueuedLongSeconds"); - if (seconds.HasValue) Threshold = TimeSpan.FromSeconds(Math.Max(1, seconds.Value)); - - var rules = enabledRules - .Where(r => NotificationRuleSemantics.RuleWants(r, NotificationEventType.ExecutionQueuedLong)) - .ToList(); - if (rules.Count == 0) return null; - - var cutoff = now - Threshold; - var batch = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Status == ExecutionStatus.Pending && e.StartedAt <= cutoff) - .OrderBy(e => e.StartedAt) - .Take(ExecutionEventSupport.ScanBatchSize) - .Select(e => new ExecRow( - e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, e.ErrorMessage, - e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .ToListAsync(ct); - if (batch.Count == 0) return null; - - var contexts = batch.Select(r => BuildContext(r, now)).ToList(); - return new NotificationCollection(rules, contexts); - } - - public async Task TryReconstructContextAsync( - NodePilotDbContext db, string eventKey, CancellationToken ct) - { - // Shape: queuedlong:{guidN}. Re-derive from the pending row. - var parts = eventKey.Split(':'); - if (parts.Length != 2 || parts[0] != "queuedlong" || !Guid.TryParse(parts[1], out var execId)) return null; - - var row = await db.WorkflowExecutions.AsNoTracking() - .Where(e => e.Id == execId) - .Select(e => new ExecRow(e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.TriggeredBy, - e.ErrorMessage, e.ParentExecutionId, e.Workflow.Name, e.Workflow.FolderId, e.Workflow.Folder!.Path, e.CancelledBy, - null)) - .FirstOrDefaultAsync(ct); - return row is null ? null : BuildContext(row, DateTime.UtcNow); - } - - private static NotificationContext BuildContext(ExecRow row, DateTime now) - { - var elapsedMs = (long)(now - row.StartedAt).TotalMilliseconds; - return new NotificationContext( - EventType: NotificationEventType.ExecutionQueuedLong, - Severity: NotificationSeverity.Warning, - EventKey: $"queuedlong:{row.Id:N}", - WorkflowId: row.WorkflowId, - WorkflowName: row.WorkflowName, - FolderId: row.FolderId, - FolderPath: row.FolderPath, - ExecutionId: row.Id, - Status: "Pending", - ErrorMessage: null, - DurationMs: elapsedMs, - OccurredAt: now, - TriggeredBy: row.TriggeredBy, - CallDepth: row.ParentExecutionId.HasValue ? 1 : 0, - IsSubWorkflow: row.ParentExecutionId.HasValue, - TargetMachine: null, - SourceKey: null, - Title: $"Execution queued long: {row.WorkflowName}", - Summary: $"Execution has been pending for ~{(long)(now - row.StartedAt).TotalMinutes} min.", - DeepLinkPath: $"/executions/{row.Id}"); } } diff --git a/src/NodePilot.Scheduler/SupportEventRetentionService.cs b/src/NodePilot.Scheduler/SupportEventRetentionService.cs index 6509cebc..cfa5084a 100644 --- a/src/NodePilot.Scheduler/SupportEventRetentionService.cs +++ b/src/NodePilot.Scheduler/SupportEventRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -19,71 +18,26 @@ namespace NodePilot.Scheduler; /// Enabled=true, MaxAgeDays=90 matching the file-based support-log retention, /// IntervalMinutes=360). /// -public class SupportEventRetentionService : BackgroundService +public class SupportEventRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:SupportEvents:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private SupportEventsRetentionOptions Opts => _opts.CurrentValue.SupportEvents; - private readonly IDatabaseAvailability _availability; - public SupportEventRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try { await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("SupportEventRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("SupportEventRetentionService stopped."); - } + protected override string ServiceName => nameof(SupportEventRetentionService); + protected override string MetricServiceTag => "support_events"; + protected override TimeSpan WarmUpDelay => TimeSpan.FromSeconds(60); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -91,7 +45,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -112,7 +66,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("Pruned {Count} support events older than {Days}d.", deleted, maxAgeDays); - var tags = new TagList { new("nodepilot.retention.service", "support_events") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} pruned", ct); @@ -120,7 +74,7 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "support_events") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "Support-event retention sweep failed — retrying on next interval."); } @@ -135,12 +89,4 @@ internal async Task PurgeOnceAsync(int maxAgeDays, CancellationToken ct) return await db.SupportEvents.Where(e => e.Timestamp < cutoff) .ExecuteDeleteAsync(ct); } - - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "SupportEventRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } } diff --git a/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs b/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs index 913692f8..19d52c86 100644 --- a/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs +++ b/src/NodePilot.Scheduler/WorkflowVersionsRetentionService.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodePilot.Data; @@ -39,74 +38,28 @@ namespace NodePilot.Scheduler; /// } /// } /// -public class WorkflowVersionsRetentionService : BackgroundService +public class WorkflowVersionsRetentionService : LeaderGatedRetentionService { - private readonly IServiceScopeFactory _scopeFactory; - // Hot-reload: hold the live monitor (not a cached snapshot) so a config edit of - // Retention:WorkflowVersions:* takes effect on the next sweep pass without a restart. - private readonly IOptionsMonitor _opts; - private readonly NodePilot.Core.Interfaces.IClusterStateProvider _cluster; - private readonly ILogger _logger; - // Resolved per pass from the live monitor — never cached across passes. private WorkflowVersionsRetentionOptions Opts => _opts.CurrentValue.WorkflowVersions; - private readonly IDatabaseAvailability _availability; - public WorkflowVersionsRetentionService( IServiceScopeFactory scopeFactory, IOptionsMonitor opts, NodePilot.Core.Interfaces.IClusterStateProvider cluster, ILogger logger, IDatabaseAvailability availability) + : base(scopeFactory, opts, cluster, logger, availability) { - _scopeFactory = scopeFactory; - _opts = opts; - _cluster = cluster; - _availability = availability; - _logger = logger; } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Match ExecutionRetentionService's warm-up delay so we don't hammer the DB during - // the cold-start window where the migration bootstrap + TriggerOrchestrator do their - // initial work. - try { await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); } - catch (OperationCanceledException) { return; } - - _logger.LogInformation("WorkflowVersionsRetentionService started (hot-reload: per-pass config)."); - - while (!stoppingToken.IsCancellationRequested) - { - // Availability gate, deliberately ABOVE the leader check: during a database outage no - // node can renew its cluster lease, so every node reads as a follower - gating on - // IsLeader first would park for the right reason and log the wrong one. - // Returns false only on shutdown and never throws (BackgroundServiceExceptionBehavior - // is left at its default StopHost, so an escaping cancellation would stop the host). - if (!await _availability.WaitUntilServableAsync(stoppingToken)) break; - - // HA gate: leader-only. - if (!_cluster.IsLeader) - { - try { await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); } - catch (OperationCanceledException) { break; } - continue; - } - - try - { - await RunIterationAsync(stoppingToken); - } - catch (OperationCanceledException) { break; } - - var interval = TimeSpan.FromMinutes(Math.Max(1, Opts.IntervalMinutes)); - try { await Task.Delay(interval, stoppingToken); } - catch (OperationCanceledException) { break; } - } - - _logger.LogInformation("WorkflowVersionsRetentionService stopped."); - } + protected override string ServiceName => nameof(WorkflowVersionsRetentionService); + protected override string MetricServiceTag => "workflow_versions"; + // Longer warm-up than the other sweeps so we don't hammer the DB during the cold-start + // window where the migration bootstrap + TriggerOrchestrator do their initial work. + protected override TimeSpan WarmUpDelay => TimeSpan.FromMinutes(2); + protected override int MinIntervalMinutes => 1; + protected override int ConfiguredIntervalMinutes => Opts.IntervalMinutes; /// /// Exactly one sweep iteration: reads the live config, skips when disabled, else runs one @@ -114,7 +67,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// owns the inter-pass spacing. Internal so unit tests can drive /// a single pass (incl. the hot-reload Enabled-toggle path) without the 2-minute warm-up. /// - internal async Task RunIterationAsync(CancellationToken ct) + internal override async Task RunIterationAsync(CancellationToken ct) { // Hot-reload: a live toggle to Enabled=false parks the sweep instead of killing the // service, so flipping back to true later takes effect without a restart. @@ -136,7 +89,7 @@ internal async Task RunIterationAsync(CancellationToken ct) sw.Stop(); if (deleted > 0) _logger.LogInformation("WorkflowVersions retention deleted {Count} old history rows.", deleted); - var tags = new TagList { new("nodepilot.retention.service", "workflow_versions") }; + var tags = RetentionTags(); SchedulerMetrics.RetentionRowsDeleted.Add(deleted, tags); SchedulerMetrics.RetentionSweepDuration.Record(sw.Elapsed.TotalMilliseconds, tags); await HeartbeatAsync(intervalMinutes, $"ok: {deleted} deleted", ct); @@ -144,20 +97,12 @@ internal async Task RunIterationAsync(CancellationToken ct) catch (OperationCanceledException) { throw; } catch (Exception ex) { - var errTags = new TagList { new("nodepilot.retention.service", "workflow_versions") }; + var errTags = RetentionTags(); SchedulerMetrics.RetentionSweepErrors.Add(1, errTags); _logger.LogError(ex, "WorkflowVersions retention pass failed — will retry on next interval."); } } - private async Task HeartbeatAsync(int intervalMinutes, string status, CancellationToken ct) - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await SystemHealthWriter.BeatAsync(db, "WorkflowVersionsRetentionService", - expectedIntervalSeconds: intervalMinutes * 60, status: status, ct: ct); - } - // Exposed as internal so unit tests can drive a single pass without waiting the warm-up // or a full interval. internal async Task PurgeOnceAsync(int maxVersionsPerWorkflow, int batchSize, CancellationToken ct) diff --git a/src/nodepilot-ui/package.json b/src/nodepilot-ui/package.json index 09cbd486..c8dea7be 100644 --- a/src/nodepilot-ui/package.json +++ b/src/nodepilot-ui/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@carbon/icons-react": "^11.85.0", - "@codemirror/lang-javascript": "^6.2.5", "@codemirror/language": "^6.12.3", "@codemirror/legacy-modes": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/src/nodepilot-ui/src/api/backup.ts b/src/nodepilot-ui/src/api/backup.ts index 6f00e017..bf6a74a2 100644 --- a/src/nodepilot-ui/src/api/backup.ts +++ b/src/nodepilot-ui/src/api/backup.ts @@ -42,17 +42,6 @@ export interface BackupRestoreResult { warnings: string[]; } -/** Section keys, in display order — must match BackupSections on the server. */ -export const BACKUP_SECTIONS = [ - 'folders', - 'users', - 'credentials', - 'machines', - 'globalVariables', - 'workflows', - 'settings', -] as const; - export const backupApi = { getManifest: () => api.get('/backup/manifest'), diff --git a/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx b/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx index ec998b06..0e210a92 100644 --- a/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/DbAdminSection.tsx @@ -1,6 +1,7 @@ -import { Close, DataBase, SecurityServices } from '@carbon/icons-react'; +import { DataBase, SecurityServices } from '@carbon/icons-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { TypedPhraseConfirmDialog } from '../common/TypedPhraseConfirmDialog'; import { useSectionForm, Card, @@ -124,82 +125,18 @@ export function DbAdminSection() { /> {ui.dialog} {pendingEnable && ( - { setPendingEnable(false); setConfirmInput(''); }} onConfirm={acceptEnable} + title={t('adminSettings:dbAdmin.confirmEnableTitle')} + body={t('adminSettings:dbAdmin.confirmEnableBody')} + prompt={t('adminSettings:dbAdmin.confirmEnablePrompt', { phrase: CONFIRM_PHRASE })} + confirmLabel={t('adminSettings:dbAdmin.confirmEnableButton')} /> )} ); } - -function ConfirmEnableWriteDialog({ - phrase, input, onInput, onCancel, onConfirm, -}: Readonly<{ - phrase: string; - input: string; - onInput: (v: string) => void; - onCancel: () => void; - onConfirm: () => void; -}>) { - const { t } = useTranslation(['adminSettings', 'common']); - const ok = input === phrase; - - return ( -
e.key === 'Escape' && onCancel()} - role="presentation" - tabIndex={-1} - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > -
-

- - {t('adminSettings:dbAdmin.confirmEnableTitle')} -

- -
-

- {t('adminSettings:dbAdmin.confirmEnableBody')} -

-

- {t('adminSettings:dbAdmin.confirmEnablePrompt', { phrase })} -

- onInput(e.target.value)} - autoFocus - className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" - /> -
- - -
-
-
- ); -} diff --git a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx index 3006eab1..e297d3c4 100644 --- a/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/LoggingTelemetrySection.tsx @@ -1,16 +1,9 @@ import { Activity, ChartBar, Chip, Document } from '@carbon/icons-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - adminSettings, - SettingsApiError, - type SettingsSectionResponse, -} from '../../api/adminSettings'; import { SecretField, serializeSecretField, type SecretFieldMode } from './SecretField'; import { EnvOverrideBadge } from './EnvOverrideBadge'; -import { EtagConflictDialog } from './EtagConflictDialog'; -import { GroupHeading, HotReloadHint } from './SectionFormHelpers'; +import { GroupHeading, HotReloadHint, useSectionForm } from './SectionFormHelpers'; /** * Three independently-saveable cards in one tab: Logging / OpenTelemetry / Stats. @@ -374,110 +367,9 @@ function StatsCard() { } // ───────────────────────────────────────────────────────────────────────────── -// useSectionForm — generic GET+PUT+ETag+conflict hook for the three cards above -// ───────────────────────────────────────────────────────────────────────────── - -type FormUi = { - loading: boolean; - data: SettingsSectionResponse; - form: T; - set: (next: T) => void; - isEnvLocked: (k: string) => boolean; - save: (payload: unknown) => void; - errors: string[] | null; - dialog: React.ReactNode; -}; - -function useSectionForm(section: string, fallback: T): FormUi | { loading: true } & Partial> { - const queryClient = useQueryClient(); - const [conflict, setConflict] = useState | null>(null); - const [errors, setErrors] = useState(null); - const pendingPayloadRef = useRef(null); - - const { data, isLoading } = useQuery({ - queryKey: ['admin-settings', section], - queryFn: () => adminSettings.getSection(section), - }); - - const [form, setForm] = useState(fallback); - useEffect(() => { if (data) setForm(data.payload); }, [data]); - - const isEnvLocked = (key: string) => { - const src = data?.effectiveSource[key]; - return src === 'env' || src === 'cli'; - }; - - const saveMutation = useMutation({ - mutationFn: async (payload: unknown) => { - setErrors(null); - if (!data) throw new Error('No section snapshot loaded yet.'); - pendingPayloadRef.current = payload; - return adminSettings.putSection(section, payload, data.etag); - }, - onSuccess: (fresh) => { - pendingPayloadRef.current = null; - queryClient.setQueryData(['admin-settings', section], fresh); - queryClient.invalidateQueries({ queryKey: ['admin-settings', 'status'] }); - }, - onError: (err: unknown) => { - if (err instanceof SettingsApiError && err.status === 412 && err.body?.current) { - setConflict(err.body.current as SettingsSectionResponse); - return; - } - pendingPayloadRef.current = null; - if (err instanceof SettingsApiError && err.status === 400 && err.body?.errors) { - setErrors(err.body.errors.map((e) => { - const fields = e.fields?.length ? `${e.fields.join(', ')}: ` : ''; - return `${fields}${e.message ?? JSON.stringify(e)}`; - })); - return; - } - setErrors([err instanceof Error ? err.message : String(err)]); - }, - }); - - if (isLoading || !data) { - return { loading: true }; - } - - const dialog = ( - { - if (!conflict) return; - const retryPayload = pendingPayloadRef.current ?? form; - queryClient.setQueryData(['admin-settings', section], conflict); - setConflict(null); - adminSettings.putSection(section, retryPayload, conflict.etag) - .then((fresh) => queryClient.setQueryData(['admin-settings', section], fresh)) - .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])) - .finally(() => { pendingPayloadRef.current = null; }); - }} - onTakeTheirs={() => { - if (!conflict) return; - queryClient.setQueryData(['admin-settings', section], conflict); - setConflict(null); - }} - onCancel={() => setConflict(null)} - /> - ); - - return { - loading: false, - data, - form, - set: setForm, - isEnvLocked, - save: (payload: unknown) => saveMutation.mutate(payload), - errors, - dialog, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared UI widgets (kept local to this section; could be hoisted later if reused) +// Local UI widgets. Card and ErrorsAndSave deliberately do NOT come from +// SectionFormHelpers: this tab's cards are the tighter p-4/mt-4 variant, and +// swapping in the shared ones would re-chrome all three cards. // ───────────────────────────────────────────────────────────────────────────── function Card({ icon: Icon, title, children }: Readonly<{ icon: React.ComponentType<{ size?: number }>; title: string; children: React.ReactNode }>) { diff --git a/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx b/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx index d4a784fe..8afc147a 100644 --- a/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx +++ b/src/nodepilot-ui/src/components/admin-settings/SectionFormHelpers.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronRight, Chip, FlashFilled } from '@carbon/icons-react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { @@ -31,6 +31,9 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l const queryClient = useQueryClient(); const [conflict, setConflict] = useState | null>(null); const [errors, setErrors] = useState(null); + // What the Save button actually PUT. "Keep mine" after a 412 has to re-send exactly that + // (the PascalCase DTO the card mapped), not the raw camelCase form state. + const pendingPayloadRef = useRef(null); const { data, isLoading } = useQuery({ queryKey: ['admin-settings', section], @@ -49,9 +52,11 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l mutationFn: async (payload: unknown) => { setErrors(null); if (!data) throw new Error('No section snapshot loaded yet.'); + pendingPayloadRef.current = payload; return adminSettings.putSection(section, payload, data.etag); }, onSuccess: (fresh) => { + pendingPayloadRef.current = null; queryClient.setQueryData(['admin-settings', section], fresh); queryClient.invalidateQueries({ queryKey: ['admin-settings', 'status'] }); // These sections drive the visibility of the AI entry points (buttons + AI-Chat nav) — @@ -64,6 +69,7 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l setConflict(err.body.current as SettingsSectionResponse); return; } + pendingPayloadRef.current = null; if (err instanceof SettingsApiError && err.status === 400 && err.body?.errors) { setErrors(err.body.errors.map((e) => { const fields = e.fields?.length ? `${e.fields.join(', ')}: ` : ''; @@ -86,14 +92,16 @@ export function useSectionForm(section: string, fallback: T): FormUi | { l localDraft={form} onKeepMine={() => { if (!conflict) return; + const retryPayload = pendingPayloadRef.current ?? form; queryClient.setQueryData(['admin-settings', section], conflict); setConflict(null); - adminSettings.putSection(section, form, conflict.etag) + adminSettings.putSection(section, retryPayload, conflict.etag) .then((fresh) => { queryClient.setQueryData(['admin-settings', section], fresh); if (section === 'AiKnowledge' || section === 'Llm') refreshAiCapabilities(queryClient); }) - .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])); + .catch((e: unknown) => setErrors([e instanceof Error ? e.message : String(e)])) + .finally(() => { pendingPayloadRef.current = null; }); }} onTakeTheirs={() => { if (!conflict) return; diff --git a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx index 747f4df7..c64724f3 100644 --- a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx +++ b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx @@ -26,80 +26,24 @@ import { hashDefinition, buildChangelog, assembleSelectiveDefinition, type Workf import { Markdown } from '../common/Markdown'; import { CopyButton } from '../common/CopyButton'; import { UsageFooter } from './UsageFooter'; +import { ChatThreadMenu } from './ChatThreadMenu'; import { useAiChatStore, aiChatScopeKey, aiChatFullKey, type ChatMessage, type ChatThreadMeta } from '../../stores/aiChatStore'; import { useAuthStore } from '../../stores/authStore'; import { buildChatMarkdown, chatFilenameSlug, downloadTextFile } from '../../lib/chatExport'; +import { + addToolCallToLast, + appendToLastAssistant, + finalizeStreaming, + isAbort, + markToolDoneOnLast, + patchLastAssistant, + trimHistory, +} from '../../lib/chatMessages'; import { formatDate } from '../../lib/format'; const EMPTY_THREAD: ChatMessage[] = []; const EMPTY_THREADS: ChatThreadMeta[] = []; const SUGGESTION_KEYS = ['suggestion1', 'suggestion2', 'suggestion3', 'suggestion4'] as const; -// The backend caps history at 20 turns / 50k characters (AiChatController) → trim hard here, -// otherwise long threads get a 400 HISTORY_TOO_LONG response. -const MAX_HISTORY_TURNS = 19; -const MAX_HISTORY_CHARS = 48_000; - -function isAbort(err: unknown): boolean { - return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; -} - -/** Trims the history sent to the backend down to its caps (most recent turns, ≤ character limit). */ -function trimHistory(history: AiChatTurn[]): AiChatTurn[] { - let turns = history.slice(-MAX_HISTORY_TURNS); - let total = turns.reduce((s, m) => s + m.content.length, 0); - while (turns.length > 0 && total > MAX_HISTORY_CHARS) { - total -= turns[0].content.length; - turns = turns.slice(1); - } - return turns; -} - -/** Appends text to the last assistant message (immutably). */ -function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } - } - return next; -} - -/** Patches the last assistant message (building/proposal/meta) immutably. */ -function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } - } - return next; -} - -/** Appends an in-progress tool call to the last assistant message. */ -function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; - break; - } - } - return next; -} - -/** Marks a tool call on the last assistant message as completed. */ -function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; - break; - } - } - return next; -} - -/** Marks all assistant messages as done (streaming/building=false). */ -function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { - return prev.map((m) => (m.streaming || m.building ? { ...m, streaming: false, building: false } : m)); -} interface Props { workflowId: string | undefined; @@ -367,7 +311,7 @@ export function AiWorkflowChatPanel({
- { createThread(scope, t('ai:chat.threadDefault', { n: threads.length + 1 })); setError(null); }} onRename={(id, name) => renameThread(scope, id, name)} onDelete={(id) => { removeThread(scope, id); setError(null); }} + triggerClassName="flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-sm font-headline font-bold text-on-surface hover:bg-surface-high" + align="left" />
@@ -754,124 +700,6 @@ function ActivityMenu({ workflowId }: Readonly<{ workflowId: string }>) { ); } -/** Compact thread switcher in the header: active chat name + dropdown (switch/rename/delete/new). */ -function ThreadMenu({ - threads, activeId, disabled, onSelect, onNew, onRename, onDelete, -}: Readonly<{ - threads: ChatThreadMeta[]; - activeId: string; - disabled?: boolean; - onSelect: (id: string) => void; - onNew: () => void; - onRename: (id: string, name: string) => void; - onDelete: (id: string) => void; -}>) { - const { t } = useTranslation(['ai']); - const [open, setOpen] = useState(false); - const [renaming, setRenaming] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const ref = useRef(null); - - useEffect(() => { - if (!open) return; - const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as globalThis.Node)) { setOpen(false); setRenaming(null); } - }; - document.addEventListener('mousedown', onDoc); - return () => document.removeEventListener('mousedown', onDoc); - }, [open]); - - const active = threads.find((th) => th.id === activeId); - const activeName = active?.name ?? t('ai:chat.threadDefault', { n: 1 }); - - const commitRename = (id: string) => { - const name = renameValue.trim(); - if (name) onRename(id, name); - setRenaming(null); - }; - - return ( -
- - {open && ( -
-
- {threads.length === 0 && ( -

{t('ai:chat.noThreads')}

- )} - {threads.map((th) => ( -
- {renaming === th.id ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename(th.id); - if (e.key === 'Escape') setRenaming(null); - }} - onBlur={() => commitRename(th.id)} - className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" - /> - ) : ( - - )} - - -
- ))} -
- -
- )} -
- ); -} - function ProposalCard({ proposal, baseDef, canApply, isViewer, getCurrentDefinition, applyDefinition, onApplied, onRefine, onUndo, onAutoLayout, }: Readonly<{ diff --git a/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx b/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx new file mode 100644 index 00000000..f8f492d6 --- /dev/null +++ b/src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx @@ -0,0 +1,134 @@ +import { Add, ChevronDown, Edit, TrashCan } from '@carbon/icons-react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ChatThreadMeta } from '../../stores/aiChatStore'; + +/** + * Compact thread switcher: active chat name + dropdown (switch/rename/delete/new). Shared by the + * docked workflow assistant and the global AI-Chat page, which only differ in how the trigger is + * chromed and on which side the panel is anchored — both are passed in verbatim so each surface + * keeps its exact look. `align` is mapped to a literal utility class (never interpolated) so + * Tailwind's source scan still sees both variants. + */ +export function ChatThreadMenu({ + threads, activeId, disabled, onSelect, onNew, onRename, onDelete, triggerClassName, align, +}: Readonly<{ + threads: ChatThreadMeta[]; + activeId: string; + disabled?: boolean; + onSelect: (id: string) => void; + onNew: () => void; + onRename: (id: string, name: string) => void; + onDelete: (id: string) => void; + /** Full class string for the trigger button — the two call sites are chromed differently. */ + triggerClassName: string; + /** Which edge of the trigger the dropdown is anchored to. */ + align: 'left' | 'right'; +}>) { + const { t } = useTranslation(['ai']); + const [open, setOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as globalThis.Node)) { setOpen(false); setRenaming(null); } + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open]); + + const active = threads.find((th) => th.id === activeId); + const activeName = active?.name ?? t('ai:chat.threadDefault', { n: 1 }); + + const commitRename = (id: string) => { + const name = renameValue.trim(); + if (name) onRename(id, name); + setRenaming(null); + }; + + const alignClass = align === 'right' ? 'right-0' : 'left-0'; + + return ( +
+ + {open && ( +
+
+ {threads.length === 0 && ( +

{t('ai:chat.noThreads')}

+ )} + {threads.map((th) => ( +
+ {renaming === th.id ? ( + setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(th.id); + if (e.key === 'Escape') setRenaming(null); + }} + onBlur={() => commitRename(th.id)} + className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" + /> + ) : ( + + )} + + +
+ ))} +
+ +
+ )} +
+ ); +} diff --git a/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx b/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx index fe3b7508..91265a39 100644 --- a/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx +++ b/src/nodepilot-ui/src/components/common/ContextMenuShell.tsx @@ -9,6 +9,8 @@ interface ContextMenuShellProps { positioning?: 'absolute' | 'fixed'; /** Tailwind z-index utility (e.g. "z-30", "z-50"). */ zIndex?: string; + /** Tailwind min-width utility — the node menu is one step narrower than the rest. */ + minWidth?: string; /** data-testid forwarded to the outer div. */ testId?: string; children: ReactNode; @@ -20,7 +22,7 @@ interface ContextMenuShellProps { * (use the sibling MenuItem helper for consistent styling). */ export function ContextMenuShell({ - x, y, onClose, positioning = 'absolute', zIndex = 'z-30', testId, children, + x, y, onClose, positioning = 'absolute', zIndex = 'z-30', minWidth = 'min-w-[180px]', testId, children, }: Readonly) { const menuRef = useRef(null); @@ -42,7 +44,7 @@ export function ContextMenuShell({ return (
diff --git a/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx new file mode 100644 index 00000000..34429d95 --- /dev/null +++ b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx @@ -0,0 +1,82 @@ +import { Close, SecurityServices } from '@carbon/icons-react'; +import { useTranslation } from 'react-i18next'; + +/** + * Type-the-phrase confirmation for the two places that grant write access to the SQL console: + * flipping `DbAdmin:AllowWriteQueries` on (admin settings) and running a write statement from + * the query pane. Confirm stays disabled until the typed input matches `phrase` exactly. + * All wording is passed in already translated — the two call sites live in different i18n + * namespaces. + */ +export function TypedPhraseConfirmDialog({ + phrase, input, onInput, onCancel, onConfirm, title, body, prompt, confirmLabel, +}: Readonly<{ + phrase: string; + input: string; + onInput: (v: string) => void; + onCancel: () => void; + onConfirm: () => void; + title: string; + body: string; + /** Instruction line — the caller interpolates `phrase` into its own key. */ + prompt: string; + confirmLabel: string; +}>) { + const { t } = useTranslation(['common']); + const ok = input === phrase; + + return ( +
e.key === 'Escape' && onCancel()} + role="presentation" + tabIndex={-1} + > +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > +
+

+ + {title} +

+ +
+

+ {body} +

+

+ {prompt} +

+ onInput(e.target.value)} + autoFocus + className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" + /> +
+ + +
+
+
+ ); +} diff --git a/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx b/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx index 33612edd..7d4d2ce8 100644 --- a/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx +++ b/src/nodepilot-ui/src/components/dbviewer/QueryPane.tsx @@ -1,4 +1,4 @@ -import { Close, DataBase, History, Play, SecurityServices, WarningAltFilled } from '@carbon/icons-react'; +import { DataBase, History, Play, SecurityServices, WarningAltFilled } from '@carbon/icons-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -9,6 +9,7 @@ import { keymap } from '@codemirror/view'; import { nodePilotCodeMirrorTheme } from '../../lib/codeMirrorTheme'; import { dbAdminApi, type DbAdminQueryResponse } from '../../api/dbadmin'; import { useThemeStore, resolveTheme } from '../../stores/themeStore'; +import { TypedPhraseConfirmDialog } from '../common/TypedPhraseConfirmDialog'; import { ResizeHandle, useResizableColumns, type ResizableColumn } from './useResizableColumns'; const HISTORY_KEY = 'nodepilot.dbAdmin.queryHistory'; @@ -335,7 +336,7 @@ export function QueryPane({ insertSignal }: Readonly) { )}
{showWriteDialog && ( - ) { setWriteConfirmInput(''); queryMutation.mutate({ sql: sql.trim(), mode: 'write' }); }} + title={t('database:query.confirmWriteTitle')} + body={t('database:query.confirmWriteHint')} + prompt={t('database:query.confirmWritePhrasePrompt', { phrase: WRITE_CONFIRM_PHRASE })} + confirmLabel={t('database:query.confirmWriteButton')} /> )}
@@ -431,71 +436,3 @@ function renderCell(value: unknown): React.ReactNode { if (s.length > 80) return {s.slice(0, 80)}…; return s; } - -function WriteConfirmDialog({ - phrase, input, onInput, onCancel, onConfirm, -}: Readonly<{ - phrase: string; - input: string; - onInput: (v: string) => void; - onCancel: () => void; - onConfirm: () => void; -}>) { - const { t } = useTranslation(['database', 'common']); - const ok = input === phrase; - - return ( -
e.key === 'Escape' && onCancel()} - role="presentation" - tabIndex={-1} - > -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - role="presentation" - > -
-

- - {t('database:query.confirmWriteTitle')} -

- -
-

- {t('database:query.confirmWriteHint')} -

-

- {t('database:query.confirmWritePhrasePrompt', { phrase })} -

- onInput(e.target.value)} - autoFocus - className="w-full px-3 py-2 border border-outline-variant rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-500" - /> -
- - -
-
-
- ); -} diff --git a/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx b/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx index 9f05c25e..e69beb40 100644 --- a/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx +++ b/src/nodepilot-ui/src/components/designer/PropertiesPanel.tsx @@ -35,9 +35,6 @@ import { StepTestPanel } from './properties/StepTestPanel'; import { JsonPathTree } from './properties/JsonPathTree'; import { useDesignStore } from '../../stores/designStore'; -// Re-export truth-tables so existing import sites keep working. -export { REMOTE_ACTIVITY_TYPES, TIMEOUT_ACTIVITY_TYPES }; - interface Props { node: Node; allNodes: Node[]; diff --git a/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx b/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx index a563fe33..9d23fbc0 100644 --- a/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx +++ b/src/nodepilot-ui/src/components/designer/overlays/NodeContextMenu.tsx @@ -1,6 +1,6 @@ import { CircleStroke, Copy, TrashCan, View, ViewOff } from '@carbon/icons-react'; -import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { ContextMenuShell, ContextMenuItem, makeMenuAction } from '../../common/ContextMenuShell'; import { useDesignStore } from '../../../stores/designStore'; interface Props { @@ -18,57 +18,23 @@ interface Props { export function NodeContextMenu({ x, y, isDisabled, hasBreakpoint, onDuplicate, onToggleDisabled, onToggleBreakpoint, onDelete, onClose }: Readonly) { const { t } = useTranslation('editor'); const expertMode = useDesignStore((s) => s.designerMode === 'expert'); - const menuRef = useRef(null); - - useEffect(() => { - const handleMouseDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [onClose]); - - const action = (fn: () => void) => () => { fn(); onClose(); }; + const action = makeMenuAction(onClose); return ( -
- } label={t('nodeMenu.duplicate')} onClick={action(onDuplicate)} /> - + } label={t('nodeMenu.duplicate')} onClick={action(onDuplicate)} /> + : } label={isDisabled ? t('nodeMenu.enableStep') : t('nodeMenu.disableStep')} onClick={action(onToggleDisabled)} /> - {expertMode && } label={hasBreakpoint ? t('nodeMenu.removeBreakpoint') : t('nodeMenu.addBreakpoint')} onClick={action(onToggleBreakpoint)} />}
- } label={t('nodeMenu.delete')} onClick={action(onDelete)} danger /> -
- ); -} - -function MenuItem({ icon, label, onClick, danger }: Readonly<{ icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }>) { - return ( - + } label={t('nodeMenu.delete')} onClick={action(onDelete)} danger /> + ); } diff --git a/src/nodepilot-ui/src/components/designer/properties/shared.tsx b/src/nodepilot-ui/src/components/designer/properties/shared.tsx index 02dc2a55..b9e991ce 100644 --- a/src/nodepilot-ui/src/components/designer/properties/shared.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/shared.tsx @@ -322,17 +322,12 @@ export function VariableInsertField({ } /** - * Compact variable picker — a single button that opens a searchable popover - * instead of rendering every upstream variable as a chip inline. Keeps per-field - * UI small even when many upstream variables exist. + * Open/close + search state shared by the field pickers below: outside-click and Escape close the + * popover and clear the query, and the search box autofocuses once the popover has mounted. + * The query lives here (not inside {@link PickerPopover}) so each picker can filter with a plain + * top-level `useMemo`. */ -export function VariablePicker({ - upstreamVars, onPick, -}: Readonly<{ - upstreamVars: UpstreamVariable[]; - onPick: (expression: string) => void; -}>) { - const { t } = useTranslation(['properties', 'common']); +function useSearchablePicker() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const containerRef = useRef(null); @@ -361,81 +356,133 @@ export function VariablePicker({ }; }, [open]); - // Group by step for readability; filter by query (matches expression or label) - const { groups, total } = useMemo(() => { - const q = query.trim().toLowerCase(); - const filtered = q - ? upstreamVars.filter((v) => v.expression.toLowerCase().includes(q) || v.label.toLowerCase().includes(q)) - : upstreamVars; - const byStep = new Map(); - for (const v of filtered) { - const baseLabel = v.label.split(' → ')[0]; - if (!byStep.has(baseLabel)) byStep.set(baseLabel, []); - byStep.get(baseLabel)!.push(v); - } - return { groups: [...byStep.entries()], total: filtered.length }; - }, [upstreamVars, query]); + const toggle = useCallback(() => setOpen((o) => !o), []); + const close = useCallback(() => { setOpen(false); setQuery(''); }, []); + return { open, toggle, close, query, setQuery, containerRef, popoverRef, searchRef }; +} + +/** Chip trigger + anchored popover + search box. `children` renders the (already filtered) rows. */ +function PickerPopover({ + picker, icon, chipLabel, count, title, placeholder, surfaceClass, children, +}: Readonly<{ + picker: ReturnType; + icon: React.ReactNode; + chipLabel: string; + count: number; + title: string; + placeholder: string; + surfaceClass?: string; + children: React.ReactNode; +}>) { return ( -
+
- +
setQuery(e.target.value)} - placeholder={t('properties:searchVariable')} + value={picker.query} + onChange={(e) => picker.setQuery(e.target.value)} + placeholder={placeholder} className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" />
- {total === 0 && ( -
{t('common:noResults')}
- )} - {groups.map(([stepLabel, items]) => ( -
-
- {stepLabel} -
- {items.map((v) => { - const suffix = v.label.includes(' → ') ? v.label.split(' → ')[1] : ''; - return ( - - ); - })} -
- ))} + {children}
); } +/** + * Compact variable picker — a single button that opens a searchable popover + * instead of rendering every upstream variable as a chip inline. Keeps per-field + * UI small even when many upstream variables exist. + */ +export function VariablePicker({ + upstreamVars, onPick, +}: Readonly<{ + upstreamVars: UpstreamVariable[]; + onPick: (expression: string) => void; +}>) { + const { t } = useTranslation(['properties', 'common']); + const picker = useSearchablePicker(); + + // Group by step for readability; filter by query (matches expression or label) + const { groups, total } = useMemo(() => { + const q = picker.query.trim().toLowerCase(); + const filtered = q + ? upstreamVars.filter((v) => v.expression.toLowerCase().includes(q) || v.label.toLowerCase().includes(q)) + : upstreamVars; + const byStep = new Map(); + for (const v of filtered) { + const baseLabel = v.label.split(' → ')[0]; + if (!byStep.has(baseLabel)) byStep.set(baseLabel, []); + byStep.get(baseLabel)!.push(v); + } + return { groups: [...byStep.entries()], total: filtered.length }; + }, [upstreamVars, picker.query]); + + return ( + } + chipLabel={t('properties:vars')} + count={upstreamVars.length} + title={t('properties:varsTooltip', { count: upstreamVars.length })} + placeholder={t('properties:searchVariable')} + > + {total === 0 && ( +
{t('common:noResults')}
+ )} + {groups.map(([stepLabel, items]) => ( +
+
+ {stepLabel} +
+ {items.map((v) => { + const suffix = v.label.includes(' → ') ? v.label.split(' → ')[1] : ''; + return ( + + ); + })} +
+ ))} +
+ ); +} + /** * Picker for admin-managed global variables — inserts {{globals.NAME}}. Renders the * button even when no globals exist (the popover then shows an empty-state hint with a link @@ -444,11 +491,7 @@ export function VariablePicker({ */ export function GlobalVariablePicker({ onPick }: Readonly<{ onPick: (expression: string) => void }>) { const { t } = useTranslation(['properties', 'common']); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const containerRef = useRef(null); - const popoverRef = useRef(null); - const searchRef = useRef(null); + const picker = useSearchablePicker(); const { data: globals = [], isLoading } = useQuery({ queryKey: ['global-variables'], @@ -457,98 +500,58 @@ export function GlobalVariablePicker({ onPick }: Readonly<{ onPick: (expression: staleTime: 60_000, }); - useEffect(() => { - if (!open) return; - const onClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (containerRef.current && !containerRef.current.contains(target) && !popoverRef.current?.contains(target)) { - setOpen(false); setQuery(''); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { setOpen(false); setQuery(''); } - }; - document.addEventListener('mousedown', onClickOutside); - document.addEventListener('keydown', onKey); - requestAnimationFrame(() => searchRef.current?.focus()); - return () => { - document.removeEventListener('mousedown', onClickOutside); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = picker.query.trim().toLowerCase(); if (!q) return globals; return globals.filter((g) => g.name.toLowerCase().includes(q) || (g.description?.toLowerCase().includes(q) ?? false)); - }, [globals, query]); + }, [globals, picker.query]); return ( -
- - -
-
- - setQuery(e.target.value)} - placeholder={t('properties:searchGlobalVariable')} - className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" - /> -
-
-
- {isLoading && ( -
{t('common:loading')}
- )} - {!isLoading && globals.length === 0 && ( -
- {t('properties:noGlobalsHint')} -
- )} - {!isLoading && globals.length > 0 && filtered.length === 0 && ( -
{t('common:noResults')}
+ } + chipLabel={t('properties:globals')} + count={globals.length} + title={t('properties:globalsTooltip', { count: globals.length })} + placeholder={t('properties:searchGlobalVariable')} + > + {isLoading && ( +
{t('common:loading')}
+ )} + {!isLoading && globals.length === 0 && ( +
+ {t('properties:noGlobalsHint')} +
+ )} + {!isLoading && globals.length > 0 && filtered.length === 0 && ( +
{t('common:noResults')}
+ )} + {filtered.map((g) => { + const expression = `{{globals.${g.name}}}`; + return ( + - ); - })} -
-
-
+ + ); + })} + ); } @@ -622,87 +625,39 @@ export function OptionsPicker({ label: string; }>) { const { t } = useTranslation(['properties', 'common']); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(''); - const containerRef = useRef(null); - const popoverRef = useRef(null); - const searchRef = useRef(null); - - useEffect(() => { - if (!open) return; - const onClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (containerRef.current && !containerRef.current.contains(target) && !popoverRef.current?.contains(target)) { - setOpen(false); setQuery(''); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { setOpen(false); setQuery(''); } - }; - document.addEventListener('mousedown', onClickOutside); - document.addEventListener('keydown', onKey); - requestAnimationFrame(() => searchRef.current?.focus()); - return () => { - document.removeEventListener('mousedown', onClickOutside); - document.removeEventListener('keydown', onKey); - }; - }, [open]); + const picker = useSearchablePicker(); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); + const q = picker.query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.label.toLowerCase().includes(q) || o.id.toLowerCase().includes(q)); - }, [options, query]); + }, [options, picker.query]); return ( -
- - -
-
- - setQuery(e.target.value)} - placeholder={t('common:searchEllipsis')} - className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" - /> -
-
-
- {filtered.length === 0 && ( -
{t('common:noResults')}
- )} - {filtered.map((o) => ( - - ))} -
-
-
+ } + chipLabel={t('properties:list')} + count={options.length} + title={t('properties:listTooltip', { label, count: options.length })} + placeholder={t('common:searchEllipsis')} + surfaceClass="bg-surface-container border-outline-variant/30" + > + {filtered.length === 0 && ( +
{t('common:noResults')}
+ )} + {filtered.map((o) => ( + + ))} +
); } diff --git a/src/nodepilot-ui/src/hooks/useDashboardFeed.ts b/src/nodepilot-ui/src/hooks/useDashboardFeed.ts index 422d8187..673b954b 100644 --- a/src/nodepilot-ui/src/hooks/useDashboardFeed.ts +++ b/src/nodepilot-ui/src/hooks/useDashboardFeed.ts @@ -1,90 +1,17 @@ -import { useEffect, useRef } from 'react'; -import * as signalR from '@microsoft/signalr'; -import { useQueryClient } from '@tanstack/react-query'; -import { readCsrfToken } from '../api/csrf'; -import { connectPersistently } from '../lib/signalrConnect'; +import { useLiveOpsFeed } from './useLiveOpsFeed'; -// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about -// ExecutionStatusChanged — it is the only event that changes dashboard aggregates -// (running/recent/queue-depth/counts). StepStarted/StepCompleted don't move KPIs. -interface StatusEvt { - executionId?: string; ExecutionId?: string; - workflowId?: string; WorkflowId?: string; - status?: string; Status?: string; -} - -function pickItems(batch: unknown): unknown[] { - if (Array.isArray(batch)) return batch; - const b = batch as { events?: unknown[]; Events?: unknown[] } | null; - return b?.events ?? b?.Events ?? []; -} - -function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { - const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; - const type = it.Type ?? it.type; - if (type !== 'ExecutionStatusChanged') return null; - const e = it.Event ?? it.evt; - if (!e) return null; - const executionId = e.executionId ?? e.ExecutionId; - const workflowId = e.workflowId ?? e.WorkflowId; - const status = e.status ?? e.Status; - if (!executionId || !workflowId || !status) return null; - return { executionId, workflowId, status }; -} +// Module-level so the identity is stable across renders — it feeds useLiveOpsFeed's effect deps. +const DASHBOARD_STATS_KEY = ['dashboard-stats']; /** * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub and debounce- * invalidates the dashboard-stats query so running/recent/queue KPIs reconcile in ~real * time instead of waiting for the 120 s polling fallback. Mirrors useOperationsFeed but * targets ['dashboard-stats'] (the dashboard's own cache key) rather than the operations - * graph. SignalR failures are swallowed — the page still works off the polled snapshot. + * graph. Any status transition (start/terminal) can move running/recent/queue/counts, so + * no per-event delta is applied — one debounced refetch covers the burst. */ export function useDashboardFeed() { - const queryClient = useQueryClient(); - const invalidateTimer = useRef | null>(null); - - useEffect(() => { - let disposed = false; - const connection = new signalR.HubConnectionBuilder() - .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) - .withAutomaticReconnect() - .configureLogging(signalR.LogLevel.Warning) - .build(); - - const scheduleInvalidate = () => { - if (invalidateTimer.current !== null) return; - invalidateTimer.current = setTimeout(() => { - invalidateTimer.current = null; - queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] }); - }, 500); - }; - - connection.on('LiveEventsBatch', (batch: unknown) => { - let sawStatus = false; - for (const item of pickItems(batch)) { - const s = asStatus(item); - if (!s) continue; - sawStatus = true; - } - // Any status transition (start/terminal) can move running/recent/queue/counts — - // one debounced refetch covers a burst of events instead of N refetches. - if (sawStatus) scheduleInvalidate(); - }); - - const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; - // connectPersistently retries forever with capped backoff: the bare onreconnected + - // one-shot start() gave up for good after ~40 s of outage (and never retried a failed - // FIRST start at all), silently degrading this feed to snapshot polling until a reload. - const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); - - return () => { - disposed = true; - persistent.dispose(); - if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); - connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); - void connection.stop(); - }; - }, [queryClient]); - + useLiveOpsFeed({ queryKey: DASHBOARD_STATS_KEY, debounceMs: 500 }); return null; -} \ No newline at end of file +} diff --git a/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts b/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts new file mode 100644 index 00000000..f703af8c --- /dev/null +++ b/src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react'; +import * as signalR from '@microsoft/signalr'; +import { useQueryClient } from '@tanstack/react-query'; +import { readCsrfToken } from '../api/csrf'; +import { connectPersistently } from '../lib/signalrConnect'; + +// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about ExecutionStatusChanged. +interface StatusEvt { + executionId?: string; ExecutionId?: string; + workflowId?: string; WorkflowId?: string; + status?: string; Status?: string; +} + +function pickItems(batch: unknown): unknown[] { + if (Array.isArray(batch)) return batch; + const b = batch as { events?: unknown[]; Events?: unknown[] } | null; + return b?.events ?? b?.Events ?? []; +} + +function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { + const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; + const type = it.Type ?? it.type; + if (type !== 'ExecutionStatusChanged') return null; + const e = it.Event ?? it.evt; + if (!e) return null; + const executionId = e.executionId ?? e.ExecutionId; + const workflowId = e.workflowId ?? e.WorkflowId; + const status = e.status ?? e.Status; + if (!executionId || !workflowId || !status) return null; + return { executionId, workflowId, status }; +} + +/** + * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub and debounce- + * invalidates `queryKey` whenever a batch carried at least one ExecutionStatusChanged — + * one refetch per burst instead of N. SignalR failures are swallowed; the consuming page + * still works off its polled snapshot. + * + * `queryKey` and `onStatus` go into the effect deps, so both must be referentially stable + * (a module-level constant / a zustand action) — an inline array would tear the connection + * down and rebuild it on every render. + */ +export function useLiveOpsFeed({ + queryKey, debounceMs, onStatus, +}: Readonly<{ + queryKey: unknown[]; + debounceMs: number; + /** Optional per-event delta applied before the debounced snapshot reconciliation. */ + onStatus?: (executionId: string, workflowId: string, status: string) => void; +}>) { + const queryClient = useQueryClient(); + const invalidateTimer = useRef | null>(null); + + useEffect(() => { + let disposed = false; + const connection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + const scheduleInvalidate = () => { + if (invalidateTimer.current !== null) return; + invalidateTimer.current = setTimeout(() => { + invalidateTimer.current = null; + queryClient.invalidateQueries({ queryKey }); + }, debounceMs); + }; + + connection.on('LiveEventsBatch', (batch: unknown) => { + let sawStatus = false; + for (const item of pickItems(batch)) { + const s = asStatus(item); + if (!s) continue; + onStatus?.(s.executionId, s.workflowId, s.status); + sawStatus = true; + } + if (sawStatus) scheduleInvalidate(); + }); + + const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; + // connectPersistently retries forever with capped backoff: the bare onreconnected + + // one-shot start() gave up for good after ~40 s of outage (and never retried a failed + // FIRST start at all), silently degrading this feed to snapshot polling until a reload. + const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); + + return () => { + disposed = true; + persistent.dispose(); + if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); + connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); + void connection.stop(); + }; + }, [queryClient, queryKey, debounceMs, onStatus]); +} diff --git a/src/nodepilot-ui/src/hooks/useOperationsFeed.ts b/src/nodepilot-ui/src/hooks/useOperationsFeed.ts index b6098732..ba75a592 100644 --- a/src/nodepilot-ui/src/hooks/useOperationsFeed.ts +++ b/src/nodepilot-ui/src/hooks/useOperationsFeed.ts @@ -1,35 +1,8 @@ -import { useEffect, useRef } from 'react'; -import * as signalR from '@microsoft/signalr'; -import { useQueryClient } from '@tanstack/react-query'; import { useOperationsStore } from '../stores/operationsStore'; -import { readCsrfToken } from '../api/csrf'; -import { connectPersistently } from '../lib/signalrConnect'; +import { useLiveOpsFeed } from './useLiveOpsFeed'; -// A LiveEventsBatch item is { Type|type, Event|evt }. We only care about ExecutionStatusChanged. -interface StatusEvt { - executionId?: string; ExecutionId?: string; - workflowId?: string; WorkflowId?: string; - status?: string; Status?: string; -} - -function pickItems(batch: unknown): unknown[] { - if (Array.isArray(batch)) return batch; - const b = batch as { events?: unknown[]; Events?: unknown[] } | null; - return b?.events ?? b?.Events ?? []; -} - -function asStatus(item: unknown): { executionId: string; workflowId: string; status: string } | null { - const it = item as { Type?: string; type?: string; Event?: StatusEvt; evt?: StatusEvt }; - const type = it.Type ?? it.type; - if (type !== 'ExecutionStatusChanged') return null; - const e = it.Event ?? it.evt; - if (!e) return null; - const executionId = e.executionId ?? e.ExecutionId; - const workflowId = e.workflowId ?? e.WorkflowId; - const status = e.status ?? e.Status; - if (!executionId || !workflowId || !status) return null; - return { executionId, workflowId, status }; -} +// Module-level so the identity is stable across renders — it feeds useLiveOpsFeed's effect deps. +const OPERATIONS_GRAPH_KEY = ['operations-graph']; /** * Subscribes to the RBAC-scoped live-ops feed on the shared execution hub. Applies @@ -39,49 +12,6 @@ function asStatus(item: unknown): { executionId: string; workflowId: string; sta * polled snapshot). */ export function useOperationsFeed() { - const queryClient = useQueryClient(); const applyStatus = useOperationsStore((s) => s.applyStatus); - const invalidateTimer = useRef | null>(null); - - useEffect(() => { - let disposed = false; - const connection = new signalR.HubConnectionBuilder() - .withUrl('/hubs/execution', { headers: { 'X-CSRF-Token': readCsrfToken() } }) - .withAutomaticReconnect() - .configureLogging(signalR.LogLevel.Warning) - .build(); - - const scheduleInvalidate = () => { - if (invalidateTimer.current !== null) return; - invalidateTimer.current = setTimeout(() => { - invalidateTimer.current = null; - queryClient.invalidateQueries({ queryKey: ['operations-graph'] }); - }, 800); - }; - - connection.on('LiveEventsBatch', (batch: unknown) => { - let sawStatus = false; - for (const item of pickItems(batch)) { - const s = asStatus(item); - if (!s) continue; - applyStatus(s.executionId, s.workflowId, s.status); - sawStatus = true; - } - if (sawStatus) scheduleInvalidate(); - }); - - const join = () => { connection.invoke('JoinOperationsFeed').catch(() => { /* RBAC reject / transient */ }); }; - // connectPersistently retries forever with capped backoff: the bare onreconnected + - // one-shot start() gave up for good after ~40 s of outage (and never retried a failed - // FIRST start at all), silently degrading this feed to snapshot polling until a reload. - const persistent = connectPersistently(connection, () => { if (!disposed) join(); }); - - return () => { - disposed = true; - persistent.dispose(); - if (invalidateTimer.current !== null) clearTimeout(invalidateTimer.current); - connection.invoke('LeaveOperationsFeed').catch(() => { /* ignore */ }); - void connection.stop(); - }; - }, [queryClient, applyStatus]); + useLiveOpsFeed({ queryKey: OPERATIONS_GRAPH_KEY, debounceMs: 800, onStatus: applyStatus }); } diff --git a/src/nodepilot-ui/src/lib/chatMessages.ts b/src/nodepilot-ui/src/lib/chatMessages.ts new file mode 100644 index 00000000..4108e2f7 --- /dev/null +++ b/src/nodepilot-ui/src/lib/chatMessages.ts @@ -0,0 +1,74 @@ +import type { AiChatTurn } from '../api/ai'; +import type { ChatMessage } from '../stores/aiChatStore'; + +// The backend caps history at 20 turns / 50k characters (AiChatController, AiKnowledgeController) +// → trim hard here, otherwise long threads get a 400 HISTORY_TOO_LONG response. +const MAX_HISTORY_TURNS = 19; +const MAX_HISTORY_CHARS = 48_000; + +/** True for a user-initiated stream abort — the partial bubble stays and no error is shown. */ +export function isAbort(err: unknown): boolean { + return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; +} + +/** Trims the history sent to the backend down to its caps (most recent turns, ≤ character limit). */ +export function trimHistory(history: AiChatTurn[]): AiChatTurn[] { + let turns = history.slice(-MAX_HISTORY_TURNS); + let total = turns.reduce((s, m) => s + m.content.length, 0); + while (turns.length > 0 && total > MAX_HISTORY_CHARS) { + total -= turns[0].content.length; + turns = turns.slice(1); + } + return turns; +} + +/** Appends text to the last assistant message (immutably). */ +export function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } + } + return next; +} + +/** Patches the last assistant message (building/proposal/meta) immutably. */ +export function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } + } + return next; +} + +/** Appends an in-progress tool call to the last assistant message. */ +export function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { + next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; + break; + } + } + return next; +} + +/** Marks a tool call on the last assistant message as completed. */ +export function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { + const next = prev.slice(); + for (let i = next.length - 1; i >= 0; i--) { + if (next[i].role === 'assistant') { + next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; + break; + } + } + return next; +} + +/** + * Marks all assistant messages as done (streaming/building=false). `building` only ever gets set + * by the designer panel (proposal buffering); for chats that never set it the extra clear is inert + * — and the store strips both flags before persisting anyway. + */ +export function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { + return prev.map((m) => (m.streaming || m.building ? { ...m, streaming: false, building: false } : m)); +} diff --git a/src/nodepilot-ui/src/lib/configClone.ts b/src/nodepilot-ui/src/lib/configClone.ts index 0948d0c9..5577fd68 100644 --- a/src/nodepilot-ui/src/lib/configClone.ts +++ b/src/nodepilot-ui/src/lib/configClone.ts @@ -32,19 +32,6 @@ export function skippedConfigKeys(activityType: string): ReadonlyArray { return [...SHARED_CONFIG_SKIP_KEYS, ...typeSpecific]; } -/** - * Back-compat alias for the older "include-list" API. Now returns an empty array when no - * skips are configured; callers should treat "no skips" as "every key is cloneable" rather - * than "nothing is cloneable" (the inverse semantics from before the cloning rules were - * relaxed to copy entire configs). - * - * @deprecated Read `skippedConfigKeys` directly — the include-list shape no longer maps onto - * the actual cloning behaviour. - */ -export function cloneableConfigKeys(activityType: string): ReadonlyArray { - return skippedConfigKeys(activityType); -} - /** * Returns true if `targetMachineId` + `credentialId` are meaningful for this activity. * Used to decide whether the clone-picker should offer cross-type Remote-→-Remote diff --git a/src/nodepilot-ui/src/pages/AiChatPage.tsx b/src/nodepilot-ui/src/pages/AiChatPage.tsx index d567a810..de9bf6f1 100644 --- a/src/nodepilot-ui/src/pages/AiChatPage.tsx +++ b/src/nodepilot-ui/src/pages/AiChatPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { - Add, BareMetalServer, ChartColumn, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code, - DataBase, DataShare, Debug, Document, Download, Edit, Email, Events, FlowModeler, InProgress, + BareMetalServer, ChartColumn, Chat, Checkbox, Checkmark, ChevronDown, CircleDash, Code, + DataBase, DataShare, Debug, Document, Download, Email, Events, FlowModeler, InProgress, Locked, Renew, Reset, Save, Send, Time, Tools, TrashCan, UserRole, WarningAlt, } from '@carbon/icons-react'; import { @@ -13,77 +13,28 @@ import { useAiCapabilities } from '../hooks/useAiCapabilities'; import { Markdown } from '../components/common/Markdown'; import { CopyButton } from '../components/common/CopyButton'; import { UsageFooter } from '../components/ai/UsageFooter'; +import { ChatThreadMenu } from '../components/ai/ChatThreadMenu'; import { useAiChatStore, aiChatScopeKey, aiChatFullKey, type ChatMessage, type ChatThreadMeta, } from '../stores/aiChatStore'; import { useAuthStore } from '../stores/authStore'; import { buildChatMarkdown, chatFilenameSlug, downloadTextFile } from '../lib/chatExport'; +import { + addToolCallToLast, + appendToLastAssistant, + finalizeStreaming, + isAbort, + markToolDoneOnLast, + patchLastAssistant, + trimHistory, +} from '../lib/chatMessages'; // A non-`__new__` sentinel workflowId so the store's `isPersistableScope` KEEPS this page's // threads across reloads (unlike an unsaved canvas). One shared scope per user. const GLOBAL_SCOPE = 'global'; const EMPTY_THREAD: ChatMessage[] = []; const EMPTY_THREADS: ChatThreadMeta[] = []; -// The backend caps history at 20 turns / 50k chars (AiKnowledgeController) → trim hard here. -const MAX_HISTORY_TURNS = 19; -const MAX_HISTORY_CHARS = 48_000; - -function isAbort(err: unknown): boolean { - return (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; -} - -function trimHistory(history: AiChatTurn[]): AiChatTurn[] { - let turns = history.slice(-MAX_HISTORY_TURNS); - let total = turns.reduce((s, m) => s + m.content.length, 0); - while (turns.length > 0 && total > MAX_HISTORY_CHARS) { - total -= turns[0].content.length; - turns = turns.slice(1); - } - return turns; -} - -function appendToLastAssistant(prev: ChatMessage[], text: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], content: next[i].content + text }; break; } - } - return next; -} - -function patchLastAssistant(prev: ChatMessage[], patch: Partial): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { next[i] = { ...next[i], ...patch }; break; } - } - return next; -} - -function addToolCallToLast(prev: ChatMessage[], toolId: string, toolName: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: [...(next[i].toolCalls ?? []), { toolId, toolName, done: false }] }; - break; - } - } - return next; -} - -function markToolDoneOnLast(prev: ChatMessage[], toolId: string): ChatMessage[] { - const next = prev.slice(); - for (let i = next.length - 1; i >= 0; i--) { - if (next[i].role === 'assistant') { - next[i] = { ...next[i], toolCalls: (next[i].toolCalls ?? []).map((tc) => (tc.toolId === toolId ? { ...tc, done: true } : tc)) }; - break; - } - } - return next; -} - -function finalizeStreaming(prev: ChatMessage[]): ChatMessage[] { - return prev.map((m) => (m.streaming ? { ...m, streaming: false } : m)); -} /** * Global "AI Chat" — a read-only knowledge & operations assistant over NodePilot's docs, @@ -281,7 +232,7 @@ export function AiChatPage() {
- { createThread(scope, t('ai:chat.threadDefault', { n: threads.length + 1 })); setError(null); }} onRename={(id, name) => renameThread(scope, id, name)} onDelete={(id) => { removeThread(scope, id); setError(null); }} - t={t} + triggerClassName="flex min-w-0 items-center gap-1 rounded-lg border border-outline-variant/40 px-2.5 py-1.5 text-sm text-on-surface transition-colors hover:bg-surface-highest" + align="right" /> - {open && ( -
-
- {threads.length === 0 && ( -

{t('ai:chat.noThreads')}

- )} - {threads.map((th) => ( -
- {renaming === th.id ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commitRename(th.id); - if (e.key === 'Escape') setRenaming(null); - }} - onBlur={() => commitRename(th.id)} - className="min-w-0 flex-1 rounded border border-outline-variant bg-surface-low px-1 py-0.5 text-xs text-on-surface" - /> - ) : ( - - )} - - -
- ))} -
- -
- )} -
- ); -} diff --git a/src/nodepilot-ui/src/stores/aiChatStore.ts b/src/nodepilot-ui/src/stores/aiChatStore.ts index a57ec664..34b5b5d5 100644 --- a/src/nodepilot-ui/src/stores/aiChatStore.ts +++ b/src/nodepilot-ui/src/stores/aiChatStore.ts @@ -49,8 +49,6 @@ const MAX_PERSISTED_MESSAGES = 200; */ const MAX_PERSISTED_PROPOSAL_CHARS = 100_000; -const EMPTY_MESSAGES: ChatMessage[] = []; - /** * Holds chat history **per user, workflow, and thread**. Unlike an earlier version, this * store is now `persist`-ed (survives a page reload), but privacy-conscious: `partialize` @@ -222,6 +220,3 @@ export const useAiChatStore = create()( }, ), ); - -/** Stable empty reference for selectors (avoids re-render loops). */ -export const aiChatEmptyMessages = EMPTY_MESSAGES; diff --git a/src/nodepilot-ui/src/stores/authStore.ts b/src/nodepilot-ui/src/stores/authStore.ts index f46eb5b8..ae1e824b 100644 --- a/src/nodepilot-ui/src/stores/authStore.ts +++ b/src/nodepilot-ui/src/stores/authStore.ts @@ -150,9 +150,3 @@ export const useAuthStore = create((set) => ({ }, })); -// Re-export a void helper so existing call sites that treat `isAuthenticated` as boolean -// get the right fallback when init hasn't completed yet (treat "unknown" as "not yet signed in" -// for guard purposes; ProtectedRoute renders a loader for null instead). -export function isAuthResolved(): boolean { - return useAuthStore.getState().isAuthenticated !== null; -} diff --git a/src/nodepilot-ui/src/telemetry/otel.ts b/src/nodepilot-ui/src/telemetry/otel.ts index 9a2ad335..49b108ab 100644 --- a/src/nodepilot-ui/src/telemetry/otel.ts +++ b/src/nodepilot-ui/src/telemetry/otel.ts @@ -70,10 +70,6 @@ export function initTelemetry(config: ObservabilityConfig): Tracer | null { return tracer; } -export function getTracer(): Tracer | null { - return tracer; -} - /** * Run a function inside a manually-named span. No-ops when telemetry is disabled. */ diff --git a/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs b/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs index e19b93a1..5ea27aff 100644 --- a/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmConnectGuardTests.cs @@ -158,15 +158,4 @@ public async Task ConnectAsync_ReachableEndpoint_LogsTheResolvedAddresses() logger.Messages.Should().Contain(m => m.Contains("TCP to")); } - private sealed class CapturingLogger : ILogger - { - public List Messages { get; } = new(); - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - => Messages.Add(formatter(state, exception)); - } } diff --git a/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs b/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs index 8bcecdc8..43363a3e 100644 --- a/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmUnreachableDiagnosticsTests.cs @@ -2,6 +2,7 @@ using System.Security.Authentication; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using NodePilot.TestCommons; using Xunit; namespace NodePilot.Ai.Tests; @@ -98,8 +99,4 @@ public void HandshakeTimeout_ExceedsTheConnectPhaseBudget() LlmConnectGuard.HandshakeTimeout.Should().BeGreaterThan(LlmConnectGuard.ConnectPhaseTimeout); } - private sealed class StubHttpClientFactory : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(); - } } diff --git a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs index 1fe54dbb..b7e51193 100644 --- a/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/AdminSettingsControllerSectionTests.cs @@ -1,6 +1,5 @@ using NodePilot.Ai; using System.Net; -using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -1567,118 +1566,4 @@ private static string ExtractFieldValue(string json, string key) var doc = JsonDocument.Parse(json); return doc.RootElement.GetProperty("Smtp").GetProperty(key).GetString()!; } - - private sealed class FakeSmtpServer : IAsyncDisposable - { - private readonly TcpListener _listener; - private readonly TaskCompletionSource _sessionTcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly CancellationTokenSource _cts = new(); - private Task? _acceptLoop; - - private FakeSmtpServer(TcpListener listener, int port) - { - _listener = listener; - Port = port; - } - - public int Port { get; } - - public static Task StartAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var server = new FakeSmtpServer(listener, port); - server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); - return Task.FromResult(server); - } - - public async Task AwaitSessionAsync(TimeSpan timeout) - { - var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); - if (completed != _sessionTcs.Task) - throw new TimeoutException("Fake SMTP server did not record a session in time."); - return await _sessionTcs.Task; - } - - private async Task AcceptAsync(CancellationToken ct) - { - try - { - using var client = await _listener.AcceptTcpClientAsync(ct); - await using var stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; - var session = new SmtpSession(); - - await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); - - while (!ct.IsCancellationRequested) - { - var line = await reader.ReadLineAsync(ct); - if (line is null) break; - - if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250-fake.smtp.test"); - await writer.WriteLineAsync("250-SIZE 10485760"); - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250 OK"); - } - else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("354 End data with ."); - while (true) - { - var dataLine = await reader.ReadLineAsync(ct); - if (dataLine is null || dataLine == ".") break; - } - session.DataReceived = true; - await writer.WriteLineAsync("250 OK message accepted"); - } - else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("221 Bye"); - break; - } - else - { - await writer.WriteLineAsync("250 OK"); - } - } - - _sessionTcs.TrySetResult(session); - } - catch (OperationCanceledException) - { - _sessionTcs.TrySetCanceled(); - } - catch (Exception ex) - { - _sessionTcs.TrySetException(ex); - } - } - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - try { _listener.Stop(); } catch { /* already stopped */ } - if (_acceptLoop is not null) - { - try { await _acceptLoop; } catch { /* shutdown noise */ } - } - _cts.Dispose(); - } - } - - private sealed class SmtpSession - { - public bool DataReceived { get; set; } - } } diff --git a/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs b/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs index 0e4292e5..c1d03e01 100644 --- a/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/ApiProblemsTests.cs @@ -23,40 +23,16 @@ private static ControllerBase Controller() return new TestController { ControllerContext = new ControllerContext { HttpContext = http } }; } - [Fact] - public void NotFound_ProducesProblemResultWithCodeAndStatus() - { - var result = ApiProblems.NotFound(Controller(), "THING_MISSING", "no such thing"); - result.StatusCode.Should().Be(StatusCodes.Status404NotFound); - var problem = result.Value.Should().BeOfType().Subject; - problem.Status.Should().Be(404); - problem.Detail.Should().Be("no such thing"); - problem.Extensions["code"].Should().Be("THING_MISSING"); - problem.Instance.Should().Be("/api/things/42"); - } - - [Fact] - public void Conflict_ProducesConflictProblem() - { - var result = ApiProblems.Conflict(Controller(), "DUP", "already exists"); - result.StatusCode.Should().Be(StatusCodes.Status409Conflict); - result.Value.Should().BeOfType().Which.Extensions["code"].Should().Be("DUP"); - } - - [Fact] - public void Unauthorized_ProducesUnauthorizedProblem() - { - var result = ApiProblems.Unauthorized(Controller(), "NO_TOKEN", "missing token"); - result.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - result.Value.Should().BeOfType().Which.Status.Should().Be(401); - } - [Fact] public void BadRequest_ProducesBadRequestProblem() { var result = ApiProblems.BadRequest(Controller(), "BAD", "nope"); result.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - result.Value.Should().BeOfType().Which.Extensions["code"].Should().Be("BAD"); + var problem = result.Value.Should().BeOfType().Subject; + problem.Status.Should().Be(400); + problem.Detail.Should().Be("nope"); + problem.Extensions["code"].Should().Be("BAD"); + problem.Instance.Should().Be("/api/things/42"); } // ---- legacy payload adapter -------------------------------------------- diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs index 7b3e990e..6730a19f 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientMaintenanceWindowsTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs index ed4b9048..b8e3d7bd 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientNewSurfaceTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs index 0e49cb43..b3121512 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientResourcesTests.cs @@ -6,6 +6,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs index e95f1aee..fbee5f34 100644 --- a/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/NodePilotApiClientTests.cs @@ -7,6 +7,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs index 7526958e..53ce9a15 100644 --- a/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/TokenRefreshHandlerTests.cs @@ -5,6 +5,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Api; diff --git a/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs b/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs index ab831102..c88dc619 100644 --- a/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs +++ b/tests/NodePilot.Cli.Tests/Auth/SessionResolverTests.cs @@ -3,6 +3,7 @@ using NodePilot.Cli.Settings; using NodePilot.Cli.Tests.Infra; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Auth; diff --git a/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs b/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs index 5b793594..9c048408 100644 --- a/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/BaseCommandTests.cs @@ -7,6 +7,7 @@ using NodePilot.Cli.Settings; using Spectre.Console.Cli; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Commands; diff --git a/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs b/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs index e9a1e071..a1fbbd87 100644 --- a/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs +++ b/tests/NodePilot.Cli.Tests/Infra/CommandTestHarness.cs @@ -6,6 +6,7 @@ using Spectre.Console.Cli.Testing; using Spectre.Console.Testing; using WireMock.Server; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Infra; diff --git a/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs b/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs index 3caf443f..d6fb8056 100644 --- a/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs +++ b/tests/NodePilot.Cli.Tests/Output/OutputAndRenderingTests.cs @@ -8,6 +8,7 @@ using Spectre.Console; using Spectre.Console.Testing; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Output; diff --git a/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs b/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs index 11b7ace9..1e9f0ed1 100644 --- a/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs +++ b/tests/NodePilot.Cli.Tests/Settings/ConfigStoreTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using NodePilot.Cli.Settings; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Tests.Settings; diff --git a/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs b/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs index d65bdf55..f11c9a54 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ActivityConfigReferenceTests.cs @@ -191,15 +191,23 @@ public void DocumentedKeys_HaveATypeAndADescription() public void SchemaVersion_IsCurrent() => ActivityConfigReference.SchemaVersion.Should().Be(2); /// - /// Maps activity/trigger type → the source text that must read its keys. For the background - /// triggers that is no longer the node executor alone: parsing moved into the shared - /// NodePilot.Core.Triggers settings, so the shared contract text is appended for every trigger - /// type. Without that this guard would flag every trigger key as phantom. + /// Maps activity/trigger type → the source text that must read its keys. Two kinds of key + /// reading happen outside the executor's own file, and both are followed here rather than + /// exempted — an exemption would stop verifying the key at all: + /// + /// Background triggers parse via the shared NodePilot.Core.Triggers settings, so the + /// shared contract text is appended for every trigger type. + /// Several activities share a base class or a helper in the same folder + /// (FileSystemOperationActivityBase, QueryPayloadSource, SubWorkflowInvocation). Any + /// Activities/ file that declares no ActivityType of its own but whose type name the executor + /// mentions is appended to that executor's text. + /// /// private static Dictionary LoadExecutorSources() { var root = FindRepoRoot(); var sources = new Dictionary(StringComparer.Ordinal); + var collaborators = new Dictionary(StringComparer.Ordinal); foreach (var dir in new[] { "Activities", "Triggers" }) { @@ -211,6 +219,17 @@ private static Dictionary LoadExecutorSources() var text = File.ReadAllText(file); var match = Regex.Match(text, @"(?:ActivityType|TriggerType)\s*=>\s*""([^""]+)"""); if (match.Success) sources[match.Groups[1].Value] = text; + else collaborators[Path.GetFileNameWithoutExtension(file)] = text; + } + } + + // Follow shared bases/helpers: a key moved into one of them is still read on every run. + foreach (var type in sources.Keys.ToList()) + { + foreach (var (name, text) in collaborators) + { + if (Regex.IsMatch(sources[type], $@"\b{Regex.Escape(name)}\b")) + sources[type] += text; } } diff --git a/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs index 41e6132e..44fe8750 100644 --- a/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/NonRemoteActivityTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Sockets; -using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -414,136 +412,4 @@ public async Task Email_HtmlBody_DeliversWithHtmlContentType() session.DataReceived.Should().BeTrue(); session.DataPayload.Should().Contain("text/html"); } - - /// - /// Minimal in-process SMTP server. Speaks just enough of RFC 5321 to satisfy - /// : 220 banner, 250 multi-line EHLO, 250 to - /// MAIL/RCPT, 354/250 around DATA, 221 to QUIT. Bound to loopback on an OS-assigned - /// port so parallel test runs don't collide. - /// - private sealed class FakeSmtpServer : IAsyncDisposable - { - private readonly TcpListener _listener; - private readonly TaskCompletionSource _sessionTcs = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly CancellationTokenSource _cts = new(); - private Task? _acceptLoop; - - public int Port { get; } - - private FakeSmtpServer(TcpListener listener, int port) - { - _listener = listener; - Port = port; - } - - public static Task StartAsync() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - var server = new FakeSmtpServer(listener, port); - server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); - return Task.FromResult(server); - } - - public async Task AwaitSessionAsync(TimeSpan timeout) - { - var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); - if (completed != _sessionTcs.Task) - throw new TimeoutException("Fake SMTP server did not record a session in time."); - return await _sessionTcs.Task; - } - - private async Task AcceptAsync(CancellationToken ct) - { - try - { - using var client = await _listener.AcceptTcpClientAsync(ct); - await using var stream = client.GetStream(); - using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); - await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; - - var session = new SmtpSession(); - var dataPayload = new StringBuilder(); - - await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); - - while (!ct.IsCancellationRequested) - { - var line = await reader.ReadLineAsync(ct); - if (line is null) break; - - if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || - line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("250-fake.smtp.test"); - await writer.WriteLineAsync("250-SIZE 10485760"); - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase)) - { - session.MailFrom = line; - await writer.WriteLineAsync("250 OK"); - } - else if (line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) - { - session.RcptTo = line; - await writer.WriteLineAsync("250 OK"); - } - else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("354 End data with ."); - while (true) - { - var dataLine = await reader.ReadLineAsync(ct); - if (dataLine is null || dataLine == ".") break; - dataPayload.AppendLine(dataLine); - } - session.DataReceived = true; - session.DataPayload = dataPayload.ToString(); - await writer.WriteLineAsync("250 OK message accepted"); - } - else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) - { - await writer.WriteLineAsync("221 Bye"); - break; - } - else - { - await writer.WriteLineAsync("250 OK"); - } - } - - _sessionTcs.TrySetResult(session); - } - catch (OperationCanceledException) - { - _sessionTcs.TrySetCanceled(); - } - catch (Exception ex) - { - _sessionTcs.TrySetException(ex); - } - } - - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - try { _listener.Stop(); } catch { /* listener may already be stopped */ } - if (_acceptLoop is not null) - { - try { await _acceptLoop; } catch { /* shutdown noise */ } - } - _cts.Dispose(); - } - } - - private sealed class SmtpSession - { - public string MailFrom { get; set; } = ""; - public string RcptTo { get; set; } = ""; - public bool DataReceived { get; set; } - public string DataPayload { get; set; } = ""; - } } diff --git a/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs b/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs index 684cefcf..d1040db1 100644 --- a/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/RunScriptExecutionTargetTests.cs @@ -265,12 +265,4 @@ public async Task ExecuteAsync_IsolatedLocalNoProcessHost_ReturnsCleanStepFailur result.Success.Should().BeFalse(); result.ErrorOutput.Should().Contain("no PowerShell host"); } - - private sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine - { - public string EngineType => engineType; - public bool IsAvailable => available; - public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) - => Task.FromResult(new PowerShellExecutionResult { Success = true }); - } } diff --git a/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs b/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs index b5a5936f..4b02967f 100644 --- a/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/StepTestContextProviderTests.cs @@ -4,6 +4,7 @@ using NodePilot.Core.Models; using NodePilot.TestCommons; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.Execution; @@ -287,42 +288,4 @@ public async Task ListRunsAsync_FlagsStepRanCorrectly() target, data = new { disabled }, }; - - private sealed class StubGlobalVariableStore : NodePilot.Core.Interfaces.IGlobalVariableStore - { - private readonly Dictionary _values; - private readonly HashSet _secretNames = new(StringComparer.Ordinal); - public StubGlobalVariableStore(params (string Key, string Value)[] values) - => _values = values.ToDictionary(p => p.Key, p => p.Value); - public void SetSecret(string name, bool isSecret) - { - if (isSecret) _secretNames.Add(name); else _secretNames.Remove(name); - } - public Task> GetAllResolvedAsync(CancellationToken ct) - => Task.FromResult>(_values); - public Task GetAllResolvedDetailedAsync(CancellationToken ct) - => Task.FromResult(new NodePilot.Core.Interfaces.GlobalVariableResolutionResult( - _values, new HashSet())); - public Task> GetAllAsync(CancellationToken ct) - => Task.FromResult>( - _values.Select(kv => new NodePilot.Core.Models.GlobalVariable - { - Id = Guid.NewGuid(), - Name = kv.Key, - Value = kv.Value, - IsSecret = _secretNames.Contains(kv.Key), - }).ToList()); - public Task GetValueAsync(string name, CancellationToken ct) - => Task.FromResult(_values.TryGetValue(name, out var v) ? v : null); - public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); - public Task ReencryptAllSecretsAsync(CancellationToken ct) - => Task.FromResult(new NodePilot.Core.Interfaces.ReencryptionSummary( - 0, 0, Array.Empty())); - } } diff --git a/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs b/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs index ee6cf890..151f3a3c 100644 --- a/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/StepTesterTests.cs @@ -8,6 +8,7 @@ using NodePilot.Engine.Security; using NodePilot.TestCommons; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.Execution; @@ -520,25 +521,4 @@ public Task ExecuteAsync(StepExecutionContext context, JsonEleme OutputParameters = { ["leakedToken"] = "eyJabcdef0123456789.eyJpayloadabcdefghij.signaturevaluexyz0" }, }); } - - private sealed class StubGlobalVariableStore : IGlobalVariableStore - { - public Task> GetAllResolvedAsync(CancellationToken ct) - => Task.FromResult>(new Dictionary()); - public Task GetAllResolvedDetailedAsync(CancellationToken ct) - => Task.FromResult(new GlobalVariableResolutionResult( - new Dictionary(), new HashSet())); - public Task> GetAllAsync(CancellationToken ct) - => Task.FromResult>(Array.Empty()); - public Task GetValueAsync(string name, CancellationToken ct) => Task.FromResult(null); - public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) - => throw new NotSupportedException(); - public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); - public Task ReencryptAllSecretsAsync(CancellationToken ct) - => Task.FromResult(new ReencryptionSummary(0, 0, Array.Empty())); - } } diff --git a/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs b/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs new file mode 100644 index 00000000..a860d684 --- /dev/null +++ b/tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs @@ -0,0 +1,70 @@ +using NodePilot.Core.Interfaces; +using NodePilot.Core.Models; +using NodePilot.Engine.PowerShell; + +namespace NodePilot.Engine.Tests.Helpers; + +/// +/// double that reports a fixed engine type and +/// availability and always succeeds. Used by the engine-selection tests, which care about +/// which engine the factory picks, never about what it executes. +/// +public sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine +{ + public string EngineType => engineType; + public bool IsAvailable => available; + public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) + => Task.FromResult(new PowerShellExecutionResult { Success = true }); +} + +/// +/// In-memory . Constructed with the name/value pairs a test +/// wants resolvable (none = an empty store); marks a name secret so +/// redaction paths can be exercised. Every mutating member throws — these tests only read. +/// +public sealed class StubGlobalVariableStore : IGlobalVariableStore +{ + private readonly Dictionary _values; + private readonly HashSet _secretNames = new(StringComparer.Ordinal); + + public StubGlobalVariableStore(params (string Key, string Value)[] values) + => _values = values.ToDictionary(p => p.Key, p => p.Value); + + public void SetSecret(string name, bool isSecret) + { + if (isSecret) _secretNames.Add(name); else _secretNames.Remove(name); + } + + public Task> GetAllResolvedAsync(CancellationToken ct) + => Task.FromResult>(_values); + + public Task GetAllResolvedDetailedAsync(CancellationToken ct) + => Task.FromResult(new GlobalVariableResolutionResult(_values, new HashSet())); + + public Task> GetAllAsync(CancellationToken ct) + => Task.FromResult>( + _values.Select(kv => new GlobalVariable + { + Id = Guid.NewGuid(), + Name = kv.Key, + Value = kv.Value, + IsSecret = _secretNames.Contains(kv.Key), + }).ToList()); + + public Task GetValueAsync(string name, CancellationToken ct) + => Task.FromResult(_values.TryGetValue(name, out var v) ? v : null); + + public Task CreateAsync(string name, string value, bool isSecret, string? description, Guid folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task UpdateAsync(Guid id, string name, string? value, bool isSecret, string? description, Guid? folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task MoveToFolderAsync(Guid id, Guid folderId, string? updatedBy, CancellationToken ct) + => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, CancellationToken ct) => throw new NotSupportedException(); + + public Task ReencryptAllSecretsAsync(CancellationToken ct) + => Task.FromResult(new ReencryptionSummary(0, 0, Array.Empty())); +} diff --git a/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs b/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs index 161f67b1..2787d740 100644 --- a/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs +++ b/tests/NodePilot.Engine.Tests/PowerShell/PowerShellEngineFactoryIsolationTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using NodePilot.Engine.PowerShell; using Xunit; +using NodePilot.Engine.Tests.Helpers; namespace NodePilot.Engine.Tests.PowerShell; @@ -13,13 +14,6 @@ namespace NodePilot.Engine.Tests.PowerShell; ///
public class PowerShellEngineFactoryIsolationTests { - private sealed class FakeEngine(string engineType, bool available) : IPowerShellExecutionEngine - { - public string EngineType => engineType; - public bool IsAvailable => available; - public Task ExecuteAsync(PowerShellExecutionRequest request, CancellationToken ct) - => Task.FromResult(new PowerShellExecutionResult { Success = true }); - } private static PowerShellEngineFactory Factory(bool pwsh = true, bool windows = true, bool runspace = true) => new( diff --git a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs index b8c0e8a8..d5ae952f 100644 --- a/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs +++ b/tests/NodePilot.Mcp.Tests/Api/InfraTests.cs @@ -8,6 +8,7 @@ using WireMock.ResponseBuilders; using WireMock.Server; using Xunit; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Tests.Api; @@ -75,7 +76,7 @@ public void Resolve_RawEnvToken_IsNotRefreshable_AndServerFromEnv() var dir = Temp(); try { - var session = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)).Resolve(); session.Server.Should().Be("https://env-srv/"); session.Token.Should().Be("raw-bearer"); session.UsesRefreshableSession.Should().BeFalse(); @@ -98,7 +99,7 @@ public void Resolve_FallsBackToCliProfileServerAndDpapiSession() var tokens = new TokenStore(dir); tokens.Save("prod", new StoredSession { Server = "https://prod-srv/", Token = "dpapi-jwt", Username = "u", UserId = Guid.NewGuid(), Role = "Operator", ExpiresAt = DateTime.UtcNow.AddHours(12) }); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Profile.Should().Be("prod"); session.Server.Should().Be("https://prod-srv/"); session.Token.Should().Be("dpapi-jwt"); @@ -125,7 +126,7 @@ public void ApiClientFactory_BuildsConfiguredClientFromSession() var dir = Temp(); try { - var cfg = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + var cfg = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); var client = new ApiClientFactory(cfg, new TokenStore(dir)).Create(); client.Session!.HasServer.Should().BeTrue(); client.Session.HasToken.Should().BeTrue(); @@ -143,7 +144,7 @@ public void ApiClientFactory_RejectsInsecureServerUrl() var dir = Temp(); try { - var cfg = new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + var cfg = new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); Action act = () => new ApiClientFactory(cfg, new TokenStore(dir)).Create(); @@ -164,7 +165,7 @@ public void Resolve_McpServerEnvironmentOverride_DropsSessionFromDifferentOrigin var tokens = new TokenStore(dir); tokens.Save("default", StoredSessionFor("https://trusted.example", "origin-bound-token")); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Server.Should().Be("https://attacker.example"); session.HasToken.Should().BeFalse(); @@ -189,7 +190,7 @@ public void Resolve_EquivalentDefaultPortOrigin_KeepsRefreshableSession( var tokens = new TokenStore(dir); tokens.Save("default", StoredSessionFor(storedServer, "tok")); - var session = new McpServerConfig(new ConfigStore(dir), tokens).Resolve(); + var session = new McpServerConfig(new ClientConfigStore(dir), tokens).Resolve(); session.Token.Should().Be("tok"); session.UsesRefreshableSession.Should().BeTrue(); diff --git a/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs b/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs index f5483ef9..2c19c85b 100644 --- a/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs +++ b/tests/NodePilot.Mcp.Tests/Infra/TestApi.cs @@ -2,6 +2,7 @@ using NodePilot.Mcp.Auth; using NodePilot.Mcp.Config; using WireMock.Server; +using NodePilot.Core.Clients; namespace NodePilot.Mcp.Tests.Infra; @@ -28,7 +29,7 @@ public NodePilotApiClient Client(string? token = "test-token") public static McpServerConfig Config() { var dir = Path.Combine(Path.GetTempPath(), "np-mcp-test-" + Guid.NewGuid().ToString("N")); - return new McpServerConfig(new ConfigStore(dir), new TokenStore(dir)); + return new McpServerConfig(new ClientConfigStore(dir), new TokenStore(dir)); } /// A full WorkflowResponse-shaped body for WireMock stubs. diff --git a/tests/NodePilot.TestCommons/FakeSmtpServer.cs b/tests/NodePilot.TestCommons/FakeSmtpServer.cs new file mode 100644 index 00000000..4df508e4 --- /dev/null +++ b/tests/NodePilot.TestCommons/FakeSmtpServer.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace NodePilot.TestCommons; + +/// +/// Minimal in-process SMTP server for tests that need to prove a mail was actually handed to a +/// server — the emailNotification activity, the admin-settings SMTP probe, alerting sinks. +/// Listens on an ephemeral loopback port, speaks just enough ESMTP to complete one delivery +/// (EHLO/HELO → MAIL FROM → RCPT TO → DATA → QUIT) and records what it saw in a +/// . Consolidates the private copies that previously lived in +/// Api.Tests and Engine.Tests. +/// +public sealed class FakeSmtpServer : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly TaskCompletionSource _sessionTcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenSource _cts = new(); + private Task? _acceptLoop; + + /// Ephemeral loopback port the server bound to. + public int Port { get; } + + private FakeSmtpServer(TcpListener listener, int port) + { + _listener = listener; + Port = port; + } + + public static Task StartAsync() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var server = new FakeSmtpServer(listener, port); + server._acceptLoop = Task.Run(() => server.AcceptAsync(server._cts.Token)); + return Task.FromResult(server); + } + + /// Waits for the single recorded session, or throws once elapses. + public async Task AwaitSessionAsync(TimeSpan timeout) + { + var completed = await Task.WhenAny(_sessionTcs.Task, Task.Delay(timeout)); + if (completed != _sessionTcs.Task) + throw new TimeoutException("Fake SMTP server did not record a session in time."); + return await _sessionTcs.Task; + } + + private async Task AcceptAsync(CancellationToken ct) + { + try + { + using var client = await _listener.AcceptTcpClientAsync(ct); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + await using var writer = new StreamWriter(stream, Encoding.ASCII) { AutoFlush = true, NewLine = "\r\n" }; + + var session = new SmtpSession(); + var dataPayload = new StringBuilder(); + + await writer.WriteLineAsync("220 fake.smtp.test ESMTP ready"); + + while (!ct.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(ct); + if (line is null) break; + + if (line.StartsWith("EHLO", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("HELO", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("250-fake.smtp.test"); + await writer.WriteLineAsync("250-SIZE 10485760"); + await writer.WriteLineAsync("250 OK"); + } + else if (line.StartsWith("MAIL FROM", StringComparison.OrdinalIgnoreCase)) + { + session.MailFrom = line; + await writer.WriteLineAsync("250 OK"); + } + else if (line.StartsWith("RCPT TO", StringComparison.OrdinalIgnoreCase)) + { + session.RcptTo = line; + await writer.WriteLineAsync("250 OK"); + } + else if (line.Equals("DATA", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("354 End data with ."); + while (true) + { + var dataLine = await reader.ReadLineAsync(ct); + if (dataLine is null || dataLine == ".") break; + dataPayload.AppendLine(dataLine); + } + session.DataReceived = true; + session.DataPayload = dataPayload.ToString(); + await writer.WriteLineAsync("250 OK message accepted"); + } + else if (line.StartsWith("QUIT", StringComparison.OrdinalIgnoreCase)) + { + await writer.WriteLineAsync("221 Bye"); + break; + } + else + { + await writer.WriteLineAsync("250 OK"); + } + } + + _sessionTcs.TrySetResult(session); + } + catch (OperationCanceledException) + { + _sessionTcs.TrySetCanceled(); + } + catch (Exception ex) + { + _sessionTcs.TrySetException(ex); + } + } + + public async ValueTask DisposeAsync() + { + _cts.Cancel(); + try { _listener.Stop(); } catch { /* already stopped */ } + if (_acceptLoop is not null) + { + try { await _acceptLoop; } catch { /* shutdown noise */ } + } + _cts.Dispose(); + } +} + +/// What observed during the one session it accepts. +public sealed class SmtpSession +{ + public string MailFrom { get; set; } = ""; + public string RcptTo { get; set; } = ""; + public bool DataReceived { get; set; } + public string DataPayload { get; set; } = ""; +} From 7a32bce040b82624b9c7bf59bdcf065bbe28a69d Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:53:08 +0200 Subject: [PATCH 2/5] Read the picker fields before rendering them Folding the three field pickers onto one PickerPopover passed the whole useSearchablePicker() result through as a prop. Inside the component that object is opaque, so the React Compiler treats every read on it as a ref access during render and eslint fails the build with seven "Cannot access refs during render" errors. Destructure once at the top of the component instead. The refs are then forwarded as plain values, which is what the pre-dedup code did and what AnchoredPickerPopover expects. --- .../components/designer/properties/shared.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/nodepilot-ui/src/components/designer/properties/shared.tsx b/src/nodepilot-ui/src/components/designer/properties/shared.tsx index b9e991ce..7be93685 100644 --- a/src/nodepilot-ui/src/components/designer/properties/shared.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/shared.tsx @@ -375,11 +375,12 @@ function PickerPopover({ surfaceClass?: string; children: React.ReactNode; }>) { + const { open, toggle, query, setQuery, containerRef, popoverRef, searchRef } = picker; return ( -
+
picker.setQuery(e.target.value)} + value={query} + onChange={(e) => setQuery(e.target.value)} placeholder={placeholder} className="w-full bg-surface-high rounded pl-7 pr-2 py-1 text-xs font-label focus:outline-none focus:ring-1 focus:ring-primary/40" /> From 016c9e8b04177a335b9073c696b0b74f11300344 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:08:46 +0200 Subject: [PATCH 3/5] Anchor the blackout tests on the actual cron fire time Both armed-trigger blackout tests split their evaluator verdict on "now + 30 seconds", while the fixture's cron fires at the top of the coming hour. Whenever the suite runs in the last half minute of an hour the cutoff lands past the fire time and the split inverts, so the flagged case reports no window and the not-flagged case reports one. CI hit exactly that at 19:00:20Z. Derive the cutoff from the hour boundary instead and place it midway, so it is strictly between "now" and the fire time at every wall-clock moment. --- .../Controllers/DashboardControllerTests.cs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs b/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs index 45d88974..cf8371d5 100644 --- a/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs +++ b/tests/NodePilot.Api.Tests/Controllers/DashboardControllerTests.cs @@ -347,6 +347,20 @@ public async Task Get_WindowHours7d_IncludesExecutionOlderThan24h() // NOT flag the row, and vice versa. /// An always-armed workflow: hourly cron, so NextFireUtc is within the next hour. + /// + /// A point in time strictly between "now" and the next firing of 's + /// cron, which is the top of the coming hour. The two blackout tests below split their verdict on + /// this instant, so it must never land on or past the fire time — a fixed "now + 30 s" did exactly + /// that whenever the suite ran in the last half minute of an hour, silently inverting both of them + /// (observed in CI at 19:00:20Z). Anchoring on the actual boundary holds at every wall-clock moment. + /// + private static DateTime CutoffBeforeNextHourlyFire() + { + var now = DateTime.UtcNow; + var nextFire = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Utc).AddHours(1); + return now.AddTicks(Math.Max(1, (nextFire - now).Ticks / 2)); + } + private static Workflow ArmedCronWorkflow(string name = "Nightly Backup") => new() { @@ -396,8 +410,8 @@ public async Task Get_ArmedTrigger_BlackoutActiveAtFireTimeButNotNow_IsFlagged() db.Workflows.Add(ArmedCronWorkflow()); await db.SaveChangesAsync(); - // Blocks only from 30 s out — i.e. not "now", but by the time the cron fires. - var cutoff = DateTime.UtcNow.AddSeconds(30); + // Blocks only from the cutoff on — i.e. not "now", but by the time the cron fires. + var cutoff = CutoffBeforeNextHourlyFire(); var evaluator = new StubMaintenanceWindowEvaluator { VerdictAt = at => at >= cutoff @@ -419,7 +433,7 @@ public async Task Get_ArmedTrigger_BlackoutActiveNowButClosedByFireTime_IsNotFla await db.SaveChangesAsync(); // The honest inverse: evaluating at "now" would have flagged this row wrongly. - var cutoff = DateTime.UtcNow.AddSeconds(30); + var cutoff = CutoffBeforeNextHourlyFire(); var evaluator = new StubMaintenanceWindowEvaluator { VerdictAt = at => at < cutoff From c194ba2941ffe397ef14af5c8be7db856b6fd55e Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:47:29 +0200 Subject: [PATCH 4/5] Remove over-engineered code paths flagged by the audit A repo-wide sweep looked for code that a smaller solution would cover just as well. These twelve findings survived an adversarial check for hidden consumers, test doubles and documented conventions. Engine - ReturnDataActivity: drop the refcounted per-execution semaphore registry. It guarded a single atomic ExecuteUpdateAsync, and the determinism it implied is explicitly not promised (last-write-wins). - WorkflowDbWriteMetrics: SaveChangesMeasuredAsync now delegates to ExecuteMeasuredAsync instead of repeating its body; the two error catches collapse into one that keeps the cancelled/failure split. - BaseRemoteActivity: delete the unreachable four-argument constructor and make _configuration non-nullable. All thirteen subclasses already passed a configuration, so the null-config branches in StartProgramActivity, PowerManagementActivity and WaitForConditionActivity were dead - and they silently permitted useShellExecute and skipped the path guards. Api - CmTraceFormatter: replace the hand-rolled in-place substitution loop with StringBuilder.Replace. - Observability: drop four DTO records that were never constructed and three response fields that were always empty, plus their TypeScript mirrors. Mcp - DefinitionDiff: replace the custom canonicalising serializer and its JsonEquals(a, b) => a == b wrapper with JsonNode.DeepEquals. Ai - LlmConfiguredProxy: drop the six-field cache record and its SequenceEqual comparison. It avoided constructing a WebProxy on a path capped at 20 requests per minute. Frontend - NodeLibrary: remove the 23-entry Tailwind colour table that shadowed the generated --act-* tokens without a dark variant, and read the shared activity visual instead. Six catalog types that the table missed are now coloured correctly. - configClone: drop the skip-key registry for a provably empty set. - SystemSettingsPage: remove the unreachable comingSoon branch and replace the nine-way || chain and ternary with a lookup record. Tooling - dev-reset.ps1: remove the SkipTests switch that defaulted to true and therefore disabled its own block. - Delete the MockRemoteSession test helper; its single caller now builds the mock inline like the other fifteen activity test files. --- .gitignore | 2 +- docs/custom-activities.md | 7 +- scripts/dev-reset.ps1 | 42 ++--- src/NodePilot.Ai/LlmConfiguredProxy.cs | 63 +++----- .../Controllers/ObservabilityController.cs | 2 +- src/NodePilot.Api/Dtos/ObservabilityDtos.cs | 7 - src/NodePilot.Api/Logging/CmTraceFormatter.cs | 30 +--- .../Observability/MetricsDashboardCatalog.cs | 2 +- .../Activities/BaseActivity.cs | 13 +- .../Activities/PowerManagementActivity.cs | 2 +- .../Activities/ReturnDataActivity.cs | 79 ++------- .../Activities/StartProgramActivity.cs | 19 +-- .../Activities/WaitForConditionActivity.cs | 9 +- .../Execution/WorkflowDbWriteMetrics.cs | 55 ++----- src/NodePilot.Mcp/Analysis/DefinitionDiff.cs | 46 +----- .../components/designer/ActivityIcon.test.tsx | 74 +++++++++ .../src/__tests__/lib/configClone.test.ts | 22 +-- .../src/__tests__/pages/MetricsPage.test.tsx | 1 - .../designer/library/NodeLibrary.tsx | 48 ++---- .../src/i18n/locales/de/adminSettings.json | 6 +- .../src/i18n/locales/en/adminSettings.json | 6 +- src/nodepilot-ui/src/lib/configClone.ts | 38 +---- .../src/pages/SystemSettingsPage.tsx | 91 ++++------- src/nodepilot-ui/src/types/api.ts | 7 - .../LlmConfiguredProxyTests.cs | 62 +++++++- .../Logging/CmTraceFormatterTests.cs | 19 +++ .../Activities/BaseRemoteActivityTests.cs | 18 ++- .../Activities/ControlFlowActivityTests.cs | 19 --- .../Activities/StartProgramActivityTests.cs | 24 ++- .../Execution/WorkflowDbWriteMetricsTests.cs | 150 ++++++++++++++++++ .../Helpers/MockRemoteSession.cs | 47 ------ .../Analysis/DefinitionDiffTests.cs | 108 +++++++++++++ 32 files changed, 596 insertions(+), 522 deletions(-) create mode 100644 src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx delete mode 100644 tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs create mode 100644 tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs diff --git a/.gitignore b/.gitignore index 16020b6a..daaa423f 100644 --- a/.gitignore +++ b/.gitignore @@ -120,7 +120,7 @@ coverage-report/ **/test-migrate.db **/test-migrate.db-* -# dev-reset.ps1 transient logs (build/test/start output, kept locally for debugging) +# dev-reset.ps1 transient logs (build/start output, kept locally for debugging) .dev-logs/ # Local artefact + backup folders produced by Build-Artifact.ps1 / manual zips diff --git a/docs/custom-activities.md b/docs/custom-activities.md index a9c12199..695dfaa3 100644 --- a/docs/custom-activities.md +++ b/docs/custom-activities.md @@ -95,9 +95,10 @@ Audit codes: `CUSTOM_ACTIVITY_CREATED|UPDATED|DELETED|ENABLED|DISABLED|IMPORTED| `activityCatalog.generated.ts` and its parity test are **untouched** — custom activities are a runtime catalog, never the static one. - Consumers wired via `isCustomActivityType` + runtime facts: palette (`activityCategories.ts`), - labels/icons (`shared.tsx`, `NodeLibrary.tsx`), node visuals (`nodes/activityConfig.ts`), - config form (`DynamicActivityConfig`), output variables (`upstreamVariables.ts`), and - PropertiesPanel remote/timeout gating. + labels (`shared.tsx`), node visuals (`nodes/activityConfig.ts` — `getActivityVisual` is also the + single source for the palette/picker glyph in `NodeLibrary.tsx`, which therefore carries no icon + or colour table of its own), config form (`DynamicActivityConfig`), output variables + (`upstreamVariables.ts`), and PropertiesPanel remote/timeout gating. ## System-configuration backup (ADR 0001) diff --git a/scripts/dev-reset.ps1 b/scripts/dev-reset.ps1 index 490609e9..7a551989 100644 --- a/scripts/dev-reset.ps1 +++ b/scripts/dev-reset.ps1 @@ -1,17 +1,15 @@ <# .SYNOPSIS - Kill -> Build -> Test -> Start (NodePilot dev reset) - Kills all running backend/frontend processes, builds both, runs all tests, - then starts backend (http://localhost:5000) and frontend (http://localhost:5173). - -.PARAMETER SkipTests - Skip all test runs and go straight to start. + Kill -> Build -> Start (NodePilot dev reset) + Kills all running backend/frontend processes, builds both, then starts + backend (http://localhost:5000) and frontend (http://localhost:5173). + Tests are not run here -- use scripts/nightly-tests.ps1 or a scoped + dotnet test / vitest run. .PARAMETER SkipBuild - Skip the build step (still runs tests and restarts processes). + Skip the build step (still restarts processes). #> param( - [switch]$SkipTests = $true, [switch]$SkipBuild = $false ) @@ -121,29 +119,7 @@ if (-not $SkipBuild) { } # --------------------------------------------------------------------------- -# 3. Tests -# --------------------------------------------------------------------------- - -if (-not $SkipTests) { - - Invoke-Checked "Backend tests (dotnet test)" { - Set-Location $root - $log = Join-Path $logDir "test-backend-$ts.log" - dotnet test --logger "console;verbosity=normal" | Tee-Object -FilePath $log - if ($LASTEXITCODE -ne 0) { throw "Backend tests failed -- see $log" } - } - - Invoke-Checked "Frontend tests (npm run test:run)" { - Set-Location $uiDir - $log = Join-Path $logDir "test-frontend-$ts.log" - cmd /c "npm run test:run" | Tee-Object -FilePath $log - if ($LASTEXITCODE -ne 0) { throw "Frontend tests failed -- see $log" } - } - -} - -# --------------------------------------------------------------------------- -# 4. Start backend +# 3. Start backend # --------------------------------------------------------------------------- Write-Step "Starting backend --> http://localhost:5000" @@ -163,7 +139,7 @@ $beProc = Start-Process ` Write-Ok "Backend PID $($beProc.Id) -> $beOut" # --------------------------------------------------------------------------- -# 5. Start frontend +# 4. Start frontend # --------------------------------------------------------------------------- Write-Step "Starting frontend --> http://localhost:5173" @@ -182,7 +158,7 @@ $feProc = Start-Process ` Write-Ok "Frontend PID $($feProc.Id) -> $feOut" # --------------------------------------------------------------------------- -# 6. Wait for backend to be ready +# 5. Wait for backend to be ready # --------------------------------------------------------------------------- Write-Step "Waiting for backend on :5000 ..." diff --git a/src/NodePilot.Ai/LlmConfiguredProxy.cs b/src/NodePilot.Ai/LlmConfiguredProxy.cs index 272f62fd..67ecd814 100644 --- a/src/NodePilot.Ai/LlmConfiguredProxy.cs +++ b/src/NodePilot.Ai/LlmConfiguredProxy.cs @@ -30,14 +30,6 @@ public sealed class LlmConfiguredProxy : IWebProxy { private readonly IOptionsMonitor _options; - /// - /// Last built custom proxy plus the values it was built from. Rebuilding a - /// (and recompiling its bypass regexes) per request would be wasteful; - /// comparing the source values is cheaper and needs no invalidation callback. A race just - /// builds twice, which is harmless. - /// - private volatile CustomProxyCache? _cache; - public LlmConfiguredProxy(IOptionsMonitor options) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -76,6 +68,12 @@ public ICredentials? Credentials { ArgumentNullException.ThrowIfNull(destination); + // A plaintext LLM endpoint is accepted only because it is physically on this host. Never + // hand such a request to a proxy: "localhost" would then refer to the proxy machine and + // the unencrypted prompt/key would leave the loopback boundary the endpoint guard promised. + if (MustStayOnLoopback(destination)) + return null; + var proxy = CurrentOptions; return proxy.Mode switch { @@ -91,6 +89,9 @@ public bool IsBypassed(Uri destination) { ArgumentNullException.ThrowIfNull(destination); + if (MustStayOnLoopback(destination)) + return true; + var proxy = CurrentOptions; return proxy.Mode switch { @@ -104,6 +105,10 @@ public bool IsBypassed(Uri destination) private LlmProxyOptions CurrentOptions => _options.CurrentValue.Proxy ?? new LlmProxyOptions(); + private static bool MustStayOnLoopback(Uri destination) + => destination.Scheme == Uri.UriSchemeHttp + && LlmEndpointGuard.IsLiteralLoopbackEndpoint(destination); + private static ICredentials? ResolveCustomCredentials(LlmProxyOptions proxy) { if (proxy.UseDefaultCredentials) return CredentialCache.DefaultCredentials; @@ -111,11 +116,13 @@ public bool IsBypassed(Uri destination) return new NetworkCredential(proxy.Username, proxy.Password ?? ""); } - private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) + /// + /// Builds the for the current settings on every call. No caching: the + /// LLM endpoints are rate-limited to 20 requests/minute, so an allocation plus a handful of + /// bypass regexes per request is not worth an invalidation mechanism of its own. + /// + private static WebProxy ResolveCustomProxy(LlmProxyOptions proxy) { - var cached = _cache; - if (cached is not null && cached.Matches(proxy)) return cached.Proxy; - // Same two rules the settings validation applies, from the same place — see LlmProfileValidation. if (!LlmProfileValidation.HasProxyAddress(proxy.Address, out var address)) { @@ -138,42 +145,12 @@ private WebProxy ResolveCustomProxy(LlmProxyOptions proxy) .Select(v => v.Trim()) .ToArray(); - var built = new WebProxy( + return new WebProxy( proxyUri, BypassOnLocal: false, BypassList: bypass.Select(ProxyBypassPattern.ToRegex).ToArray()) { Credentials = ResolveCustomCredentials(proxy), }; - - _cache = new CustomProxyCache(built, address, bypass, proxy.Username, proxy.Password, proxy.UseDefaultCredentials); - return built; - } - - /// - /// Snapshot of the values a cached was built from. Compared field by - /// field rather than via a concatenated signature string so the proxy password does not get a - /// second, longer-lived copy in memory. - /// - private sealed record CustomProxyCache( - WebProxy Proxy, - string Address, - string[] Bypass, - string? Username, - string? Password, - bool UseDefaultCredentials) - { - public bool Matches(LlmProxyOptions options) - { - if (!string.Equals(Address, options.Address?.Trim(), StringComparison.Ordinal)) return false; - if (!string.Equals(Username, options.Username, StringComparison.Ordinal)) return false; - if (!string.Equals(Password, options.Password, StringComparison.Ordinal)) return false; - if (UseDefaultCredentials != options.UseDefaultCredentials) return false; - - var incoming = (options.BypassList ?? new List()) - .Where(v => !string.IsNullOrWhiteSpace(v)) - .Select(v => v.Trim()); - return Bypass.SequenceEqual(incoming, StringComparer.Ordinal); - } } } diff --git a/src/NodePilot.Api/Controllers/ObservabilityController.cs b/src/NodePilot.Api/Controllers/ObservabilityController.cs index 05522e8d..59f39be1 100644 --- a/src/NodePilot.Api/Controllers/ObservabilityController.cs +++ b/src/NodePilot.Api/Controllers/ObservabilityController.cs @@ -246,7 +246,7 @@ public async Task> Dashboard(string key, if (!MetricsDashboardCatalog.Exists(key)) return NotFound(new { message = "Unknown metrics dashboard." }); hours = hours switch { 1 or 24 or 168 or 720 => hours, _ => 24 }; if (!_prom.IsConfigured) - return Ok(new MetricsDashboardResponse(false, key, MetricsDashboardCatalog.Title(key), [], [], [], [])); + return Ok(new MetricsDashboardResponse(false, key, MetricsDashboardCatalog.Title(key), [])); return Ok(await MetricsDashboardCatalog.ExecuteAsync(key, hours, _prom, _logger, ct)); } diff --git a/src/NodePilot.Api/Dtos/ObservabilityDtos.cs b/src/NodePilot.Api/Dtos/ObservabilityDtos.cs index a79686a1..76f3e8fe 100644 --- a/src/NodePilot.Api/Dtos/ObservabilityDtos.cs +++ b/src/NodePilot.Api/Dtos/ObservabilityDtos.cs @@ -26,16 +26,9 @@ public record MetricsDashboardResponse( bool Available, string Key, string Title, - List Panels, - List Series, - List Tables, List? Widgets = null); -public record MetricsSeries(string Key, string Title, string Unit, List Lines); -public record MetricsSeriesLine(string Label, List Points); public record MetricsPoint(long Timestamp, double? Value); -public record MetricsTable(string Key, string Title, string Unit, List Rows); -public record MetricsTableRow(string Label, double Value); public record MetricsWidget( int Id, diff --git a/src/NodePilot.Api/Logging/CmTraceFormatter.cs b/src/NodePilot.Api/Logging/CmTraceFormatter.cs index f1c5a05f..da670d90 100644 --- a/src/NodePilot.Api/Logging/CmTraceFormatter.cs +++ b/src/NodePilot.Api/Logging/CmTraceFormatter.cs @@ -46,7 +46,7 @@ public void Format(LogEvent logEvent, TextWriter output) // (e.g. a copied log excerpt as script output) would truncate the message at the // terminator and dump the rest of the payload outside the SMS wrapper. Replace the // `!` with `_` to keep the substring recognisable while breaking the match. - ReplaceAll(msgBuf, "]LOG]!>", "]LOG]_>"); + msgBuf.Replace("]LOG]!>", "]LOG]_>"); // CMTrace's parser gives up at ~4096 chars per line — past that point the viewer // falls back to "raw line" rendering (no column extraction, all meta fields blank). @@ -94,34 +94,6 @@ public void Format(LogEvent logEvent, TextWriter output) $"thread=\"{Environment.CurrentManagedThreadId}\" file=\"\">"); } - /// - /// In-place replace of every occurrence of with - /// (lengths may differ). - /// - private static void ReplaceAll(System.Text.StringBuilder sb, string needle, string replacement) - { - if (needle.Length == 0 || sb.Length < needle.Length) return; - var idx = 0; - while (idx <= sb.Length - needle.Length) - { - var hit = true; - for (var k = 0; k < needle.Length; k++) - { - if (sb[idx + k] != needle[k]) { hit = false; break; } - } - if (hit) - { - sb.Remove(idx, needle.Length); - sb.Insert(idx, replacement); - idx += replacement.Length; - } - else - { - idx++; - } - } - } - /// /// Replaces CR/LF/TAB with spaces and collapses consecutive whitespace into one /// space. Operates in-place on the buffer. diff --git a/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs b/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs index c3d99c4a..2d06bfb5 100644 --- a/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs +++ b/src/NodePilot.Api/Services/Observability/MetricsDashboardCatalog.cs @@ -59,7 +59,7 @@ public static async Task ExecuteAsync( var widgets = await Task.WhenAll(panels.Select(panel => ExecutePanelAsync( panel, hours, start, end, step, prometheus, logger, cancellationToken))); - return new MetricsDashboardResponse(true, key, title, [], [], [], widgets.ToList()); + return new MetricsDashboardResponse(true, key, title, widgets.ToList()); } private static bool IsMetricPanel(JsonElement panel) diff --git a/src/NodePilot.Engine/Activities/BaseActivity.cs b/src/NodePilot.Engine/Activities/BaseActivity.cs index e94b85fe..c29b4d8e 100644 --- a/src/NodePilot.Engine/Activities/BaseActivity.cs +++ b/src/NodePilot.Engine/Activities/BaseActivity.cs @@ -18,23 +18,16 @@ public abstract class BaseRemoteActivity : IActivityExecutor protected readonly ICredentialStore _credentialStore; protected readonly NodePilot.Data.NodePilotDbContext _db; protected readonly PowerShellEngineFactory _engineFactory; - protected readonly IConfiguration? _configuration; + protected readonly IConfiguration _configuration; public abstract string ActivityType { get; } - protected BaseRemoteActivity( - IRemoteSessionFactory sessionFactory, - ICredentialStore credentialStore, - NodePilot.Data.NodePilotDbContext db, - PowerShellEngineFactory engineFactory) - : this(sessionFactory, credentialStore, db, engineFactory, null) { } - protected BaseRemoteActivity( IRemoteSessionFactory sessionFactory, ICredentialStore credentialStore, NodePilot.Data.NodePilotDbContext db, PowerShellEngineFactory engineFactory, - IConfiguration? configuration) + IConfiguration configuration) { _sessionFactory = sessionFactory; _credentialStore = credentialStore; @@ -94,7 +87,7 @@ public virtual async Task ExecuteAsync(StepExecutionContext cont // Emitted via the engine's ActivitySource so it surfaces in OpenTelemetry // tracing without requiring an ILogger reference inside NodePilot.Core // (which is zero-deps by convention, see CLAUDE.md). - if (_configuration?.GetValue("Cluster:Enabled") ?? false) + if (_configuration.GetValue("Cluster:Enabled")) { var nodeId = _configuration["Cluster:NodeId"] ?? Environment.MachineName; Activity.Current?.AddEvent(new ActivityEvent("cluster.localhost_step", diff --git a/src/NodePilot.Engine/Activities/PowerManagementActivity.cs b/src/NodePilot.Engine/Activities/PowerManagementActivity.cs index 1cd0fcaf..d187ac98 100644 --- a/src/NodePilot.Engine/Activities/PowerManagementActivity.cs +++ b/src/NodePilot.Engine/Activities/PowerManagementActivity.cs @@ -100,7 +100,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c private bool AllowsLocalSelfShutdown() { - var raw = _configuration?["PowerManagement:AllowLocalSelfShutdown"]; + var raw = _configuration["PowerManagement:AllowLocalSelfShutdown"]; return string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase); } diff --git a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs index 031f6626..1bcc26c4 100644 --- a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs +++ b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Text.Json; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Interfaces; @@ -16,31 +15,17 @@ namespace NodePilot.Engine.Activities; /// Config: /// data: { key1: "literal or {{template}}", key2: "...", ... } /// -/// Concurrency (audit M10): multiple returnData steps on parallel branches used to race — -/// each fetched the execution row via its scope-local DbContext, set ReturnData, saved, and -/// EF's default last-writer-wins gave nondeterministic results. The fix is two-fold: -/// 1. per-execution SemaphoreSlim serializes writes within a single process. -/// 2. ExecuteUpdate bypasses tracked-entity state entirely — no stale-entity concurrency -/// exception between the fetch and the update from a different scope. -/// -/// Designers are still encouraged to place a single terminal returnData step; this fix just -/// makes the "last-write" semantic deterministic rather than racy. +/// Concurrency: multiple returnData steps on parallel branches write the same row. The write +/// goes through ExecuteUpdate, which bypasses tracked-entity state entirely — no stale-entity +/// concurrency exception between a fetch and an update from a different scope. Which branch +/// wins is deliberately not promised: the documented semantic is last-write-wins on the whole +/// JSON (not per-key), so designers are expected to place a single terminal returnData step. /// public class ReturnDataActivity : IActivityExecutor { private readonly NodePilotDbContext _db; private readonly OutputRedactor? _redactor; - // Process-wide: one semaphore per WorkflowExecutionId. Slots are released by the finally - // block below. We don't aggressively evict completed executions — the dictionary grows - // linearly with lifetime executions which is bounded (existing executions live forever - // in the DB but fewer than a handful of returnData writes per execution) and the steady - // state is small. - private static readonly object _locksGate = new(); - private static readonly ConcurrentDictionary _perExecutionLocks = new(); - - internal static int ActiveLockCount => _perExecutionLocks.Count; - // Cap the serialized ReturnData so a single misbehaving workflow (or a caller trying // to stuff secrets) can't blow the column / audit trail. private const int MaxReturnDataChars = 32 * 1024; @@ -105,21 +90,11 @@ public async Task ExecuteAsync(StepExecutionContext context, Jso }; } - var executionLock = AcquireExecutionLock(context.WorkflowExecutionId); - await executionLock.Gate.WaitAsync(ct); - try - { - // Atomic update — avoids fetching a tracked entity from this scope's DbContext - // while another scope's context might also be tracking the same row. - await _db.WorkflowExecutions - .Where(e => e.Id == context.WorkflowExecutionId) - .ExecuteUpdateAsync(setters => setters.SetProperty(e => e.ReturnData, persistJson), ct); - } - finally - { - executionLock.Gate.Release(); - ReleaseExecutionLock(context.WorkflowExecutionId, executionLock); - } + // Atomic update — avoids fetching a tracked entity from this scope's DbContext + // while another scope's context might also be tracking the same row. + await _db.WorkflowExecutions + .Where(e => e.Id == context.WorkflowExecutionId) + .ExecuteUpdateAsync(setters => setters.SetProperty(e => e.ReturnData, persistJson), ct); return new ActivityResult { @@ -128,38 +103,4 @@ await _db.WorkflowExecutions OutputParameters = outputParams, }; } - - private static ExecutionLock AcquireExecutionLock(Guid executionId) - { - lock (_locksGate) - { - if (!_perExecutionLocks.TryGetValue(executionId, out var executionLock)) - { - executionLock = new ExecutionLock(); - _perExecutionLocks[executionId] = executionLock; - } - - executionLock.RefCount++; - return executionLock; - } - } - - private static void ReleaseExecutionLock(Guid executionId, ExecutionLock executionLock) - { - lock (_locksGate) - { - executionLock.RefCount--; - if (executionLock.RefCount == 0) - { - _perExecutionLocks.TryRemove(KeyValuePair.Create(executionId, executionLock)); - executionLock.Gate.Dispose(); - } - } - } - - private sealed class ExecutionLock - { - public SemaphoreSlim Gate { get; } = new(1, 1); - public int RefCount { get; set; } - } } diff --git a/src/NodePilot.Engine/Activities/StartProgramActivity.cs b/src/NodePilot.Engine/Activities/StartProgramActivity.cs index 25ef9fd8..ff804ff3 100644 --- a/src/NodePilot.Engine/Activities/StartProgramActivity.cs +++ b/src/NodePilot.Engine/Activities/StartProgramActivity.cs @@ -76,15 +76,12 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c // The shell parser introduces a second injection surface beyond PowerShell quoting, // so default-on since Phase 3: a missing config key is treated as // "DisallowShellExecute=true". Dev/test deployments that need shell-mediated launches - // flip StartProgram:DisallowShellExecute=false explicitly. Activities running with a - // null configuration (test harness without IConfiguration) keep the old permissive - // behaviour — there's no operator at risk in that scenario. + // flip StartProgram:DisallowShellExecute=false explicitly. if (useShell) { - var raw = _configuration?["StartProgram:DisallowShellExecute"]; - var disallow = _configuration is not null - && (string.IsNullOrWhiteSpace(raw) - || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)); + var raw = _configuration["StartProgram:DisallowShellExecute"]; + var disallow = string.IsNullOrWhiteSpace(raw) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase); if (disallow) throw new InvalidOperationException( "StartProgram: useShellExecute=true is blocked by configuration. " + @@ -110,6 +107,10 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c var timeoutMs = PowerShellOperation.ToWaitForExitMilliseconds(timeoutSeconds); var useShellPs = useShell ? "$true" : "$false"; var waitPs = wait ? "$true" : "$false"; + var targetPathGuard = TargetPathGuardScript.Build( + _configuration, + ("$__filePath", "filePath"), + ("$__workingDir", "workingDirectory")); // Build a self-contained script that emits a JSON result block between markers. // Uses ProcessStartInfo directly for reliable stdout/stderr capture (Start-Process @@ -122,6 +123,7 @@ protected override string BuildScript(JsonElement config, StepExecutionContext c $__useShell = {{useShellPs}} $__wait = {{waitPs}} $__timeoutMs = {{timeoutMs}} + {{targetPathGuard}} $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $__filePath @@ -322,8 +324,7 @@ private void ValidateLocalAbsolutePath(string fieldName, string path) { try { - if (_configuration is not null) - PathGuard.Validate(_configuration, path, allowWildcards: false); + PathGuard.Validate(_configuration, path, allowWildcards: false); } catch (InvalidOperationException ex) { diff --git a/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs b/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs index df1decf0..12df6477 100644 --- a/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs +++ b/src/NodePilot.Engine/Activities/WaitForConditionActivity.cs @@ -224,22 +224,17 @@ private void ValidateNetworkTarget(JsonElement config) if (conditionType == "portopen") { var host = config.GetStringOrNull("host")!; // presence is validated by the builder first - NetworkGuard.RequireExplicitlyAllowlistedHost( - _configuration ?? throw new InvalidOperationException("WaitForCondition: network policy configuration is unavailable."), - host, - "WaitForCondition portOpen"); + NetworkGuard.RequireExplicitlyAllowlistedHost(_configuration, host, "WaitForCondition portOpen"); return; } if (conditionType == "httpok") { var url = config.GetStringOrNull("url")!; // presence is validated by the builder first - var configuration = _configuration - ?? throw new InvalidOperationException("WaitForCondition: network policy configuration is unavailable."); // Probe policy only — NOT NetworkGuard.ValidateUrl. That is the restApi SSRF guard; // running httpOk through it made WaitForCondition:AllowedHosts inert for exactly the // loopback/RFC1918 targets it ships enabled for. See NetworkGuard.ValidateProbeUrl. - NetworkGuard.ValidateProbeUrl(configuration, url, "WaitForCondition httpOk"); + NetworkGuard.ValidateProbeUrl(_configuration, url, "WaitForCondition httpOk"); } } diff --git a/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs b/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs index f9ddf11e..3ec2c30e 100644 --- a/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs +++ b/src/NodePilot.Engine/Execution/WorkflowDbWriteMetrics.cs @@ -20,63 +20,28 @@ internal static async Task ExecuteMeasuredAsync( try { var rows = await write(); - var statusTag = new KeyValuePair("status", "success"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); + RecordCall("success", startTimestamp, operationTag); EngineMetrics.DbSaveChangesRows.Record(rows, operationTag); return rows; } - catch (OperationCanceledException) + catch (Exception ex) { - var statusTag = new KeyValuePair("status", "cancelled"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } - catch - { - var statusTag = new KeyValuePair("status", "failure"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); + RecordCall(ex is OperationCanceledException ? "cancelled" : "failure", startTimestamp, operationTag); throw; } } - internal static async Task SaveChangesMeasuredAsync( + internal static Task SaveChangesMeasuredAsync( this NodePilotDbContext db, string operation, CancellationToken ct) - { - if (!HasDbSaveMetricsListener()) - return await SaveChangesIdempotentAsync(db, ct); - - var startTimestamp = Stopwatch.GetTimestamp(); - var operationTag = new KeyValuePair("operation", operation); - - try - { - var rows = await SaveChangesIdempotentAsync(db, ct); + => ExecuteMeasuredAsync(operation, () => SaveChangesIdempotentAsync(db, ct)); - var statusTag = new KeyValuePair("status", "success"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - EngineMetrics.DbSaveChangesRows.Record(rows, operationTag); - return rows; - } - catch (OperationCanceledException) - { - var statusTag = new KeyValuePair("status", "cancelled"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } - catch - { - var statusTag = new KeyValuePair("status", "failure"); - EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); - EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); - throw; - } + private static void RecordCall(string status, long startTimestamp, KeyValuePair operationTag) + { + var statusTag = new KeyValuePair("status", status); + EngineMetrics.DbSaveChanges.Add(1, operationTag, statusTag); + EngineMetrics.DbSaveChangesDuration.Record(ElapsedMilliseconds(startTimestamp), operationTag, statusTag); } /// diff --git a/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs b/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs index 1776ed04..22545503 100644 --- a/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs +++ b/src/NodePilot.Mcp/Analysis/DefinitionDiff.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; namespace NodePilot.Mcp.Analysis; @@ -21,56 +22,23 @@ private static ArrayDiff DiffArray(JsonElement current, JsonElement proposed, st var added = prop.Keys.Where(k => !cur.ContainsKey(k)).OrderBy(k => k).ToList(); var removed = cur.Keys.Where(k => !prop.ContainsKey(k)).OrderBy(k => k).ToList(); - var modified = prop.Keys.Where(k => cur.ContainsKey(k) && !JsonEquals(cur[k], prop[k])).OrderBy(k => k).ToList(); + // Semantic comparison: property order and number representation (1 vs 1.0) are irrelevant, + // array order is not. + var modified = prop.Keys.Where(k => cur.ContainsKey(k) && !JsonNode.DeepEquals(cur[k], prop[k])).OrderBy(k => k).ToList(); return new ArrayDiff(added, removed, modified); } - private static Dictionary IndexById(JsonElement def, string arrayName) + private static Dictionary IndexById(JsonElement def, string arrayName) { - var map = new Dictionary(StringComparer.Ordinal); + var map = new Dictionary(StringComparer.Ordinal); if (def.ValueKind == JsonValueKind.Object && def.TryGetProperty(arrayName, out var arr) && arr.ValueKind == JsonValueKind.Array) foreach (var item in arr.EnumerateArray()) if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) { var id = idEl.GetString(); - if (!string.IsNullOrEmpty(id)) map[id] = Canonical(item); + if (!string.IsNullOrEmpty(id)) map[id] = JsonSerializer.SerializeToNode(item); } return map; } - - // Order-insensitive comparison of objects by canonicalising property order recursively. - private static bool JsonEquals(string a, string b) => a == b; - - private static string Canonical(JsonElement el) - { - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) - WriteCanonical(el, writer); - return System.Text.Encoding.UTF8.GetString(stream.ToArray()); - } - - private static void WriteCanonical(JsonElement el, Utf8JsonWriter writer) - { - switch (el.ValueKind) - { - case JsonValueKind.Object: - writer.WriteStartObject(); - foreach (var p in el.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) - { - writer.WritePropertyName(p.Name); - WriteCanonical(p.Value, writer); - } - writer.WriteEndObject(); - break; - case JsonValueKind.Array: - writer.WriteStartArray(); - foreach (var item in el.EnumerateArray()) WriteCanonical(item, writer); - writer.WriteEndArray(); - break; - default: - el.WriteTo(writer); - break; - } - } } diff --git a/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx b/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx new file mode 100644 index 00000000..4603ac6e --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/components/designer/ActivityIcon.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { ActivityIcon } from '../../../components/designer/library/NodeLibrary'; +import { + useCustomActivityCatalogStore, + type CustomActivityCatalogEntry, +} from '../../../lib/customActivities'; + +/** + * ActivityIcon is the palette/picker glyph. It used to carry its own 23-entry table of + * Tailwind colour literals (`text-blue-600`, …) parallel to the generated `--act-*` design + * tokens — incomplete and with no dark-mode variant. It now resolves icon AND colour through + * getActivityVisual, the same resolver the canvas ActivityNode uses. These tests pin that: + * the colour must arrive as a CSS variable on `style`, never as a Tailwind class. + */ +const customEntry = (over: Partial = {}): CustomActivityCatalogEntry => ({ + id: 'id-1', key: 'disk_check', type: 'custom:disk_check', name: 'Disk Check', description: null, + icon: 'bolt', color: '#ff8800', runsRemote: false, timeout: 'always', + inputs: [], outputs: [], isEnabled: true, version: 1, ...over, +}); + +function renderIcon(type: string, size?: number) { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + return svg!; +} + +describe('ActivityIcon', () => { + beforeEach(() => useCustomActivityCatalogStore.getState().setCatalog([])); + + it('rendersBuiltInType_UsesDesignTokenColor', () => { + const svg = renderIcon('sql'); + expect(svg.style.color).toBe('var(--act-sql-color)'); + }); + + it('rendersBuiltInType_CarriesNoTailwindColorLiteral', () => { + // The whole point of the token switch: no `text--` class may come back. + for (const type of ['runScript', 'manualTrigger', 'delay', 'decision', 'textFileEdit']) { + const svg = renderIcon(type); + expect(svg.getAttribute('class') ?? '').not.toMatch(/\btext-[a-z]+-\d{3}\b/); + expect(svg.style.color).toBe(`var(--act-${type}-color)`); + } + }); + + it('rendersTypesThatTheOldColorTableMissed', () => { + // These five were absent from the deleted `iconColors` map and fell through to the muted + // default; they now get their own token like every other catalog entry. + for (const type of ['textFileEdit', 'forEach', 'startWorkflow', 'returnData', 'llmQuery']) { + expect(renderIcon(type).style.color).toBe(`var(--act-${type}-color)`); + } + }); + + it('rendersCustomActivity_UsesRuntimeAccentColor', () => { + useCustomActivityCatalogStore.getState().setCatalog([customEntry()]); + // jsdom may serialise a hex literal as rgb() — accept either spelling of the same colour. + expect(['#ff8800', 'rgb(255, 136, 0)']).toContain(renderIcon('custom:disk_check').style.color); + }); + + it('rendersCustomActivityWithoutColor_FallsBackToIndigoAccent', () => { + useCustomActivityCatalogStore.getState().setCatalog([customEntry({ color: null })]); + expect(['#6366f1', 'rgb(99, 102, 241)']).toContain(renderIcon('custom:disk_check').style.color); + }); + + it('rendersUnknownType_FallsBackToRunScriptVisual', () => { + expect(renderIcon('totallyUnknown').style.color).toBe('var(--act-runScript-color)'); + }); + + it('honoursTheSizeProp', () => { + const svg = renderIcon('sql', 18); + expect(svg.getAttribute('width')).toBe('18'); + expect(svg.getAttribute('height')).toBe('18'); + }); +}); diff --git a/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts b/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts index 1d263375..6b40a8af 100644 --- a/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts +++ b/src/nodepilot-ui/src/__tests__/lib/configClone.test.ts @@ -1,19 +1,10 @@ import { describe, it, expect } from 'vitest'; import { - skippedConfigKeys, isRemoteActivityType, buildClonedDataPatch, applyClonedPatch, } from '../../lib/configClone'; -describe('configClone — skippedConfigKeys', () => { - it('returns empty list for activities with no type-specific skip rules', () => { - expect(skippedConfigKeys('runScript')).toEqual([]); - expect(skippedConfigKeys('sql')).toEqual([]); - expect(skippedConfigKeys('unknownActivity')).toEqual([]); - }); -}); - describe('configClone — isRemoteActivityType', () => { it('identifies remote-capable activities', () => { expect(isRemoteActivityType('runScript')).toBe(true); @@ -60,6 +51,19 @@ describe('configClone — buildClonedDataPatch (scope=all)', () => { }); }); + it('copies unknown/arbitrary config keys verbatim — there is no skip list', () => { + const source = { + activityType: 'runScript', + config: { script: 'Get-Date', someFutureKey: 'kept', result: 'kept-too' }, + }; + const patch = buildClonedDataPatch(source, 'runScript', 'all'); + expect(patch.__configPatch).toEqual({ + script: 'Get-Date', + someFutureKey: 'kept', + result: 'kept-too', + }); + }); + it('does NOT copy label or outputVariable (those identify the step itself)', () => { const patch = buildClonedDataPatch(sourceRunScript, 'runScript', 'all'); expect(patch).not.toHaveProperty('label'); diff --git a/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx index aedc6e1e..baa24c3a 100644 --- a/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/MetricsPage.test.tsx @@ -15,7 +15,6 @@ const server = setupServer( http.get(`${BASE}/api/observability/config`, () => HttpResponse.json({ enabled: true, prometheusAvailable: true, grafanaBaseUrl: 'http://localhost:3000' })), http.get(`${BASE}/api/observability/dashboards/:key`, () => HttpResponse.json({ available: true, key: 'mission-control', title: 'Mission Control', - panels: [], series: [], tables: [], widgets: [ { id: 1, title: 'Active executions', description: null, type: 'stat', unit: 'short', grid: { x: 0, y: 0, width: 3, height: 4 }, data: [{ label: 'Value', labels: {}, points: [{ timestamp: 1, value: 3 }] }], error: null }, { id: 2, title: 'Top failing workflows', description: null, type: 'bargauge', unit: 'short', grid: { x: 0, y: 4, width: 12, height: 8 }, data: [{ label: 'Import users', labels: { workflow_name: 'Import users' }, points: [{ timestamp: 1, value: 4 }] }], error: null }, diff --git a/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx b/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx index 8c4c38b2..71080ca9 100644 --- a/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx +++ b/src/nodepilot-ui/src/components/designer/library/NodeLibrary.tsx @@ -1,46 +1,24 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { getWorkflowSnippets } from '../../../lib/workflowSnippets'; -import { ACTIVITY_ICONS } from '../../../lib/activityCatalog.generated'; -import { isCustomActivityType, getCustomActivityFacts } from '../../../lib/customActivities'; import { ACTIVITY_ICON_COMPONENTS, FALLBACK_ACTIVITY_ICON } from '../../../lib/activityIcons'; +import { getActivityVisual } from '../nodes/activityConfig'; import { ChevronDown } from '@carbon/icons-react'; -const iconColors: Record = { - // Triggers - manualTrigger: 'text-red-600', scheduleTrigger: 'text-yellow-600', webhookTrigger: 'text-orange-600', - fileWatcherTrigger: 'text-green-600', databaseTrigger: 'text-purple-600', eventLogTrigger: 'text-sky-600', - // Activities - runScript: 'text-blue-600', fileOperation: 'text-amber-600', folderOperation: 'text-amber-600', fileHash: 'text-violet-600', - zipOperation: 'text-yellow-700', serviceManagement: 'text-green-600', scheduledTask: 'text-fuchsia-600', - registryOperation: 'text-purple-600', wmiQuery: 'text-cyan-600', startProgram: 'text-rose-600', - powerManagement: 'text-red-700', waitForCondition: 'text-cyan-600', - restApi: 'text-orange-600', - sql: 'text-sky-700', xmlQuery: 'text-teal-600', jsonQuery: 'text-amber-700', - emailNotification: 'text-pink-600', delay: 'text-on-surface-variant', log: 'text-slate-600', - // Control Flow - junction: 'text-indigo-600', decision: 'text-indigo-700', -}; - +/** + * Palette/picker glyph for an activity type. + * + * Icon *and* accent come from {@link getActivityVisual} — the same resolver the canvas nodes + * use, so palette and canvas can never drift apart. Built-ins resolve to the generated + * `--act--*` design tokens (which carry their own dark-mode values); custom activities + * (`custom:`) resolve to the runtime catalog's icon + accent. The colour is a CSS + * variable, so it rides on `style` — there is no Tailwind class for it. + */ export function ActivityIcon({ type, size = 20 }: Readonly<{ type: string; size?: number }>) { - // Custom activities (custom:) carry their own icon + optional accent colour in the runtime - // catalog — they have no static ACTIVITY_ICONS / iconColors entry. - if (isCustomActivityType(type)) { - const facts = getCustomActivityFacts(type); - const CustomIcon = ACTIVITY_ICON_COMPONENTS[facts?.icon ?? ''] ?? FALLBACK_ACTIVITY_ICON; - return ( - - ); - } - - const colorClass = iconColors[type] || 'text-on-surface-variant'; - const Icon = ACTIVITY_ICON_COMPONENTS[ACTIVITY_ICONS[type] || 'help'] ?? FALLBACK_ACTIVITY_ICON; + const { icon, color } = getActivityVisual(type); + const Icon = ACTIVITY_ICON_COMPONENTS[icon] ?? FALLBACK_ACTIVITY_ICON; - return ; + return ; } export function SnippetsSection({ collapsed, onToggle, onInsert, canWrite = true }: Readonly<{ diff --git a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json index f49eab40..14588fda 100644 --- a/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/de/adminSettings.json @@ -51,7 +51,6 @@ "confirmEnablePrompt": "Tippe: {{phrase}}", "confirmEnableButton": "Ja, aktivieren" }, - "comingSoon": "Kommt in einer späteren Version", "restartBannerTitle": "Service-Neustart erforderlich", "restartBannerBody": "Folgende Einstellungen wurden gespeichert, sind aber erst nach einem Service-Neustart aktiv:", "restartBannerSince": "ausstehend seit {{since}}", @@ -83,7 +82,6 @@ "errorTitle": "Fehler", "validationErrorsTitle": "Diese Werte würden den nächsten Service-Start verhindern:", "noSections": "Keine Sektionen konfiguriert.", - "comingSoonSectionTitle": "Diese Sektion kommt in einer späteren Version", "smtpEnableSslLabel": "TLS aktivieren (EnableSsl)", "smtpEnableSslWarning": "Warnung: Benutzername ist gesetzt, aber TLS ist deaktiviert. SMTP-Anmeldedaten und Nachrichteninhalte werden im Klartext übertragen. Nur für lokale, nicht öffentlich erreichbare Relays empfohlen.", "perf": { @@ -159,7 +157,9 @@ "requireWebhookSecret": "X-Webhook-Secret-Header erforderlich", "requireWebhookSecretHint": "Ein webhookTrigger ohne konfiguriertes Secret feuert nicht. Verifiziert wird je nach signatureMode des Triggers als X-Webhook-Secret-Header oder als HMAC-Signatur; der HMAC-Modus ist immer fail-closed, unabhängig von diesem Schalter. Aus heißt: wer die Webhook-URL kennt, kann den Workflow auslösen — jeder secret-lose Treffer wird zusätzlich als Warnung geloggt.", "externalTriggerApiKey": "API-Schlüssel", - "externalTriggerHint": "Leer/gelöscht deaktiviert <0>POST /api/trigger/... vollständig (503).", + "externalTriggerHint": "Legacy-Schlüssel für <0>POST /api/trigger/.... Er kann nur explizit gelistete Workflows mit aktivem Manual/API-Trigger starten. Neue Integrationen sollten gehashte, eingeschränkte Schlüssel-Einträge in der Konfiguration verwenden.", + "externalTriggerAllowedWorkflowIds": "Erlaubte Workflow-IDs (Legacy-Schlüssel)", + "externalTriggerAllowedWorkflowIdsHint": "Leer bedeutet alles verweigern. Unveränderliche Workflow-GUIDs verwenden, ein Eintrag pro Workflow; Namen und Wildcards sind nicht erlaubt.", "strictAllowedHosts": "AllowedHosts='*' beim Start ablehnen (StrictAllowedHosts)", "strictAllowedHostsHint": "Bricht den Dienststart ab, wenn AllowedHosts unsicher weit gefasst ist: '*', leer oder ein nicht ersetzter Platzhalter aus dem Deployment-Template. Wirkt ausschließlich beim Boot, nicht zur Laufzeit — nach einer Änderung hier entscheidet erst der nächste Start. Ohne diesen Schalter bleibt eine weit offene Liste nur eine Warnung im Log.", "allowedHostsField": "AllowedHosts (semikolon-getrennt)", diff --git a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json index 856224b5..f31fbf01 100644 --- a/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json +++ b/src/nodepilot-ui/src/i18n/locales/en/adminSettings.json @@ -51,7 +51,6 @@ "confirmEnablePrompt": "Type: {{phrase}}", "confirmEnableButton": "Yes, enable" }, - "comingSoon": "Coming in a later version", "restartBannerTitle": "Service restart required", "restartBannerBody": "The following settings have been saved but will only take effect after a service restart:", "restartBannerSince": "pending since {{since}}", @@ -83,7 +82,6 @@ "errorTitle": "Error", "validationErrorsTitle": "These values would prevent the next service start:", "noSections": "No sections configured.", - "comingSoonSectionTitle": "This section is coming in a later version", "smtpEnableSslLabel": "Enable TLS (EnableSsl)", "smtpEnableSslWarning": "Warning: a username is configured but TLS is disabled. SMTP credentials and message bodies will travel in plaintext. Only recommended for local, non-public relays.", "perf": { @@ -159,7 +157,9 @@ "requireWebhookSecret": "Require X-Webhook-Secret header", "requireWebhookSecretHint": "A webhookTrigger without a configured secret will not fire. Verification follows the trigger's signatureMode — either the X-Webhook-Secret header or an HMAC signature; HMAC mode is always fail-closed regardless of this switch. Off means anyone who knows the webhook URL can start the workflow — every secret-less hit is additionally logged as a warning.", "externalTriggerApiKey": "API key", - "externalTriggerHint": "Empty/cleared disables <0>POST /api/trigger/... entirely (503).", + "externalTriggerHint": "Legacy key for <0>POST /api/trigger/.... It can start only explicitly listed workflows that contain an enabled Manual/API trigger. New integrations should use hashed, scoped key entries in configuration.", + "externalTriggerAllowedWorkflowIds": "Allowed workflow IDs (legacy key)", + "externalTriggerAllowedWorkflowIdsHint": "Empty means deny all. Use immutable workflow GUIDs, one entry per workflow; names and wildcards are not accepted.", "strictAllowedHosts": "Reject AllowedHosts='*' at boot (StrictAllowedHosts)", "strictAllowedHostsHint": "Aborts service start-up when AllowedHosts is unsafely wide: '*', empty, or a deployment-template placeholder that was never substituted. It applies at boot only, never at runtime — a change here takes effect on the next start. Without this switch, a wide-open list is only a warning in the log.", "allowedHostsField": "AllowedHosts (semicolon-separated)", diff --git a/src/nodepilot-ui/src/lib/configClone.ts b/src/nodepilot-ui/src/lib/configClone.ts index 5577fd68..0525bf9e 100644 --- a/src/nodepilot-ui/src/lib/configClone.ts +++ b/src/nodepilot-ui/src/lib/configClone.ts @@ -7,31 +7,6 @@ import { REMOTE_ACTIVITY_TYPES } from './activityCatalog.generated'; */ export const SHARED_NODE_CLONE_KEYS = ['targetMachineId', 'credentialId'] as const; -/** - * Per-activity-type list of `data.config.*` keys to skip when cloning. The default rule is - * "copy the entire config" — when the user picks a source step they want the whole step, - * not a half-copy that forces them to re-enter the script body. Skip-list exists only for - * fields that would always be wrong on the target (e.g. an inherited execution-time `result` - * stamped into the source's config from a previous run). - * - * Default = empty skip list, so adding a new activity type needs no entry here. - */ -const CONFIG_CLONE_SKIP_KEYS_BY_TYPE: Record> = { - // No type-specific skips today — defaults handled by SHARED_CONFIG_SKIP_KEYS below. -}; - -/** Keys that never make sense to copy regardless of activity type — runtime-stamped state. */ -const SHARED_CONFIG_SKIP_KEYS: ReadonlyArray = []; - -/** - * Returns the list of `config.*` keys that are explicitly skipped for an activity type. Used - * by tests + the popover preview text. Empty array → "everything in config is cloneable". - */ -export function skippedConfigKeys(activityType: string): ReadonlyArray { - const typeSpecific = CONFIG_CLONE_SKIP_KEYS_BY_TYPE[activityType] ?? []; - return [...SHARED_CONFIG_SKIP_KEYS, ...typeSpecific]; -} - /** * Returns true if `targetMachineId` + `credentialId` are meaningful for this activity. * Used to decide whether the clone-picker should offer cross-type Remote-→-Remote @@ -52,7 +27,7 @@ export type CloneScope = 'all' | 'remoteOnly'; * dragging timeout/retry policy along. * * `scope = 'all'` requires identical activity types — copies the shared keys plus the - * type-specific cloneable config keys. + * complete source config. */ export function buildClonedDataPatch( source: Record, @@ -83,15 +58,10 @@ export function buildClonedDataPatch( } // Take the entire source config (including the action payload — script bodies, queries, - // paths, URLs, etc.) and only drop runtime-stamped keys. Users explicitly want a full - // copy: "this new step should look exactly like that one, then I'll edit what I need." + // paths, URLs, etc.). Users explicitly want a full copy: "this new step should look + // exactly like that one, then I'll edit what I need." const sourceConfig = (source.config as Record | undefined) ?? {}; - const skip = new Set(skippedConfigKeys(targetActivityType)); - const configPatch: Record = {}; - for (const [k, v] of Object.entries(sourceConfig)) { - if (skip.has(k)) continue; - configPatch[k] = v; - } + const configPatch: Record = { ...sourceConfig }; if (Object.keys(configPatch).length > 0) { patch.__configPatch = configPatch; } diff --git a/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx b/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx index 63e3cbe1..8e605895 100644 --- a/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx +++ b/src/nodepilot-ui/src/pages/SystemSettingsPage.tsx @@ -25,8 +25,6 @@ import { AiKnowledgeSection } from '../components/admin-settings/AiKnowledgeSect type SubTab = 'integrations' | 'ai-knowledge' | 'retention' | 'system-info' | 'authentication' | 'logging-telemetry' | 'security' | 'performance' | 'db-admin'; -// Tabs are progressively activated as their section is implemented. Disabled tabs -// keep the operator informed about what's on the roadmap. // Order is grouped by topic, not by implementation history: outbound integrations → // security → operations → data. `integrations` stays first because it is also the // default/fallback section for a bare `?tab=system`, and `system-info` sits LAST because @@ -34,21 +32,21 @@ type SubTab = 'integrations' | 'ai-knowledge' | 'retention' | 'system-info' // rather than splitting that run. // Order is presentation only: nothing indexes into this array and deep links address a // section by `?section=`, so reordering breaks no bookmark. -const TABS: { id: SubTab; ready: boolean }[] = [ +const TABS: SubTab[] = [ // External connections — AI knowledge sources hang off the LLM profile configured next door. - { id: 'integrations', ready: true }, - { id: 'ai-knowledge', ready: true }, + 'integrations', + 'ai-knowledge', // Security — the two access/hardening tabs stay adjacent. - { id: 'authentication', ready: true }, - { id: 'security', ready: true }, + 'authentication', + 'security', // Operations. - { id: 'logging-telemetry', ready: true }, - { id: 'performance', ready: true }, + 'logging-telemetry', + 'performance', // Data lifecycle — retention is a database-side concern, so it follows the DB tab. - { id: 'db-admin', ready: true }, - { id: 'retention', ready: true }, + 'db-admin', + 'retention', // Read-only, always last. - { id: 'system-info', ready: true }, + 'system-info', ]; const ICONS: Record> = { @@ -63,19 +61,33 @@ const ICONS: Record> = { 'db-admin': DataBase, }; +// i18n key per sub-tab. Doubles as the whitelist for the `?section=` deep link: a value is +// accepted iff it is a key here, so a new sub-tab cannot be link-addressable without a label. +const LABEL_KEYS: Record = { + 'integrations': 'subTabIntegrations', + 'ai-knowledge': 'subTabAiKnowledge', + 'retention': 'subTabRetention', + 'system-info': 'subTabSystemInfo', + 'authentication': 'subTabAuthentication', + 'logging-telemetry': 'subTabLoggingTelemetry', + 'security': 'subTabSecurity', + 'performance': 'subTabPerformance', + 'db-admin': 'subTabDbAdmin', +}; + +const DEFAULT_SUB_TAB: SubTab = 'integrations'; + +function isSubTab(value: string | null): value is SubTab { + return value !== null && Object.prototype.hasOwnProperty.call(LABEL_KEYS, value); +} + export function SystemSettingsPage() { const { t } = useTranslation(['adminSettings']); // Deep-link: /settings?tab=system§ion= opens the requested sub-tab directly. // The dashboard's "LLM config" shortcut targets `integrations` (SMTP + LLM cards). const [searchParams, setSearchParams] = useSearchParams(); const sectionParam = searchParams.get('section'); - const initialSub: SubTab = - sectionParam === 'integrations' || sectionParam === 'ai-knowledge' || sectionParam === 'retention' - || sectionParam === 'system-info' || sectionParam === 'authentication' - || sectionParam === 'logging-telemetry' || sectionParam === 'security' - || sectionParam === 'performance' || sectionParam === 'db-admin' - ? (sectionParam as SubTab) : 'integrations'; - const active = initialSub; + const active: SubTab = isSubTab(sectionParam) ? sectionParam : DEFAULT_SUB_TAB; const setActive = (next: SubTab) => { const params = new URLSearchParams(searchParams); params.set('tab', 'system'); @@ -83,53 +95,24 @@ export function SystemSettingsPage() { setSearchParams(params); }; - const labelFor = (tab: SubTab): string => { - const key = tab === 'integrations' ? 'subTabIntegrations' - : tab === 'ai-knowledge' ? 'subTabAiKnowledge' - : tab === 'retention' ? 'subTabRetention' - : tab === 'system-info' ? 'subTabSystemInfo' - : tab === 'authentication' ? 'subTabAuthentication' - : tab === 'logging-telemetry' ? 'subTabLoggingTelemetry' - : tab === 'security' ? 'subTabSecurity' - : tab === 'db-admin' ? 'subTabDbAdmin' - : 'subTabPerformance'; - return t(`adminSettings:${key}`); - }; + const labelFor = (tab: SubTab): string => t(`adminSettings:${LABEL_KEYS[tab]}`); return (
- {TABS.map(({ id, ready }) => { + {TABS.map((id) => { const Icon = ICONS[id]; - const isActive = active === id; - if (ready) { - return ( - - ); - } return ( ); })} @@ -149,7 +132,3 @@ export function SystemSettingsPage() {
); } - -// ComingSoonPlaceholder is no longer used now that all four V2 tabs are wired up. -// Keeping the function around would emit an unused-import lint; if a future tab is -// added with a placeholder during scaffolding, re-add it here. diff --git a/src/nodepilot-ui/src/types/api.ts b/src/nodepilot-ui/src/types/api.ts index e3ef99c7..c0fa07a0 100644 --- a/src/nodepilot-ui/src/types/api.ts +++ b/src/nodepilot-ui/src/types/api.ts @@ -135,10 +135,6 @@ export interface TelemetrySummary { } export interface MetricsPoint { timestamp: number; value: number | null; } -export interface MetricsSeriesLine { label: string; points: MetricsPoint[]; } -export interface MetricsSeries { key: string; title: string; unit: string; lines: MetricsSeriesLine[]; } -export interface MetricsTableRow { label: string; value: number; } -export interface MetricsTable { key: string; title: string; unit: string; rows: MetricsTableRow[]; } export interface MetricsDataSeries { label: string; labels: Record; points: MetricsPoint[]; } export interface MetricsWidget { id: number; @@ -154,9 +150,6 @@ export interface MetricsDashboard { available: boolean; key: string; title: string; - panels: TelemetryPanel[]; - series: MetricsSeries[]; - tables: MetricsTable[]; widgets: MetricsWidget[]; } diff --git a/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs b/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs index 8c3c75cc..d9097050 100644 --- a/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs +++ b/tests/NodePilot.Ai.Tests/LlmConfiguredProxyTests.cs @@ -77,6 +77,19 @@ public void Custom_BypassGlob_KeepsALocalEndpointDirect() proxy.IsBypassed(CloudEndpoint).Should().BeFalse(); } + [Fact] + public void Custom_PlaintextLoopback_IsAlwaysDirect_EvenWithoutBypassConfiguration() + { + var (proxy, _) = Build(new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://proxy.corp.local:8080", + }); + + proxy.IsBypassed(LocalEndpoint).Should().BeTrue(); + proxy.GetProxy(LocalEndpoint).Should().BeNull(); + } + [Fact] public void Custom_WithUsername_PresentsNetworkCredential() { @@ -148,8 +161,9 @@ public void ModeChange_TakesEffectWithoutRebuildingTheProxy() } [Fact] - public void AddressChange_RebuildsTheCachedProxy() + public void AddressChange_TakesEffectOnTheNextRequest() { + // The WebProxy is rebuilt per call, so a hot-reloaded address needs no invalidation step. var options = LlmTestOptions.WithProfile(); options.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080" }; var monitor = new MutableOptionsMonitor(options); @@ -165,27 +179,61 @@ public void AddressChange_RebuildsTheCachedProxy() } [Fact] - public void BypassListChange_RebuildsTheCachedProxy() + public void BypassListChange_TakesEffectOnTheNextRequest() { - // The cache compares the source values field by field; a changed bypass list must not be - // masked by an unchanged address. + // The address stays the same — only the bypass globs change. Nothing may carry the old + // bypass regexes over into the next request. var options = LlmTestOptions.WithProfile(); options.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080" }; var monitor = new MutableOptionsMonitor(options); var proxy = new LlmConfiguredProxy(monitor); - proxy.IsBypassed(LocalEndpoint).Should().BeFalse(); + var internalEndpoint = new Uri("https://llm.intern/v1/chat/completions"); + proxy.IsBypassed(internalEndpoint).Should().BeFalse(); var updated = LlmTestOptions.WithProfile(); updated.Proxy = new LlmProxyOptions { Mode = LlmProxyMode.Custom, Address = "http://p1:8080", - BypassList = ["localhost"], + BypassList = ["*.intern"], }; monitor.Set(updated); - proxy.IsBypassed(LocalEndpoint).Should().BeTrue(); + proxy.IsBypassed(internalEndpoint).Should().BeTrue(); + } + + [Fact] + public void CredentialChange_TakesEffectOnTheNextRequest() + { + // Same address, different credentials — the proxy must not serve a previously built one. + var options = LlmTestOptions.WithProfile(); + options.Proxy = new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://p1:8080", + Username = "old", + Password = "old-secret", + }; + var monitor = new MutableOptionsMonitor(options); + var proxy = new LlmConfiguredProxy(monitor); + + proxy.GetProxy(CloudEndpoint).Should().Be(new Uri("http://p1:8080")); + proxy.Credentials.Should().BeOfType().Which.UserName.Should().Be("old"); + + var updated = LlmTestOptions.WithProfile(); + updated.Proxy = new LlmProxyOptions + { + Mode = LlmProxyMode.Custom, + Address = "http://p1:8080", + Username = "new", + Password = "new-secret", + }; + monitor.Set(updated); + + var credential = proxy.Credentials.Should().BeOfType().Subject; + credential.UserName.Should().Be("new"); + credential.Password.Should().Be("new-secret"); } [Fact] diff --git a/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs b/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs index 36384c9d..8f51de51 100644 --- a/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs +++ b/tests/NodePilot.Api.Tests/Logging/CmTraceFormatterTests.cs @@ -107,6 +107,25 @@ public void Format_MessageWithLogTerminatorLiteral_IsSanitized() SmsLine.IsMatch(line).Should().BeTrue($"line was: {line}"); } + [Fact] + public void Format_MessageWithSeveralLogTerminatorLiterals_SanitizesEveryOccurrence() + { + // A pasted multi-line log excerpt carries the terminator once per copied entry. + // Sanitising only the first occurrence would still let the later ones close the + // SMS wrapper early. Includes two adjacent occurrences so a scan that skips past + // its own replacement is exercised too. + var ev = MakeEvent(LogEventLevel.Information, + "a ]LOG]!> b ]LOG]!>]LOG]!> c ]LOG]!>"); + + var line = FormatOne(ev); + + // Exactly one terminator survives — the one at the real end of the message body. + Regex.Count(line, Regex.Escape("]LOG]!>")).Should().Be(1); + SmsLine.IsMatch(line).Should().BeTrue($"line was: {line}"); + var msg = SmsLine.Match(line).Groups["msg"].Value; + msg.Should().Be("a ]LOG]_> b ]LOG]_>]LOG]_> c ]LOG]_>"); + } + [Fact] public void Format_ComponentWithDoubleQuotes_IsSanitized() { diff --git a/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs index 1605a24b..72a667bf 100644 --- a/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/BaseRemoteActivityTests.cs @@ -26,7 +26,23 @@ public BaseRemoteActivityTests() { _db = TestDbContext.Create(); _credentialStore = new Mock(); - _sessionFactory = MockRemoteSession.CreateFactory(); + + var session = new Mock(); + session + .Setup(s => s.ExecuteScriptAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new RemoteExecutionResult + { + Success = true, + Output = "OK", + ErrorOutput = "", + Duration = TimeSpan.FromMilliseconds(100) + }); + session.Setup(s => s.DisposeAsync()).Returns(ValueTask.CompletedTask); + + _sessionFactory = new Mock(); + _sessionFactory + .Setup(f => f.CreateSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(session.Object); } public void Dispose() diff --git a/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs index 3e2bc516..1827742d 100644 --- a/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/ControlFlowActivityTests.cs @@ -132,25 +132,6 @@ public async Task ExecuteAsync_PersistsReturnDataOnExecutionRow() reloaded.ReturnData.Should().NotBeNullOrEmpty(); reloaded.ReturnData.Should().Contain("\"status\":\"ok\""); } - - [Fact] - public async Task ExecuteAsync_RemovesPerExecutionLockAfterWrite() - { - var execId = Guid.NewGuid(); - var wf = new Workflow { Id = Guid.NewGuid(), Name = "WF", DefinitionJson = "{}" }; - _db.Workflows.Add(wf); - _db.WorkflowExecutions.Add(new WorkflowExecution { Id = execId, WorkflowId = wf.Id }); - await _db.SaveChangesAsync(); - - var activity = new ReturnDataActivity(_db); - var ctx = new StepExecutionContext { WorkflowExecutionId = execId, StepId = "r1" }; - - var before = ReturnDataActivity.ActiveLockCount; - var result = await activity.ExecuteAsync(ctx, Parse("{\"data\":{\"status\":\"ok\"}}"), CancellationToken.None); - - result.Success.Should().BeTrue(); - ReturnDataActivity.ActiveLockCount.Should().Be(before); - } } public sealed class StartWorkflowActivityTests : IDisposable diff --git a/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs b/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs index 4bb1d584..6f5158c6 100644 --- a/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs +++ b/tests/NodePilot.Engine.Tests/Activities/StartProgramActivityTests.cs @@ -42,6 +42,26 @@ private static StepExecutionContext Ctx() => private static string CmdPath => Path.Combine(Environment.SystemDirectory, "cmd.exe"); + [Fact] + public void BuildScript_WithAllowedRoots_InjectsTargetGuardForExecutableAndWorkingDirectory() + { + var root = Path.GetPathRoot(CmdPath)!; + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary + { + ["FileSystemOperation:AllowedRoots:0"] = root, + }).Build(); + var script = new Accessor(config).CallBuildScript(Cfg(new + { + filePath = CmdPath, + workingDirectory = Environment.SystemDirectory, + }), Ctx()); + + script.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__filePath)"); + script.Should().Contain("Assert-NodePilotAllowedPath -Candidate ($__workingDir)"); + script.Should().Contain("FileAttributes]::ReparsePoint"); + } + [Fact] public void BuildScript_MissingFilePath_Throws() { @@ -117,8 +137,8 @@ public void BuildScript_UseShellExecuteTrue_RejectedByDefault() { // Phase-3 hardening: an empty IConfiguration now reads the missing // StartProgram:DisallowShellExecute as "true" so a stripped-down deployment falls - // on the safe side. Activities running with a NULL configuration (load harness) - // keep the old permissive behaviour — see BuildScript_UseShellExecuteTrue_NullConfig_Allowed. + // on the safe side. There is no permissive path left — BaseRemoteActivity requires a + // non-null IConfiguration, so the guard applies to every caller. var act = () => new Accessor().CallBuildScript(Cfg(new { filePath = @"C:\data\report.xlsx", diff --git a/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs b/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs index 8799c0d7..ec88f489 100644 --- a/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs +++ b/tests/NodePilot.Engine.Tests/Execution/WorkflowDbWriteMetricsTests.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.Metrics; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Models; +using NodePilot.Core.Telemetry; using NodePilot.Data; +using NodePilot.Engine; using NodePilot.Engine.Execution; using Npgsql; using Xunit; @@ -50,6 +53,153 @@ public async Task SaveChangesMeasuredAsync_PostgresNonUniqueViolation_Propagates await act.Should().ThrowAsync(); } + [Fact] + public async Task ExecuteMeasuredAsync_Success_RecordsSuccessStatusAndRowCount() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var rows = await WorkflowDbWriteMetrics.ExecuteMeasuredAsync(operation, () => Task.FromResult(3)); + rows.Should().Be(3); + }); + + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes" && m.Status == "success"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration" && m.Status == "success"); + measurements.Should().ContainSingle(m => m.Instrument == "nodepilot.db.save_changes.rows") + .Which.Value.Should().Be(3); + } + + [Fact] + public async Task ExecuteMeasuredAsync_Cancellation_RecordsCancelledStatus() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await WorkflowDbWriteMetrics.ExecuteMeasuredAsync( + operation, () => Task.FromException(new OperationCanceledException())); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "cancelled"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration"); + measurements.Should().NotContain(m => m.Instrument == "nodepilot.db.save_changes.rows"); + } + + [Fact] + public async Task ExecuteMeasuredAsync_Failure_RecordsFailureStatus() + { + var operation = UniqueOperation(); + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await WorkflowDbWriteMetrics.ExecuteMeasuredAsync( + operation, () => Task.FromException(new InvalidOperationException("boom"))); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "failure"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes.duration"); + measurements.Should().NotContain(m => m.Instrument == "nodepilot.db.save_changes.rows"); + } + + [Fact] + public async Task SaveChangesMeasuredAsync_AbsorbedUniqueViolation_RecordsSuccessWithZeroRows() + { + var operation = UniqueOperation(); + var pgException = new PostgresException( + "duplicate key value violates unique constraint", + "ERROR", "ERROR", "23505"); + var (connection, ctx) = BuildThrowingContext(new DbUpdateException("retry replay", pgException)); + await using var _ = connection; + await using var __ = ctx; + ctx.Workflows.Add(new Workflow { Id = Guid.NewGuid(), Name = "test" }); + + var measurements = await MeasureAsync(operation, async () => + { + var rows = await ctx.SaveChangesMeasuredAsync(operation, CancellationToken.None); + rows.Should().Be(0); + }); + + measurements.Should().OnlyContain(m => m.Status == null || m.Status == "success"); + measurements.Should().ContainSingle(m => m.Instrument == "nodepilot.db.save_changes.rows") + .Which.Value.Should().Be(0); + } + + [Fact] + public async Task SaveChangesMeasuredAsync_Cancellation_RecordsCancelledStatus() + { + var operation = UniqueOperation(); + var (connection, ctx) = BuildThrowingContext(new OperationCanceledException()); + await using var _ = connection; + await using var __ = ctx; + + var measurements = await MeasureAsync(operation, async () => + { + var act = async () => await ctx.SaveChangesMeasuredAsync(operation, CancellationToken.None); + await act.Should().ThrowAsync(); + }); + + measurements.Should().OnlyContain(m => m.Status == "cancelled"); + measurements.Should().Contain(m => m.Instrument == "nodepilot.db.save_changes"); + } + + private static string UniqueOperation() => $"test.{Guid.NewGuid():N}"; + + /// + /// Runs with a live attached to the + /// DB-write instruments and returns everything that was emitted for . + /// The operation tag is unique per test, so measurements from tests running in parallel are filtered out. + /// + private static async Task> MeasureAsync(string operation, Func action) + { + _ = EngineMetrics.DbSaveChanges; // force instrument creation before the listener starts + var captured = new List(); + var gate = new object(); + + void Record(Instrument instrument, double value, ReadOnlySpan> tags) + { + string? taggedOperation = null; + string? status = null; + foreach (var tag in tags) + { + if (tag.Key == "operation") + taggedOperation = tag.Value as string; + else if (tag.Key == "status") + status = tag.Value as string; + } + + if (taggedOperation != operation) + return; + + lock (gate) + captured.Add(new CapturedMeasurement(instrument.Name, status, value)); + } + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == TelemetryConstants.Meters.Engine + && instrument.Name.StartsWith("nodepilot.db.save_changes", StringComparison.Ordinal)) + l.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, value, tags, _) => Record(instrument, value, tags)); + listener.SetMeasurementEventCallback((instrument, value, tags, _) => Record(instrument, value, tags)); + listener.Start(); + + await action(); + + lock (gate) + return captured.ToList(); + } + + private sealed record CapturedMeasurement(string Instrument, string? Status, double Value); + private static (SqliteConnection conn, ThrowingDbContext ctx) BuildThrowingContext(Exception toThrow) { var connection = new SqliteConnection("DataSource=:memory:"); diff --git a/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs b/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs deleted file mode 100644 index 58ba461d..00000000 --- a/tests/NodePilot.Engine.Tests/Helpers/MockRemoteSession.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Moq; -using NodePilot.Core.Interfaces; -using NodePilot.Core.Models; - -namespace NodePilot.Engine.Tests.Helpers; - -public static class MockRemoteSession -{ - public static Mock CreateFactory(RemoteExecutionResult? defaultResult = null) - { - var result = defaultResult ?? new RemoteExecutionResult - { - Success = true, - Output = "OK", - ErrorOutput = "", - Duration = TimeSpan.FromMilliseconds(100) - }; - - var mockSession = new Mock(); - mockSession - .Setup(s => s.ExecuteScriptAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(result); - mockSession - .Setup(s => s.DisposeAsync()) - .Returns(ValueTask.CompletedTask); - - var mockFactory = new Mock(); - mockFactory - .Setup(f => f.CreateSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(mockSession.Object); - - return mockFactory; - } - - public static Mock CreateFailingFactory(string errorMessage = "Connection failed") - { - var result = new RemoteExecutionResult - { - Success = false, - Output = "", - ErrorOutput = errorMessage, - Duration = TimeSpan.FromMilliseconds(50) - }; - - return CreateFactory(result); - } -} diff --git a/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs b/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs new file mode 100644 index 00000000..41344b4d --- /dev/null +++ b/tests/NodePilot.Mcp.Tests/Analysis/DefinitionDiffTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using FluentAssertions; +using NodePilot.Mcp.Analysis; +using Xunit; + +namespace NodePilot.Mcp.Tests.Analysis; + +/// +/// Pins the equality contract of the by-id definition diff. The comparison is semantic +/// (JsonNode.DeepEquals): property order and number representation do not matter, array +/// order and actual value changes do. +/// +public sealed class DefinitionDiffTests +{ + private static JsonElement E(string json) => JsonDocument.Parse(json).RootElement; + + [Fact] + public void Diff_SameNodeWithSwappedPropertyOrderAndNumberFormat_NotModified() + { + // Same node, three cosmetic differences only: top-level property order, nested property + // order inside data/position, and 100 written as 100.0 / 200 as 2e2. + var current = E(""" + {"nodes":[ + {"id":"a","type":"activity","position":{"x":100,"y":200}, + "data":{"label":"Check Disk","activityType":"runScript","config":{"timeoutSeconds":60}}}], + "edges":[]} + """); + var proposed = E(""" + {"nodes":[ + {"data":{"config":{"timeoutSeconds":60.0},"activityType":"runScript","label":"Check Disk"}, + "position":{"y":2e2,"x":100.0},"type":"activity","id":"a"}], + "edges":[]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + diff.Nodes.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_ChangedConfigValue_ReportsModified() + { + var current = E(""" + {"nodes":[{"id":"a","data":{"config":{"timeoutSeconds":60}}}],"edges":[]} + """); + var proposed = E(""" + {"nodes":[{"id":"a","data":{"config":{"timeoutSeconds":90}}}],"edges":[]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Modified.Should().Equal("a"); + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + } + + [Fact] + public void Diff_ReorderedArrayValue_ReportsModified() + { + // Arrays stay order-sensitive — only object property order is ignored. + var current = E(""" + {"nodes":[{"id":"a","data":{"config":{"tags":["x","y"]}}}],"edges":[]} + """); + var proposed = E(""" + {"nodes":[{"id":"a","data":{"config":{"tags":["y","x"]}}}],"edges":[]} + """); + + DefinitionDiff.Diff(current, proposed).Nodes.Modified.Should().Equal("a"); + } + + [Fact] + public void Diff_AddedRemovedAndModified_AreReportedSortedById() + { + var current = E(""" + {"nodes":[{"id":"keep"},{"id":"gone"},{"id":"b","data":{"label":"old"}}], + "edges":[{"id":"e1","source":"a","target":"b"}]} + """); + var proposed = E(""" + {"nodes":[{"id":"keep"},{"id":"zNew"},{"id":"aNew"},{"id":"b","data":{"label":"new"}}], + "edges":[{"id":"e1","target":"b","source":"a"}]} + """); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().Equal("aNew", "zNew"); + diff.Nodes.Removed.Should().Equal("gone"); + diff.Nodes.Modified.Should().Equal("b"); + diff.Edges.Added.Should().BeEmpty(); + diff.Edges.Removed.Should().BeEmpty(); + diff.Edges.Modified.Should().BeEmpty(); + } + + [Fact] + public void Diff_MissingArraysAndNonObjectItems_AreIgnored() + { + var current = E("""{"nodes":["not-an-object",{"noId":true},{"id":42}]}"""); + var proposed = E("""{"edges":[{"id":"e1"}]}"""); + + var diff = DefinitionDiff.Diff(current, proposed); + + diff.Nodes.Added.Should().BeEmpty(); + diff.Nodes.Removed.Should().BeEmpty(); + diff.Nodes.Modified.Should().BeEmpty(); + diff.Edges.Added.Should().Equal("e1"); + } +} From 29b0417f07bea2dd8db5488862ca10429a39d6f6 Mon Sep 17 00:00:00 2001 From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:50:05 +0200 Subject: [PATCH 5/5] Cover the system-settings sub-tab routing with a test SystemSettingsPage had no vitest file, so the LABEL_KEYS record that replaced the nine-way || chain went in unguarded. It doubles as the whitelist for the ?section= deep link, which makes the mapping worth pinning: one tab per sub-tab, every label translated (a missing key would render as "adminSettings:subTabX"), each section reachable by deep link, an unknown value falling back to integrations, and a click writing tab=system§ion= back into the query string. --- .../pages/SystemSettingsPage.test.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx diff --git a/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx new file mode 100644 index 00000000..e5409067 --- /dev/null +++ b/src/nodepilot-ui/src/__tests__/pages/SystemSettingsPage.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router'; +import { SystemSettingsPage } from '../../pages/SystemSettingsPage'; + +// The page's own job is tab selection: which section is active, and how `?section=` maps onto +// it. The nine sections each fetch their own config, so they are stubbed down to a marker — +// mounting them would test their data loading, not this page's routing. +vi.mock('../../components/admin-settings/RestartBanner', () => ({ RestartBanner: () => null })); +vi.mock('../../components/admin-settings/IntegrationsSection', () => ({ IntegrationsSection: () =>
section:integrations
})); +vi.mock('../../components/admin-settings/AiKnowledgeSection', () => ({ AiKnowledgeSection: () =>
section:ai-knowledge
})); +vi.mock('../../components/admin-settings/AuthenticationSection', () => ({ AuthenticationSection: () =>
section:authentication
})); +vi.mock('../../components/admin-settings/SecuritySection', () => ({ SecuritySection: () =>
section:security
})); +vi.mock('../../components/admin-settings/LoggingTelemetrySection', () => ({ LoggingTelemetrySection: () =>
section:logging-telemetry
})); +vi.mock('../../components/admin-settings/PerformanceSection', () => ({ PerformanceSection: () =>
section:performance
})); +vi.mock('../../components/admin-settings/DbAdminSection', () => ({ DbAdminSection: () =>
section:db-admin
})); +vi.mock('../../components/admin-settings/RetentionSection', () => ({ RetentionSection: () =>
section:retention
})); +vi.mock('../../components/admin-settings/SystemInfoSection', () => ({ SystemInfoSection: () =>
section:system-info
})); + +const SUB_TABS = [ + 'integrations', 'ai-knowledge', 'authentication', 'security', + 'logging-telemetry', 'performance', 'db-admin', 'retention', 'system-info', +]; + +function SearchParamsProbe() { + const [params] = useSearchParams(); + return
{params.toString()}
; +} + +function renderPage(path = '/settings?tab=system') { + return render( + + + } /> + + , + ); +} + +describe('SystemSettingsPage', () => { + it('renders one tab per sub-tab and opens the default section', () => { + renderPage(); + + expect(screen.getAllByRole('button')).toHaveLength(SUB_TABS.length); + expect(screen.getByText('section:integrations')).toBeInTheDocument(); + }); + + it('labels every tab from the i18n catalogue, never a raw key', () => { + renderPage(); + + for (const button of screen.getAllByRole('button')) { + // A missing translation makes i18next echo the key back — "adminSettings:subTabSecurity" + // rendered as a label is exactly the drift this asserts against. + expect(button.textContent?.trim()).not.toBe(''); + expect(button.textContent).not.toContain('subTab'); + expect(button.textContent).not.toContain('adminSettings:'); + } + }); + + it.each(SUB_TABS)('opens %s from a ?section= deep link', (section) => { + renderPage(`/settings?tab=system§ion=${section}`); + + expect(screen.getByText(`section:${section}`)).toBeInTheDocument(); + }); + + it('falls back to the default section for an unknown ?section= value', () => { + renderPage('/settings?tab=system§ion=does-not-exist'); + + expect(screen.getByText('section:integrations')).toBeInTheDocument(); + }); + + it('writes the selected section into the query string and keeps tab=system', async () => { + renderPage(); + + const securityTab = screen.getAllByRole('button').find((b) => b.textContent?.includes('Security')); + await userEvent.click(securityTab!); + + expect(screen.getByText('section:security')).toBeInTheDocument(); + expect(screen.getByTestId('query').textContent).toBe('tab=system§ion=security'); + }); +});