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/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..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,13 +116,15 @@ 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; - - 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 +134,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."); @@ -139,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.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/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/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/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/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/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/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.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.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..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", @@ -191,10 +184,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/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/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..1bcc26c4 100644 --- a/src/NodePilot.Engine/Activities/ReturnDataActivity.cs +++ b/src/NodePilot.Engine/Activities/ReturnDataActivity.cs @@ -1,8 +1,8 @@ -using System.Collections.Concurrent; using System.Text.Json; using Microsoft.EntityFrameworkCore; using NodePilot.Core.Interfaces; using NodePilot.Data; +using NodePilot.Engine.PowerShell; using NodePilot.Engine.Security; namespace NodePilot.Engine.Activities; @@ -15,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; @@ -77,12 +63,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; @@ -109,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 { @@ -132,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/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..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 @@ -312,24 +314,17 @@ 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) { 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/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/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/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/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.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/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.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/__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/__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'); + }); +}); 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/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/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..7be93685 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,34 +356,44 @@ 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; +}>) { + const { open, toggle, query, setQuery, containerRef, popoverRef, searchRef } = picker; return (
- +
@@ -397,45 +402,88 @@ export function VariablePicker({ type="text" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder={t('properties:searchVariable')} + 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 +492,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 +501,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 +626,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/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/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..0525bf9e 100644 --- a/src/nodepilot-ui/src/lib/configClone.ts +++ b/src/nodepilot-ui/src/lib/configClone.ts @@ -7,44 +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]; -} - -/** - * 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 @@ -65,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, @@ -96,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/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/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/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/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.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.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 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.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/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/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/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/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/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.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/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"); + } +} 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; } = ""; +}