From 49f5420d056a6af1162ea91f80da8033d2bb9d58 Mon Sep 17 00:00:00 2001
From: Sev7eNup <79143581+Sev7eNup@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:10:02 +0200
Subject: [PATCH 1/6] Collapse duplicated code across the solution
A repo-wide clone scan plus a semantic pass found ~85 verified places where the
same logic existed more than once. This removes them, mostly by routing callers
through a helper or base type that already existed.
Backend:
- Five retention services shared a byte-identical ExecuteAsync loop; they now
derive from LeaderGatedRetentionService. The broad catch stays in each
service's RunIterationAsync, one level below the host-fatal boundary.
- fileOperation/folderOperation share FileSystemOperationActivityBase; the
marker-envelope PostProcess preamble is now one helper used by seven activities.
- The AES-GCM envelope (version byte + nonce + ciphertext + tag) had two
independent implementations; both now use SecretEnvelope. Header validation
stays outside the metrics scope, as before.
- Crypto call/latency instrumentation collapses into DataMetrics.MeasureCrypto.
- The ExecRow projection, the long-running/queued-long collectors, the
NotificationDispatcher send tail, CsvField, the trigger manual.* extraction
and the folder-tree helpers each exist once now.
- ScimProvisioningService now forwards audits through AuditEventForwarder
instead of hand-rolling the ECS scope. This fixes a real inconsistency: it
hard-coded event.category=iam where the shared classifier returns
"configuration" for SCIM_GROUP_* codes.
Clients:
- ApiException, the response plumbing and the config.json read side move to
NodePilot.Core.Clients, shared by the CLI and the MCP server. The DTO copies
stay duplicated on purpose (ApiDtoParityTests).
Frontend:
- The AI chat message mutators and the thread picker were duplicated between
the designer panel and the global chat page; both now share one module.
- LoggingTelemetrySection carried a private fork of useSectionForm. The fork had
drifted and was the better version (it re-sends the mapped payload after a
412), so that behaviour moved into the shared hook before the fork was removed.
- useDashboardFeed/useOperationsFeed, the type-the-phrase confirm dialog and the
node context menu now use shared implementations.
Dead code: three unused DbErrorClassifier predicates, a deprecated
cloneableConfigKeys alias, four unused frontend exports, two orphaned stress
scripts and an unused npm dependency.
Test doubles: FakeSmtpServer, CapturingLogger, StubHttpClientFactory,
StubGlobalVariableStore and FakeEngine each existed in two places; they now live
in TestCommons or a shared Helpers file.
ActivityConfigReferenceTests learned to follow an executor's base class and
helpers, so config keys that moved into shared types stay verified instead of
being exempted.
Net effect: 1407 fewer lines of code. Full backend suites (5486 tests) and the
frontend suite (2596 tests) pass; the solution builds clean.
---
scripts/stress-test-100-executions.py | 274 ----------
scripts/stress-test-master-workflow.py | 276 ----------
src/NodePilot.Ai/ChatToolDispatch.cs | 69 +++
src/NodePilot.Ai/ChatToolRegistry.cs | 44 +-
.../Knowledge/DocsKnowledgeReader.cs | 41 +-
.../Knowledge/KnowledgeChatToolRegistry.cs | 44 +-
.../Knowledge/KnowledgeCorpusReader.cs | 58 ++
.../Knowledge/KnowledgeTimeContext.cs | 21 +-
.../Knowledge/SourceCodeKnowledgeReader.cs | 49 +-
src/NodePilot.Ai/LlmConfiguredProxy.cs | 7 +-
src/NodePilot.Ai/LlmJson.cs | 20 +
src/NodePilot.Ai/LlmProfileValidation.cs | 26 +-
src/NodePilot.Ai/OpenAiCompatibleLlmClient.cs | 66 +--
src/NodePilot.Ai/OpenAiResponsesLlmClient.cs | 21 +-
src/NodePilot.Ai/ToolCallAccumulator.cs | 7 +
src/NodePilot.Ai/WorkflowAssistantService.cs | 22 +-
.../Controllers/AiChatController.cs | 57 +-
src/NodePilot.Api/Controllers/AiController.cs | 54 +-
.../Controllers/AiKnowledgeController.cs | 65 +--
.../Controllers/AiStreamSupport.cs | 79 +++
.../Controllers/AlertingController.cs | 81 +--
.../Controllers/AlertingRuleMapping.cs | 165 ++++++
src/NodePilot.Api/Controllers/ApiProblems.cs | 33 --
.../Controllers/AuditController.cs | 29 +-
.../Controllers/AuthController.cs | 272 ++++------
.../Controllers/CustomActivitiesController.cs | 96 ++--
.../Controllers/DashboardController.cs | 17 +-
.../Controllers/DbAdminController.cs | 27 +-
.../Controllers/DiagnosticsController.cs | 31 +-
.../Controllers/ExecutionsController.cs | 9 +-
.../Controllers/ExternalTriggerController.cs | 34 +-
.../Controllers/FolderScopedQueries.cs | 34 ++
.../Controllers/OperationsController.cs | 13 +-
.../Controllers/SystemAlertingController.cs | 77 +--
.../Controllers/WorkflowEditingController.cs | 250 ++++-----
.../WorkflowImportExportController.cs | 9 +-
.../Controllers/WorkflowsController.cs | 26 +-
src/NodePilot.Api/Export/CsvWriter.cs | 29 +
.../Security/DirectoryGroupPrincipal.cs | 38 ++
.../Security/DirectoryMembershipReconciler.cs | 72 +++
.../Ldap/DirectorySynchronizationService.cs | 25 +-
.../Security/Ldap/ExternalUserMapper.cs | 42 +-
.../Security/Oidc/OidcIdentityMapper.cs | 66 +--
.../Security/ResourceAuthorizationService.cs | 32 +-
.../Security/Scim/ScimProvisioningService.cs | 28 +-
.../SubWorkflowAuthorizationResolver.cs | 16 +-
.../Services/Backup/BackupRestoreService.cs | 510 +++++++++---------
.../Services/Backup/FolderTreeShape.cs | 84 +++
.../Services/Backup/Parts/FolderBackupPart.cs | 14 +-
.../Parts/GlobalVariableFolderBackupPart.cs | 17 +-
.../WorkflowDefinitionSecretRewriter.cs | 24 +-
src/NodePilot.Cli/Api/ApiException.cs | 45 --
src/NodePilot.Cli/Api/NodePilotApiClient.cs | 34 +-
src/NodePilot.Cli/Api/TokenRefreshHandler.cs | 1 +
src/NodePilot.Cli/CLAUDE.md | 2 +
.../Commands/Auth/AuthCommands.cs | 1 +
src/NodePilot.Cli/Commands/BaseCommand.cs | 1 +
.../Commands/Config/ConfigCommands.cs | 1 +
.../Workflow/WorkflowTriggerCommand.cs | 1 +
src/NodePilot.Cli/Settings/ConfigStore.cs | 54 +-
.../Clients}/ApiException.cs | 10 +-
.../Clients/ApiResponseReader.cs | 43 ++
.../Clients/ClientConfigStore.cs} | 21 +-
src/NodePilot.Data/DataMetrics.cs | 34 ++
src/NodePilot.Data/DbErrorClassifier.cs | 9 -
.../Security/AesGcmSecretProtector.cs | 98 +---
.../Security/DpapiSecretProtector.cs | 59 +-
.../Security/PassphraseSecretProtector.cs | 45 +-
src/NodePilot.Data/Security/SecretEnvelope.cs | 83 +++
.../Activities/BaseActivity.cs | 52 +-
.../Activities/FileHashActivity.cs | 13 +-
.../Activities/FileOperationActivity.cs | 167 +-----
.../FileSystemOperationActivityBase.cs | 182 +++++++
.../Activities/FolderOperationActivity.cs | 187 ++-----
.../Activities/ForEachActivity.cs | 84 +--
.../Activities/JsonQueryActivity.cs | 46 +-
.../Activities/QueryPayloadSource.cs | 58 ++
.../Activities/RegistryActivity.cs | 31 +-
.../Activities/ReturnDataActivity.cs | 8 +-
.../Activities/ScheduledTaskActivity.cs | 48 +-
.../Activities/ServiceManagementActivity.cs | 44 +-
.../Activities/StartProgramActivity.cs | 14 +-
.../Activities/StartWorkflowActivity.cs | 101 ++--
.../Activities/SubWorkflowInvocation.cs | 123 +++++
.../Activities/TextFileEditActivity.cs | 14 +-
.../Activities/XmlQueryActivity.cs | 43 +-
.../Activities/ZipOperationActivity.cs | 13 +-
.../Conditions/ConditionEvaluator.cs | 18 +-
.../Debug/DebugCoordinator.cs | 4 +-
src/NodePilot.Engine/Execution/StepRunner.cs | 5 +-
.../Execution/VariableResolver.cs | 42 +-
.../PowerShell/PowerShellOperation.cs | 23 +
.../PowerShell/ProcessExecutionEngine.cs | 29 +-
src/NodePilot.Engine/Scorch/ScorchImporter.cs | 25 +-
.../Security/FileWatcherPathGuard.cs | 12 +-
.../Security/OutputRedactor.cs | 19 +
src/NodePilot.Engine/Security/PathGuard.cs | 35 +-
.../Security/RestApiHttpClientProvider.cs | 12 +-
.../Triggers/DatabaseTrigger.cs | 5 +-
.../Triggers/EventLogTrigger.cs | 5 +-
.../Triggers/FileWatcherTrigger.cs | 5 +-
.../Triggers/ScheduleTrigger.cs | 11 +-
.../Triggers/TriggerVariables.cs | 23 +
.../Triggers/WebhookTrigger.cs | 5 +-
src/NodePilot.Engine/WorkflowEngine.cs | 81 +--
src/NodePilot.Mcp/Api/NodePilotApiClient.cs | 34 +-
src/NodePilot.Mcp/Auth/TokenStore.cs | 4 +-
src/NodePilot.Mcp/CLAUDE.md | 2 +
src/NodePilot.Mcp/Config/McpServerConfig.cs | 5 +-
src/NodePilot.Mcp/Mapping/ApiErrorMapper.cs | 1 +
src/NodePilot.Mcp/Program.cs | 3 +-
.../AuditLogRetentionService.cs | 71 +--
.../ExecutionRetentionService.cs | 79 +--
.../LeaderGatedRetentionService.cs | 135 +++++
.../NotificationDispatcher.cs | 33 +-
.../NotificationRetentionService.cs | 74 +--
.../ElapsedExecutionCollector.cs | 123 +++++
.../Notifications/ExecutionEventCollector.cs | 24 +-
.../Notifications/ExecutionEventSupport.cs | 28 +
.../LongRunningExecutionCollector.cs | 100 +---
.../QueuedLongExecutionCollector.cs | 95 +---
.../SupportEventRetentionService.cs | 74 +--
.../WorkflowVersionsRetentionService.cs | 79 +--
src/nodepilot-ui/package.json | 1 -
src/nodepilot-ui/src/api/backup.ts | 11 -
.../admin-settings/DbAdminSection.tsx | 77 +--
.../LoggingTelemetrySection.tsx | 118 +---
.../admin-settings/SectionFormHelpers.tsx | 14 +-
.../src/components/ai/AiWorkflowChatPanel.tsx | 198 +------
.../src/components/ai/ChatThreadMenu.tsx | 134 +++++
.../components/common/ContextMenuShell.tsx | 6 +-
.../common/TypedPhraseConfirmDialog.tsx | 82 +++
.../src/components/dbviewer/QueryPane.tsx | 77 +--
.../components/designer/PropertiesPanel.tsx | 3 -
.../designer/overlays/NodeContextMenu.tsx | 50 +-
.../components/designer/properties/shared.tsx | 411 +++++++-------
.../src/hooks/useDashboardFeed.ts | 87 +--
src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts | 95 ++++
.../src/hooks/useOperationsFeed.ts | 78 +--
src/nodepilot-ui/src/lib/chatMessages.ts | 74 +++
src/nodepilot-ui/src/lib/configClone.ts | 13 -
src/nodepilot-ui/src/pages/AiChatPage.tsx | 195 +------
src/nodepilot-ui/src/stores/aiChatStore.ts | 5 -
src/nodepilot-ui/src/stores/authStore.ts | 6 -
src/nodepilot-ui/src/telemetry/otel.ts | 4 -
.../LlmConnectGuardTests.cs | 11 -
.../LlmUnreachableDiagnosticsTests.cs | 5 +-
.../AdminSettingsControllerSectionTests.cs | 115 ----
.../Controllers/ApiProblemsTests.cs | 34 +-
...dePilotApiClientMaintenanceWindowsTests.cs | 1 +
.../Api/NodePilotApiClientNewSurfaceTests.cs | 1 +
.../Api/NodePilotApiClientResourcesTests.cs | 1 +
.../Api/NodePilotApiClientTests.cs | 1 +
.../Api/TokenRefreshHandlerTests.cs | 1 +
.../Auth/SessionResolverTests.cs | 1 +
.../Commands/BaseCommandTests.cs | 1 +
.../Infra/CommandTestHarness.cs | 1 +
.../Output/OutputAndRenderingTests.cs | 1 +
.../Settings/ConfigStoreTests.cs | 1 +
.../ActivityConfigReferenceTests.cs | 27 +-
.../Activities/NonRemoteActivityTests.cs | 134 -----
.../RunScriptExecutionTargetTests.cs | 8 -
.../Execution/StepTestContextProviderTests.cs | 39 +-
.../Execution/StepTesterTests.cs | 22 +-
.../Helpers/TestDoubles.cs | 70 +++
.../PowerShellEngineFactoryIsolationTests.cs | 8 +-
tests/NodePilot.Mcp.Tests/Api/InfraTests.cs | 13 +-
tests/NodePilot.Mcp.Tests/Infra/TestApi.cs | 3 +-
tests/NodePilot.TestCommons/FakeSmtpServer.cs | 142 +++++
169 files changed, 3784 insertions(+), 5189 deletions(-)
delete mode 100644 scripts/stress-test-100-executions.py
delete mode 100644 scripts/stress-test-master-workflow.py
create mode 100644 src/NodePilot.Ai/ChatToolDispatch.cs
create mode 100644 src/NodePilot.Ai/Knowledge/KnowledgeCorpusReader.cs
create mode 100644 src/NodePilot.Ai/LlmJson.cs
create mode 100644 src/NodePilot.Api/Controllers/AiStreamSupport.cs
create mode 100644 src/NodePilot.Api/Controllers/AlertingRuleMapping.cs
create mode 100644 src/NodePilot.Api/Controllers/FolderScopedQueries.cs
create mode 100644 src/NodePilot.Api/Export/CsvWriter.cs
create mode 100644 src/NodePilot.Api/Security/DirectoryMembershipReconciler.cs
create mode 100644 src/NodePilot.Api/Services/Backup/FolderTreeShape.cs
delete mode 100644 src/NodePilot.Cli/Api/ApiException.cs
rename src/{NodePilot.Mcp/Api => NodePilot.Core/Clients}/ApiException.cs (79%)
create mode 100644 src/NodePilot.Core/Clients/ApiResponseReader.cs
rename src/{NodePilot.Mcp/Config/ConfigStore.cs => NodePilot.Core/Clients/ClientConfigStore.cs} (57%)
create mode 100644 src/NodePilot.Data/Security/SecretEnvelope.cs
create mode 100644 src/NodePilot.Engine/Activities/FileSystemOperationActivityBase.cs
create mode 100644 src/NodePilot.Engine/Activities/QueryPayloadSource.cs
create mode 100644 src/NodePilot.Engine/Activities/SubWorkflowInvocation.cs
create mode 100644 src/NodePilot.Engine/Triggers/TriggerVariables.cs
create mode 100644 src/NodePilot.Scheduler/LeaderGatedRetentionService.cs
create mode 100644 src/NodePilot.Scheduler/Notifications/ElapsedExecutionCollector.cs
create mode 100644 src/nodepilot-ui/src/components/ai/ChatThreadMenu.tsx
create mode 100644 src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx
create mode 100644 src/nodepilot-ui/src/hooks/useLiveOpsFeed.ts
create mode 100644 src/nodepilot-ui/src/lib/chatMessages.ts
create mode 100644 tests/NodePilot.Engine.Tests/Helpers/TestDoubles.cs
create mode 100644 tests/NodePilot.TestCommons/FakeSmtpServer.cs
diff --git a/scripts/stress-test-100-executions.py b/scripts/stress-test-100-executions.py
deleted file mode 100644
index 1825af40..00000000
--- a/scripts/stress-test-100-executions.py
+++ /dev/null
@@ -1,274 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-Stress test: Launch 100 workflow executions in ~30 seconds and measure performance.
-"""
-
-import requests
-import json
-import time
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from statistics import mean, stdev, median
-from datetime import datetime
-import sys
-import io
-
-# Force UTF-8 output
-sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
-
-BASE_URL = "http://localhost:5000"
-WORKFLOW_NAME = "Stress Test Simple"
-NUM_EXECUTIONS = 100
-MAX_WORKERS = 10 # Concurrent requests
-
-# Global token cache
-_auth_token = None
-_token_lock = __import__('threading').Lock()
-
-def get_auth_token():
- """Get or refresh Bearer token with caching."""
- global _auth_token
-
- if _auth_token:
- return _auth_token
-
- with _token_lock:
- # Double-check pattern
- if _auth_token:
- return _auth_token
-
- login_url = f"{BASE_URL}/api/auth/login"
- login_payload = {
- "username": "admin",
- "password": "admin123"
- }
- try:
- resp = requests.post(login_url, json=login_payload, timeout=5)
- if resp.status_code == 200:
- data = resp.json()
- _auth_token = data.get("token")
- if _auth_token:
- print(f"[✓] Auth token obtained")
- return _auth_token
- except Exception as e:
- print(f"[WARN] Auth failed: {e}")
- return None
-
-def create_auth_header():
- """Return auth header with cached token."""
- token = get_auth_token()
- if token:
- return {"Authorization": f"Bearer {token}"}
- return {}
-
-def import_test_workflow():
- """Create a simple test workflow if not already present."""
- print(f"[*] Creating test workflow '{WORKFLOW_NAME}'...")
-
- # Simple workflow with just a delay
- workflow_def = {
- "nodes": [
- {
- "id": "delay",
- "type": "activity",
- "position": {"x": 100, "y": 100},
- "data": {
- "label": "Delay 1s",
- "activityType": "delay",
- "config": {"seconds": 1}
- }
- }
- ],
- "edges": []
- }
-
- create_url = f"{BASE_URL}/api/workflows"
- payload = {
- "name": WORKFLOW_NAME,
- "description": "Simple stress test workflow",
- "definitionJson": json.dumps(workflow_def)
- }
-
- headers = create_auth_header()
- try:
- resp = requests.post(create_url, json=payload, headers=headers, timeout=10)
- if resp.status_code in [200, 201]:
- print(f"[✓] Workflow created successfully")
- return True
- else:
- print(f"[WARN] Create returned {resp.status_code}")
- return False
- except Exception as e:
- print(f"[WARN] Create failed: {e}")
- return False
-
-def get_workflow_id():
- """Get the workflow ID by name."""
- print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...")
- list_url = f"{BASE_URL}/api/workflows"
- headers = create_auth_header()
- try:
- resp = requests.get(list_url, headers=headers, timeout=5)
- if resp.status_code == 200:
- workflows = resp.json()
- for wf in workflows:
- if wf.get("name") == WORKFLOW_NAME:
- wid = wf.get("id")
- print(f"[✓] Found workflow ID: {wid}")
- return wid
- print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list")
- except Exception as e:
- print(f"[WARN] Lookup failed: {e}")
- return None
-
-def execute_workflow(workflow_id, attempt_num):
- """Execute a single workflow and record metrics."""
- execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute"
- payload = {
- "parameters": {},
- "timeoutSeconds": 300,
- "debug": False
- }
- headers = create_auth_header()
-
- start = time.time()
- try:
- resp = requests.post(execute_url, json=payload, headers=headers, timeout=30)
- elapsed = time.time() - start
- status = resp.status_code
- execution_id = None
-
- if status == 202:
- try:
- data = resp.json()
- execution_id = data.get("executionId")
- except:
- pass
-
- return {
- "attempt": attempt_num,
- "status": status,
- "elapsed_ms": elapsed * 1000,
- "execution_id": execution_id,
- "success": status == 202
- }
- except Exception as e:
- elapsed = time.time() - start
- return {
- "attempt": attempt_num,
- "status": 0,
- "elapsed_ms": elapsed * 1000,
- "execution_id": None,
- "success": False,
- "error": str(e)
- }
-
-def main():
- global _auth_token
-
- print("=" * 70)
- print("NODEPILOT STRESS TEST: 100 Executions in 30 Seconds")
- print("=" * 70)
- print(f"[*] Start time: {datetime.now().isoformat()}")
- print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers")
- print()
-
- # Step 0: Get auth token first
- _auth_token = get_auth_token()
- if not _auth_token:
- print("[!] ERROR: Could not obtain auth token. Aborting.")
- sys.exit(1)
- print()
-
- # Step 1: Import workflow
- import_test_workflow()
- time.sleep(1)
-
- # Step 2: Get workflow ID
- workflow_id = get_workflow_id()
- if not workflow_id:
- print("[!] ERROR: Could not find or import workflow. Aborting.")
- sys.exit(1)
-
- print()
- print(f"[*] Starting execution burst at {datetime.now().isoformat()}...")
- print()
-
- # Step 3: Launch execution burst
- results = []
- start_time = time.time()
-
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
- futures = []
- for i in range(NUM_EXECUTIONS):
- future = executor.submit(execute_workflow, workflow_id, i + 1)
- futures.append(future)
-
- completed = 0
- for future in as_completed(futures):
- result = future.result()
- results.append(result)
- completed += 1
- if completed % 10 == 0:
- print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched")
-
- total_time = time.time() - start_time
-
- print()
- print("=" * 70)
- print("PERFORMANCE METRICS")
- print("=" * 70)
- print()
-
- # Parse results
- successful = [r for r in results if r["success"]]
- failed = [r for r in results if not r["success"]]
- response_times = [r["elapsed_ms"] for r in successful]
-
- print(f"Total Time: {total_time:.2f} seconds")
- print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)")
- print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}")
- print()
-
- if response_times:
- print("Response Time (ms):")
- print(f" Min: {min(response_times):.1f}")
- print(f" Max: {max(response_times):.1f}")
- print(f" Mean: {mean(response_times):.1f}")
- print(f" Median: {median(response_times):.1f}")
- if len(response_times) > 1:
- print(f" Stdev: {stdev(response_times):.1f}")
- print()
-
- # Status code distribution
- status_counts = {}
- for r in results:
- status = r["status"]
- status_counts[status] = status_counts.get(status, 0) + 1
-
- print("Status Code Distribution:")
- for status in sorted(status_counts.keys()):
- count = status_counts[status]
- pct = 100 * count / NUM_EXECUTIONS
- print(f" {status:3d}: {count:3d} ({pct:5.1f}%)")
- print()
-
- # Throughput
- throughput = NUM_EXECUTIONS / total_time
- print(f"Throughput: {throughput:.1f} executions/second")
- print()
-
- # Sample execution IDs
- exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]]
- if exec_ids:
- print("Sample Execution IDs (first 5):")
- for eid in exec_ids:
- print(f" - {eid}")
- print()
-
- print("=" * 70)
- print(f"[*] End time: {datetime.now().isoformat()}")
- print("=" * 70)
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/stress-test-master-workflow.py b/scripts/stress-test-master-workflow.py
deleted file mode 100644
index 2189728b..00000000
--- a/scripts/stress-test-master-workflow.py
+++ /dev/null
@@ -1,276 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-"""
-Stress test: Launch 100 executions of "Master Test — All Activities" in ~30 seconds.
-This workflow tests all 24 activity types, all edge conditions, retry logic, etc.
-"""
-
-import requests
-import json
-import time
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from statistics import mean, stdev, median
-from datetime import datetime
-import sys
-import io
-
-# Force UTF-8 output
-sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
-
-BASE_URL = "http://localhost:5000"
-WORKFLOW_NAME = "Master Test — All Activities"
-NUM_EXECUTIONS = 100
-MAX_WORKERS = 10 # Concurrent requests
-
-# Global token cache
-_auth_token = None
-_token_lock = __import__('threading').Lock()
-
-def get_auth_token():
- """Get or refresh Bearer token with caching."""
- global _auth_token
-
- if _auth_token:
- return _auth_token
-
- with _token_lock:
- # Double-check pattern
- if _auth_token:
- return _auth_token
-
- login_url = f"{BASE_URL}/api/auth/login"
- login_payload = {
- "username": "admin",
- "password": "admin123"
- }
- try:
- resp = requests.post(login_url, json=login_payload, timeout=5)
- if resp.status_code == 200:
- data = resp.json()
- _auth_token = data.get("token")
- if _auth_token:
- print(f"[✓] Auth token obtained")
- return _auth_token
- except Exception as e:
- print(f"[WARN] Auth failed: {e}")
- return None
-
-def create_auth_header():
- """Return auth header with cached token."""
- token = get_auth_token()
- if token:
- return {"Authorization": f"Bearer {token}"}
- return {}
-
-def import_master_workflow():
- """Import the Master Test workflow from file."""
- print(f"[*] Importing master workflow '{WORKFLOW_NAME}'...")
-
- try:
- with open('scripts/test-master-all-activities.json', 'r', encoding='utf-8') as f:
- export_data = json.load(f)
- except Exception as e:
- print(f"[!] Failed to read workflow file: {e}")
- return False
-
- # Extract first workflow from export
- if "workflows" not in export_data or not export_data["workflows"]:
- print(f"[!] No workflows in export file")
- return False
-
- workflow = export_data["workflows"][0]
-
- create_url = f"{BASE_URL}/api/workflows"
- payload = {
- "name": workflow.get("name", WORKFLOW_NAME),
- "description": workflow.get("description", ""),
- "definitionJson": json.dumps(workflow.get("definition", {}))
- }
-
- headers = create_auth_header()
- try:
- resp = requests.post(create_url, json=payload, headers=headers, timeout=10)
- if resp.status_code in [200, 201]:
- print(f"[✓] Workflow imported successfully")
- return True
- elif resp.status_code == 409:
- print(f"[✓] Workflow already exists (conflict, using existing)")
- return True
- else:
- print(f"[WARN] Import returned {resp.status_code}: {resp.text}")
- return False
- except Exception as e:
- print(f"[WARN] Import failed: {e}")
- return False
-
-def get_workflow_id():
- """Get the workflow ID by name."""
- print(f"[*] Looking up workflow ID for '{WORKFLOW_NAME}'...")
- list_url = f"{BASE_URL}/api/workflows"
- headers = create_auth_header()
- try:
- resp = requests.get(list_url, headers=headers, timeout=5)
- if resp.status_code == 200:
- workflows = resp.json()
- for wf in workflows:
- if wf.get("name") == WORKFLOW_NAME:
- wid = wf.get("id")
- print(f"[✓] Found workflow ID: {wid}")
- return wid
- print(f"[!] Workflow '{WORKFLOW_NAME}' not found in list")
- except Exception as e:
- print(f"[WARN] Lookup failed: {e}")
- return None
-
-def execute_workflow(workflow_id, attempt_num):
- """Execute a single workflow and record metrics."""
- execute_url = f"{BASE_URL}/api/workflows/{workflow_id}/execute"
- payload = {
- "parameters": {},
- "timeoutSeconds": 300,
- "debug": False
- }
- headers = create_auth_header()
-
- start = time.time()
- try:
- resp = requests.post(execute_url, json=payload, headers=headers, timeout=30)
- elapsed = time.time() - start
- status = resp.status_code
- execution_id = None
-
- if status == 202:
- try:
- data = resp.json()
- execution_id = data.get("executionId")
- except:
- pass
-
- return {
- "attempt": attempt_num,
- "status": status,
- "elapsed_ms": elapsed * 1000,
- "execution_id": execution_id,
- "success": status == 202
- }
- except Exception as e:
- elapsed = time.time() - start
- return {
- "attempt": attempt_num,
- "status": 0,
- "elapsed_ms": elapsed * 1000,
- "execution_id": None,
- "success": False,
- "error": str(e)
- }
-
-def main():
- global _auth_token
-
- print("=" * 70)
- print("NODEPILOT STRESS TEST: Master Workflow — 100 Executions")
- print("=" * 70)
- print(f"[*] Start time: {datetime.now().isoformat()}")
- print(f"[*] Workflow: {WORKFLOW_NAME}")
- print(f"[*] Target: {NUM_EXECUTIONS} executions with {MAX_WORKERS} concurrent workers")
- print()
-
- # Step 0: Get auth token first
- _auth_token = get_auth_token()
- if not _auth_token:
- print("[!] ERROR: Could not obtain auth token. Aborting.")
- sys.exit(1)
- print()
-
- # Step 1: Import workflow
- import_master_workflow()
- time.sleep(1)
-
- # Step 2: Get workflow ID
- workflow_id = get_workflow_id()
- if not workflow_id:
- print("[!] ERROR: Could not find or import workflow. Aborting.")
- sys.exit(1)
-
- print()
- print(f"[*] Starting execution burst at {datetime.now().isoformat()}...")
- print()
-
- # Step 3: Launch execution burst
- results = []
- start_time = time.time()
-
- with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
- futures = []
- for i in range(NUM_EXECUTIONS):
- future = executor.submit(execute_workflow, workflow_id, i + 1)
- futures.append(future)
-
- completed = 0
- for future in as_completed(futures):
- result = future.result()
- results.append(result)
- completed += 1
- if completed % 10 == 0:
- print(f"[+] {completed}/{NUM_EXECUTIONS} executions launched")
-
- total_time = time.time() - start_time
-
- print()
- print("=" * 70)
- print("PERFORMANCE METRICS")
- print("=" * 70)
- print()
-
- # Parse results
- successful = [r for r in results if r["success"]]
- failed = [r for r in results if not r["success"]]
- response_times = [r["elapsed_ms"] for r in successful]
-
- print(f"Total Time: {total_time:.2f} seconds")
- print(f"Successful: {len(successful)}/{NUM_EXECUTIONS} ({100*len(successful)/NUM_EXECUTIONS:.1f}%)")
- print(f"Failed: {len(failed)}/{NUM_EXECUTIONS}")
- print()
-
- if response_times:
- print("Response Time (ms):")
- print(f" Min: {min(response_times):.1f}")
- print(f" Max: {max(response_times):.1f}")
- print(f" Mean: {mean(response_times):.1f}")
- print(f" Median: {median(response_times):.1f}")
- if len(response_times) > 1:
- print(f" Stdev: {stdev(response_times):.1f}")
- print()
-
- # Status code distribution
- status_counts = {}
- for r in results:
- status = r["status"]
- status_counts[status] = status_counts.get(status, 0) + 1
-
- print("Status Code Distribution:")
- for status in sorted(status_counts.keys()):
- count = status_counts[status]
- pct = 100 * count / NUM_EXECUTIONS
- print(f" {status:3d}: {count:3d} ({pct:5.1f}%)")
- print()
-
- # Throughput
- throughput = NUM_EXECUTIONS / total_time
- print(f"Throughput: {throughput:.1f} executions/second")
- print()
-
- # Sample execution IDs
- exec_ids = [r["execution_id"] for r in successful[:5] if r["execution_id"]]
- if exec_ids:
- print("Sample Execution IDs (first 5):")
- for eid in exec_ids:
- print(f" - {eid}")
- print()
-
- print("=" * 70)
- print(f"[*] End time: {datetime.now().isoformat()}")
- print("=" * 70)
-
-if __name__ == "__main__":
- main()
diff --git a/src/NodePilot.Ai/ChatToolDispatch.cs b/src/NodePilot.Ai/ChatToolDispatch.cs
new file mode 100644
index 00000000..61758031
--- /dev/null
+++ b/src/NodePilot.Ai/ChatToolDispatch.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+
+namespace NodePilot.Ai;
+
+/// One tool implementation: raw arguments + the registry's per-request context.
+internal delegate Task