diff --git a/CLAUDE.md b/CLAUDE.md index fe695447..0a4c7c33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Diese Datei ist der Index; die Tiefe liegt in `docs/`: - **Remote Execution:** PowerShell SDK / WinRM, agentless. `Remote:Provider`: `winrm` (default) | `noop` (`noop` muss per `Remote:AllowNoop=true` bzw. `NODEPILOT_ALLOW_NOOP_REMOTE=1` quittiert werden, sonst Boot-Abbruch). Engine-local (In-Proc-Pool): implizite WinPS-Kompatibilität **deaktiviert** (Desktop-only-Module → lauter Fehler statt `powershell.exe -s`-Session-Leak; `Microsoft.PowerShell.Archive` gebündelt) — Details `docs/claude-reference.md` + `docs/performance-improvements.md` - **Real-time:** SignalR (`/hubs/execution`) - **Logging:** Serilog. Format via `Logging:Format`: `text`|`cmtrace`|`json`|`ecs-json` (ECS 1.x für SIEM, siehe `docs/siem-logging.md`). Support-Log: File + DB-Projektion -- **MCP-Server (opt-in):** `nodepilot-mcp` (stdio) — AI-Agent steuert/editiert Workflows über 99 Tools, HTTP-only gegen die REST-API +- **MCP-Server (opt-in):** `nodepilot-mcp` (stdio) — AI-Agent steuert/editiert Workflows über 100 Tools, HTTP-only gegen die REST-API - **Enterprise (opt-in):** Active/Passive HA (`Cluster:Enabled`), pluggable Secret-Provider (`Secrets:Provider` = `Dpapi`|`AesGcm`), LDAP/Windows-SSO, ECS-JSON-SIEM, Folder-RBAC ## Solution-Struktur @@ -333,7 +333,7 @@ Scoped Testing übersieht genau eine Fehlerklasse — die Parity-/Drift-Tests, d ## Clients (`np` CLI + `nodepilot-mcp`) -Beide sind reine HTTP-Clients gegen die REST-API — **kein** eigener Backend-Pfad. Ausgeliefert werden beide per `dotnet publish` (Ordner in den `PATH` bzw. `.mcp.json` auf die `.exe` zeigen lassen); **keine** `dotnet global tool`s — `PackAsTool` verträgt das geerbte `net10.0-windows`-TFM nicht (NETSDK1146, siehe `docs/roadmap.md`-Sperrvermerk). Der MCP-Server ergänzt In-Proc-Analyse gegen `NodePilot.Core` (99 Tools, 3 Resources, stdio) und reused die DPAPI-Session der CLI (`np auth login`). +Beide sind reine HTTP-Clients gegen die REST-API — **kein** eigener Backend-Pfad. Ausgeliefert werden beide per `dotnet publish` (Ordner in den `PATH` bzw. `.mcp.json` auf die `.exe` zeigen lassen); **keine** `dotnet global tool`s — `PackAsTool` verträgt das geerbte `net10.0-windows`-TFM nicht (NETSDK1146, siehe `docs/roadmap.md`-Sperrvermerk). Der MCP-Server ergänzt In-Proc-Analyse gegen `NodePilot.Core` (100 Tools, 3 Resources, stdio) und reused die DPAPI-Session der CLI (`np auth login`). **Jeder neue API-Endpoint braucht beide Clients.** Mechanik, Befehlsbereiche und Tool-Katalog: `src/NodePilot.Cli/CLAUDE.md`, `src/NodePilot.Mcp/CLAUDE.md`, `docs/mcp-server.md`, `docs/claude-reference.md`. diff --git a/README.md b/README.md index 7b40a059..64270902 100644 --- a/README.md +++ b/README.md @@ -999,7 +999,7 @@ it never permits plaintext connections to remote hosts. ## `nodepilot-mcp` — the MCP server -A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI agent (Claude Desktop/Code, or any MCP client) **drive and edit workflows and read data** — 99 tools over the same REST API the SPA uses, plus in-process graph/data-bus analysis for the in-canvas chat assistant. Like the CLI: **HTTP-only, no backend dependencies**, and it reuses the CLI's `np auth login` session. +A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI agent (Claude Desktop/Code, or any MCP client) **drive and edit workflows and read data** — 100 tools over the same REST API the SPA uses, plus in-process graph/data-bus analysis for the in-canvas chat assistant. Like the CLI: **HTTP-only, no backend dependencies**, and it reuses the CLI's `np auth login` session. ```powershell dotnet publish src/NodePilot.Mcp -c Release -o C:\Tools\NodePilot-Mcp diff --git a/docs/alerting.md b/docs/alerting.md index aaeb9787..baabbfed 100644 --- a/docs/alerting.md +++ b/docs/alerting.md @@ -185,6 +185,7 @@ operand mode; the globals query is disabled in that mode). A **Deliveries** butt ### CLI ``` +np alerting catalog # event types, filter fields, channels np alerting list np alerting get np alerting create --name "Prod failures" --event-types ExecutionFailed,ExecutionCancelled \ @@ -200,7 +201,7 @@ Routes are given with `--email` / `--webhook` (repeatable); scope targets with ` ### MCP -`list_alerting_rules`, `get_alerting_rule`, `list_alerting_deliveries`, `create_alerting_rule`, +`get_alerting_catalog`, `list_alerting_rules`, `get_alerting_rule`, `list_alerting_deliveries`, `create_alerting_rule`, `update_alerting_rule`, `test_fire_alerting_rule` (read + write); `delete_alerting_rule` lives in the gated `DestructiveTools` (only registered when `NODEPILOT_MCP_ALLOW_DESTRUCTIVE=true`; listed in `get_safety_status`). Route secrets are never surfaced in tool output. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index eda73b81..0dfcff9a 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -94,7 +94,7 @@ Large free-text fields (stdout/stderr, return data, audit details, diagnostics) ## Tool catalog -89 default tools across 10 groups, plus 10 gated destructive tools (99 total). (Roles refer to the +90 default tools across 10 groups, plus 10 gated destructive tools (100 total). (Roles refer to the authenticated user.) ### Discovery @@ -159,8 +159,8 @@ harmlosen Casts auf diesen Secret-Tabellen; explizit benannte, nicht geschützte `move_global_variable_folder` · `move_global_variable_to_folder` ### Alerting (notification rules; route secrets never surfaced) -`list_alerting_rules` · `get_alerting_rule` · `create_alerting_rule` · `update_alerting_rule` · -`test_fire_alerting_rule` · `list_alerting_deliveries` +`get_alerting_catalog` · `list_alerting_rules` · `get_alerting_rule` · `create_alerting_rule` · +`update_alerting_rule` · `test_fire_alerting_rule` · `list_alerting_deliveries` ### System alerts (ADR 0008; catalog-driven policies) `get_system_alert_catalog` · `list_system_alert_policies` · `get_system_alert_policy` · diff --git a/src/NodePilot.Cli/Api/Dtos/AlertingDtos.cs b/src/NodePilot.Cli/Api/Dtos/AlertingDtos.cs index ad54e50d..6aa3dc00 100644 --- a/src/NodePilot.Cli/Api/Dtos/AlertingDtos.cs +++ b/src/NodePilot.Cli/Api/Dtos/AlertingDtos.cs @@ -26,6 +26,19 @@ public sealed record TestFireRouteResult(string Channel, string Target, bool Suc public sealed record TestFireResponse(bool AllSucceeded, List Results); +// The rule-authoring catalog: which event types exist, which fields a filter may reference, and +// which channels this installation can actually deliver on. `np alerting create` takes a rule as +// JSON, so without this the field names had to be read out of the web UI or the source. +public sealed record AlertingCatalogFieldDto(string Name, string Applies, string Type, IReadOnlyList? Values = null); + +public sealed record AlertingCatalogEventTypeDto(string Name, string Category, bool Scopeable); + +public sealed record AlertingCatalogResponse( + IReadOnlyList EventTypes, + IReadOnlyList EventFields, + IReadOnlyList Channels, + IReadOnlyList DedupTemplateFields); + public sealed record NotificationDeliveryDto( Guid Id, Guid RuleId, string? RuleName, Guid RouteId, string? Channel, string? Target, string EventKey, string Status, int Attempt, DateTime CreatedAt, DateTime? SentAt, diff --git a/src/NodePilot.Cli/Api/NodePilotApiClient.cs b/src/NodePilot.Cli/Api/NodePilotApiClient.cs index bffc234e..1dee6863 100644 --- a/src/NodePilot.Cli/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Cli/Api/NodePilotApiClient.cs @@ -93,6 +93,16 @@ public async Task GetWorkflowAsync(Guid id, CancellationToken return await ParseAsync(res, ct); } + /// + /// Name lookup with the engine's own rules: exact case wins, otherwise case-insensitive, + /// ambiguous names answer 409. Listing and filtering client-side cannot reproduce that. + /// + public async Task GetWorkflowByNameAsync(string name, CancellationToken ct) + { + using var res = await _http.GetAsync($"api/workflows/by-name/{Uri.EscapeDataString(name)}", ct); + return await ParseAsync(res, ct); + } + public async Task ExecuteWorkflowAsync(Guid id, ExecuteWorkflowRequest req, CancellationToken ct) { using var res = await _http.PostAsJsonAsync($"api/workflows/{id}/execute", req, JsonOptions, ct); @@ -475,6 +485,12 @@ public async Task> ListAlertingRulesAsync(Cancell return await ParseAsync>(res, ct); } + public async Task GetAlertingCatalogAsync(CancellationToken ct) + { + using var res = await _http.GetAsync("api/alerting/catalog", ct); + return await ParseAsync(res, ct); + } + public async Task GetAlertingRuleAsync(Guid id, CancellationToken ct) { using var res = await _http.GetAsync($"api/alerting/rules/{id}", ct); diff --git a/src/NodePilot.Cli/CommandRegistration.cs b/src/NodePilot.Cli/CommandRegistration.cs index 99800ffb..1d18ba1c 100644 --- a/src/NodePilot.Cli/CommandRegistration.cs +++ b/src/NodePilot.Cli/CommandRegistration.cs @@ -179,6 +179,7 @@ public static void Register(IConfigurator config) config.AddBranch("alerting", a => { a.SetDescription("Manage alerting rules (notify on matching events)."); + a.AddCommand("catalog").WithDescription("Show the rule vocabulary: event types, filter fields, channels."); a.AddCommand("list").WithDescription("List all alerting rules."); a.AddCommand("get").WithDescription("Show one alerting rule."); a.AddCommand("create").WithDescription("Create an alerting rule (Admin only)."); diff --git a/src/NodePilot.Cli/Commands/Alerting/AlertingCommands.cs b/src/NodePilot.Cli/Commands/Alerting/AlertingCommands.cs index e5efb2e7..2bbd41bb 100644 --- a/src/NodePilot.Cli/Commands/Alerting/AlertingCommands.cs +++ b/src/NodePilot.Cli/Commands/Alerting/AlertingCommands.cs @@ -23,6 +23,22 @@ protected override async Task RunAsync(CommandContext _, GlobalSettings set } } +// Rules are authored as JSON (`np alerting create --file`), so the vocabulary that JSON may use — +// event types, filter-referenceable fields, the channels this installation can deliver on — has to +// be readable from the CLI too. Otherwise the only way to learn a field name is the web UI. +[SupportedOSPlatform("windows")] +public sealed class AlertingCatalogCommand : BaseCommand +{ + public AlertingCatalogCommand(SessionResolver s, ApiClientFactory f) : base(s, f) { } + protected override async Task RunAsync(CommandContext _, GlobalSettings settings, SessionContext session, OutputWriter writer, CancellationToken ct) + { + var api = ClientFactory.Create(session); + var catalog = await api.GetAlertingCatalogAsync(ct); + writer.WriteData(catalog, (console, c) => Renderers.AlertingCatalog(console, c)); + return ExitCodes.Success; + } +} + public sealed class AlertingIdSettings : GlobalSettings { [CommandArgument(0, "")] diff --git a/src/NodePilot.Cli/Commands/WorkflowResolver.cs b/src/NodePilot.Cli/Commands/WorkflowResolver.cs index 199c4cdf..0e622e7a 100644 --- a/src/NodePilot.Cli/Commands/WorkflowResolver.cs +++ b/src/NodePilot.Cli/Commands/WorkflowResolver.cs @@ -1,12 +1,18 @@ +using System.Net; using System.Runtime.Versioning; using NodePilot.Cli.Api; using NodePilot.Cli.Api.Dtos; +using NodePilot.Core.Clients; namespace NodePilot.Cli.Commands; /// -/// Resolves a CLI workflow argument that may be either a Guid or a name. List endpoint -/// is the only way to look up by name today (no GET /by-name route on the API). +/// Resolves a CLI workflow argument that may be either a Guid or a name. The name half goes +/// through GET /api/workflows/by-name/{name}, so `np` resolves exactly like the engine, +/// the API and the trigger path do: exact case wins, otherwise case-insensitive, ambiguity is +/// an error. Listing and filtering client-side used to disagree with all three — two workflows +/// differing only in case were "ambiguous" to `np` while every other caller picked the exact +/// match — and it dragged the whole workflow list over the wire to resolve one name. /// [SupportedOSPlatform("windows")] public static class WorkflowResolver @@ -16,12 +22,18 @@ public static async Task ResolveAsync(NodePilotApiClient api, if (Guid.TryParse(idOrName, out var id)) return await api.GetWorkflowAsync(id, ct); - var all = await api.ListWorkflowsAsync(ct); - var matches = all.Where(w => string.Equals(w.Name, idOrName, StringComparison.OrdinalIgnoreCase)).ToList(); - if (matches.Count == 0) + try + { + return await api.GetWorkflowByNameAsync(idOrName, ct); + } + catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { throw new InvalidOperationException($"No workflow named '{idOrName}'."); - if (matches.Count > 1) - throw new InvalidOperationException($"Multiple workflows named '{idOrName}' — disambiguate with the GUID."); - return matches[0]; + } + catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + throw new InvalidOperationException( + $"Multiple workflows named '{idOrName}' — disambiguate with the GUID."); + } } } diff --git a/src/NodePilot.Cli/Output/Renderers.cs b/src/NodePilot.Cli/Output/Renderers.cs index 2e729e0b..e8ba1698 100644 --- a/src/NodePilot.Cli/Output/Renderers.cs +++ b/src/NodePilot.Cli/Output/Renderers.cs @@ -337,6 +337,28 @@ public static void AlertingRules(IAnsiConsole console, IReadOnlyList 0 } v ? Markup.Escape(string.Join(", ", v)) : "[dim]-[/]"); + } + console.Write(table); + console.MarkupLine($"Channels: {Markup.Escape(string.Join(", ", catalog.Channels))}"); + console.MarkupLine($"Scopeable events: {Markup.Escape(string.Join(", ", catalog.EventTypes.Where(e => e.Scopeable).Select(e => e.Name)))}"); + } + public static void AlertingDeliveries(IAnsiConsole console, IReadOnlyList rows) { var table = new Table().Border(TableBorder.Rounded) diff --git a/src/NodePilot.Mcp/Api/Dtos/AlertingDtos.cs b/src/NodePilot.Mcp/Api/Dtos/AlertingDtos.cs index aeb77f9b..3517cfe4 100644 --- a/src/NodePilot.Mcp/Api/Dtos/AlertingDtos.cs +++ b/src/NodePilot.Mcp/Api/Dtos/AlertingDtos.cs @@ -24,6 +24,18 @@ public sealed record TestFireRouteResult(string Channel, string Target, bool Suc public sealed record TestFireResponse(bool AllSucceeded, List Results); +// The rule-authoring vocabulary. An agent writing a filter expression has to know which fields +// exist and which values they take; guessing produces rules that never match. +public sealed record AlertingCatalogFieldDto(string Name, string Applies, string Type, IReadOnlyList? Values = null); + +public sealed record AlertingCatalogEventTypeDto(string Name, string Category, bool Scopeable); + +public sealed record AlertingCatalogResponse( + IReadOnlyList EventTypes, + IReadOnlyList EventFields, + IReadOnlyList Channels, + IReadOnlyList DedupTemplateFields); + public sealed record NotificationDeliveryDto( Guid Id, Guid RuleId, string? RuleName, Guid RouteId, string? Channel, string? Target, string EventKey, string Status, int Attempt, DateTime CreatedAt, DateTime? SentAt, diff --git a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs index 6bffe84e..77d8ddab 100644 --- a/src/NodePilot.Mcp/Api/NodePilotApiClient.cs +++ b/src/NodePilot.Mcp/Api/NodePilotApiClient.cs @@ -584,6 +584,13 @@ public async Task> ListAlertingRulesAsync(Cancell return await ParseAsync>(res, ct); } + public async Task GetAlertingCatalogAsync(CancellationToken ct) + { + EnsureReady(); + using var res = await _http.GetAsync("api/alerting/catalog", ct); + return await ParseAsync(res, ct); + } + public async Task GetAlertingRuleAsync(Guid id, CancellationToken ct) { EnsureReady(); diff --git a/src/NodePilot.Mcp/CLAUDE.md b/src/NodePilot.Mcp/CLAUDE.md index 69e32576..e50bb9fb 100644 --- a/src/NodePilot.Mcp/CLAUDE.md +++ b/src/NodePilot.Mcp/CLAUDE.md @@ -2,7 +2,7 @@ Gilt für `src/NodePilot.Mcp/`. Projektweite Regeln stehen in der Root-`CLAUDE.md`. -Reiner HTTP-Client gegen die REST-API (wie die CLI) + In-Proc-Analyse gegen `NodePilot.Core` — **kein** neuer Backend-Pfad (99 Tools, 3 Resources), Transport stdio; reused die DPAPI-Session der CLI (`np auth login`). Destruktive Tools (`delete_*`, `force_unlock_workflow`, `cancel_all_executions`, `test_step`) werden nur bei `NODEPILOT_MCP_ALLOW_DESTRUCTIVE=true` registriert; Workflow-Definitionen werden vor Tool-Output secret-redigiert, bei publish/patch werden echte Secrets aus der gespeicherten Version wiederhergestellt. Volle Doku: `docs/mcp-server.md`. +Reiner HTTP-Client gegen die REST-API (wie die CLI) + In-Proc-Analyse gegen `NodePilot.Core` — **kein** neuer Backend-Pfad (100 Tools, 3 Resources), Transport stdio; reused die DPAPI-Session der CLI (`np auth login`). Destruktive Tools (`delete_*`, `force_unlock_workflow`, `cancel_all_executions`, `test_step`) werden nur bei `NODEPILOT_MCP_ALLOW_DESTRUCTIVE=true` registriert; Workflow-Definitionen werden vor Tool-Output secret-redigiert, bei publish/patch werden echte Secrets aus der gespeicherten Version wiederhergestellt. Volle Doku: `docs/mcp-server.md`. **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`). diff --git a/src/NodePilot.Mcp/Tools/AlertingTools.cs b/src/NodePilot.Mcp/Tools/AlertingTools.cs index 52690ab3..2ddfcc2b 100644 --- a/src/NodePilot.Mcp/Tools/AlertingTools.cs +++ b/src/NodePilot.Mcp/Tools/AlertingTools.cs @@ -17,6 +17,20 @@ public sealed class AlertingTools public AlertingTools(NodePilotApiClient api) => _api = api; + [McpServerTool(Name = "get_alerting_catalog", ReadOnly = true)] + [Description("Get the alerting rule vocabulary: supported event types (with category and whether they can be scoped), the fields a filter expression may reference (with type and allowed values), the notification channels this installation can deliver on, and the fields usable in a dedup-key template. Read this before authoring a filterExpressionJson — a filter over an unknown field never matches.")] + public async Task GetAlertingCatalog(CancellationToken cancellationToken = default) + { + var catalog = await ApiErrorMapper.Guard(() => _api.GetAlertingCatalogAsync(cancellationToken)); + return new + { + eventTypes = catalog.EventTypes, + eventFields = catalog.EventFields, + channels = catalog.Channels, + dedupTemplateFields = catalog.DedupTemplateFields, + }; + } + [McpServerTool(Name = "list_alerting_rules", ReadOnly = true)] [Description("List alerting rules with their event types, scope, throttle and route channels.")] public async Task ListAlertingRules(CancellationToken cancellationToken = default) diff --git a/src/nodepilot-docs-ui/content/cli.md b/src/nodepilot-docs-ui/content/cli.md index 860616e6..3bfb39ac 100644 --- a/src/nodepilot-docs-ui/content/cli.md +++ b/src/nodepilot-docs-ui/content/cli.md @@ -333,6 +333,7 @@ np observability query-range --query "rate(nodepilot_workflows_total[5m])" \ ```bash # Alerting (Read Admin/Op; Create/Edit/Delete/Test-Fire Admin-only) +np alerting catalog # Event-Typen, Filter-Felder, Kanäle np alerting list np alerting get 9a2f... np alerting create --file ./rule.json diff --git a/src/nodepilot-docs-ui/content/mcp-server.md b/src/nodepilot-docs-ui/content/mcp-server.md index 321bd5d4..e0d733e4 100644 --- a/src/nodepilot-docs-ui/content/mcp-server.md +++ b/src/nodepilot-docs-ui/content/mcp-server.md @@ -4,7 +4,7 @@ Ein [Model-Context-Protocol](https://modelcontextprotocol.io)-Server, mit dem ei (Claude Desktop/Code oder ein beliebiger MCP-Client) **NodePilot-Workflows steuern und bearbeiten** sowie **Daten auslesen** kann. Wie die `np`-CLI ist er ein reiner HTTP-Client gegen die bestehende REST-API — **kein neuer Backend-Pfad**; jedes Tool ruft einen vorhandenen Endpoint auf oder rechnet -in-proc gegen `NodePilot.Core`. 99 Tools über 10 Gruppen, plus 3 MCP-Resources. +in-proc gegen `NodePilot.Core`. 100 Tools über 10 Gruppen, plus 3 MCP-Resources. ## Installation @@ -65,7 +65,7 @@ Transport ist **stdio** (Streamable HTTP ist als spätere Option vorgesehen). Wi - **Telemetrie:** dashboard, coverage/step-health/step-stats, `query_audit_log` (Admin), `get_support_diagnostics` (Admin) - **DB / text2sql (Admin, nur lesend):** `list_db_tables` (Schema-Katalog; Secret-Spalten hidden, `GlobalVariable.Value` maskiert), `get_db_info` (Provider + Row-/Timeout-Limits), `run_readonly_sql` (ein Read-Only-Statement, Server erzwingt Keyword-Whitelist + Rollback; kein Write-Tool). Secret-Spalten sind auch über Raw-SQL unerreichbar — drei Schichten: Ein direkter Verweis liefert `protected_column`; ein `SELECT *` liefert die Werte als `***`; und wer eine **ganze Zeile** einer Tabelle mit Secret-Spalte serialisiert (`to_json`/`row_to_json`/`::text`/`FOR JSON`), bekommt `protected_row_projection` — genau dieser Weg trug die Werte an den ersten beiden, rein namensbasierten Schichten vorbei. Spalten explizit benennen funktioniert immer. Die Übersetzung natürlicher Sprache nach SQL übernimmt der Agent. - **Supporting:** Machines, Credentials, Globals (Secrets nie ausgegeben) -- **Alerting:** `list/get/create/update/test_fire_alerting_rule` + `list_alerting_deliveries` (Ledger) (+ gated `delete_alerting_rule`; Route-Secrets nie ausgegeben) +- **Alerting:** `get_alerting_catalog` (Regel-Vokabular) + `list/get/create/update/test_fire_alerting_rule` + `list_alerting_deliveries` (Ledger) (+ gated `delete_alerting_rule`; Route-Secrets nie ausgegeben) - **System-Alerts (ADR 0008):** `get_system_alert_catalog` + `list/get/create/update/enable/disable/test_fire_system_alert_policy` (+ gated `delete_system_alert_policy`) - **Canvas-Assistent** (für den Designer-Chat, überwiegend in-proc): `analyze_workflow`, `get_available_variables`, `get_failure_context`, `find_unresolved_references`, `validate_edge_condition`, `validate_activity_config`, `preview_template_resolution`, `suggest_layout`, `diff_workflow_definition`, `get_workflow_node`, `check_styleguide` diff --git a/tests/NodePilot.Cli.Tests/Api/EndpointClientCoverageTests.cs b/tests/NodePilot.Cli.Tests/Api/EndpointClientCoverageTests.cs index 68c0fab8..19dbb0df 100644 --- a/tests/NodePilot.Cli.Tests/Api/EndpointClientCoverageTests.cs +++ b/tests/NodePilot.Cli.Tests/Api/EndpointClientCoverageTests.cs @@ -36,6 +36,7 @@ public sealed class EndpointClientCoverageTests private const string InteractiveAiSse = "DELIBERATE: interactive SSE surface for the designer/knowledge chat UI; clients have no streaming UX"; private const string WebhookIngress = "DELIBERATE: external webhook ingress — callers are third-party systems, not our clients"; private const string CustomActivityGap = "audit finding F1: the custom-activities surface has NO client — close by adding np custom-activity + MCP tools"; + private const string RuleBuilderPreview = "DELIBERATE: stateless dry-run for the rule builder's live preview; a client authors the rule JSON and validates it by saving"; private static readonly Dictionary KnownCliGaps = new(StringComparer.OrdinalIgnoreCase) { @@ -52,6 +53,8 @@ public sealed class EndpointClientCoverageTests ["api/ai/knowledge/capabilities"] = InteractiveAiSse, ["api/alerting/rules/*/disable"] = "np alerting toggles via PUT update; the dedicated enable/disable endpoints have no CLI verb", ["api/alerting/rules/*/enable"] = "np alerting toggles via PUT update; the dedicated enable/disable endpoints have no CLI verb", + ["api/alerting/preview-filter"] = RuleBuilderPreview, + ["api/alerting/preview-rule"] = RuleBuilderPreview, ["api/alerting/system/preview"] = "system-policy preview is a UI builder affordance", ["api/audit/export"] = "np audit list exists but cannot download the CSV export", ["api/auth/oidc"] = BrowserAuthFlow, @@ -109,6 +112,8 @@ public sealed class EndpointClientCoverageTests ["api/ai/knowledge/capabilities"] = InteractiveAiSse, ["api/alerting/rules/*/disable"] = "MCP alerting tools toggle via update; dedicated enable/disable endpoints unused", ["api/alerting/rules/*/enable"] = "MCP alerting tools toggle via update; dedicated enable/disable endpoints unused", + ["api/alerting/preview-filter"] = RuleBuilderPreview, + ["api/alerting/preview-rule"] = RuleBuilderPreview, ["api/alerting/system/preview"] = "system-policy preview is a UI builder affordance", ["api/audit/export"] = "MCP has audit read tools but no CSV export", ["api/auth/login"] = "DELIBERATE: the MCP server reuses the CLI's DPAPI session (np auth login) — it never logs in itself", @@ -159,6 +164,8 @@ public sealed class EndpointClientCoverageTests ["api/users/*"] = "no MCP user-management tools — deliberate, user admin stays human", ["api/users/*/reactivate"] = "no MCP user-management tools — deliberate, user admin stays human", ["api/webhooks/*/*"] = WebhookIngress, + ["api/workflows/*/move-folder"] = "no MCP folder-RBAC tools — placement follows the same gap as api/shared-workflow-folders", + ["api/workflows/export"] = "export_workflow covers one workflow; a bulk dump is an operator (UI/CLI) task", }; [Theory] @@ -201,6 +208,35 @@ public void KnownGaps_AreAllStillReal(string client) "remove them so the list stays an honest inventory:\n" + string.Join("\n", stale)); } + // ---- the matcher itself ------------------------------------------------------------- + + /// + /// The guard is only as good as its matcher, and the matcher has no other test — a leniency + /// bug here reports the whole API as covered and nothing fails. These cases pin the two rules + /// that went wrong: a client wildcard must not swallow a literal endpoint route, and a + /// query-string interpolation must not turn its own segment into a wildcard. + /// + [Theory] + // A by-id/by-section client call may not stand in for a literal sibling route. + [InlineData("api/admin/settings/effective-sizing", "api/admin/settings/*", false)] + [InlineData("api/alerting/catalog", "api/alerting/*", false)] + // ...but it does cover the parameterized route it was written for. + [InlineData("api/admin/settings/*", "api/admin/settings/*", true)] + // An endpoint literal is covered by the same literal, whatever the casing. + [InlineData("api/workflows/export", "api/workflows/export", true)] + [InlineData("api/workflows/export", "API/Workflows/Export", true)] + // Segment counts must line up — a prefix is not a cover. + [InlineData("api/workflows/*/versions", "api/workflows/*", false)] + // The query-string idiom keeps its literal segment instead of collapsing to a wildcard. + [InlineData("api/alerting/deliveries", "api/alerting/deliveries{qs}", true)] + [InlineData("api/alerting/catalog", "api/alerting/deliveries{qs}", false)] + public void Matcher_TreatsWildcardsAsParametersOnly(string endpoint, string clientUrl, bool expected) + { + var routes = new HashSet(StringComparer.OrdinalIgnoreCase) { NormalizeRoute(clientUrl) }; + + IsCovered(NormalizeRoute(endpoint), routes).Should().Be(expected); + } + // ---- endpoint discovery ------------------------------------------------------------- private static readonly Regex ClassRoutePattern = new(@"^\s*\[Route\(""([^""]+)""\)\]", RegexOptions.Compiled); @@ -270,10 +306,16 @@ private static HashSet DiscoverClientUrls(string relativeProjectDir) // ---- matching ----------------------------------------------------------------------- /// - /// A client URL covers an endpoint when segment counts match and every segment is - /// compatible — equal, or a wildcard on either side (a client interpolation hole like - /// {(enable ? "enable" : "disable")} legitimately reaches literal endpoint - /// segments at runtime). + /// A client URL covers an endpoint when segment counts match and every segment is equal — + /// with * matching only *. + /// + /// A client wildcard deliberately does NOT satisfy an endpoint literal. The lenient + /// rule (wildcard on either side) is what let the effective-sizing endpoint ship + /// without a CLI client while this guard reported the surface as covered: the CLI's + /// api/admin/settings/{section} call normalizes to api/admin/settings/* and + /// then matched every literal sibling route under that path. The by-id/by-name/by-section + /// call sites that make this shape common are exactly the ones with literal siblings, so + /// the leniency cost coverage everywhere it applied. /// private static bool IsCovered(string endpoint, HashSet clientRoutes) { @@ -286,7 +328,7 @@ private static bool IsCovered(string endpoint, HashSet clientRoutes) var allCompatible = true; for (var i = 0; i < e.Length; i++) { - if (e[i] != c[i] && e[i] != "*" && c[i] != "*") { allCompatible = false; break; } + if (e[i] != c[i]) { allCompatible = false; break; } } if (allCompatible) return true; @@ -301,6 +343,13 @@ private static bool IsCovered(string endpoint, HashSet clientRoutes) /// Both sides collapse to the same shape: leading slash and query string stripped, every /// parameterized segment (route {id:guid}/{*path} or interpolation hole /// {Uri.EscapeDataString(x)}) becomes *, compared case-insensitively. + /// + /// A segment keeps whatever literal prefix precedes its first hole — only a segment + /// that STARTS with { is a parameter. This matters for the query-string idiom + /// $"api/alerting/deliveries{qs}": collapsing that to api/alerting/* both + /// loses the route it actually calls and hands the strict matcher a wildcard that would + /// otherwise report every literal sibling (catalog, preview-filter, …) as + /// covered by the deliveries call site. /// private static string NormalizeRoute(string raw) { @@ -309,7 +358,12 @@ private static string NormalizeRoute(string raw) if (q >= 0) path = path[..q]; var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries) - .Select(s => s.Contains('{') ? "*" : s.ToLowerInvariant()); + .Select(s => + { + var hole = s.IndexOf('{'); + if (hole < 0) return s.ToLowerInvariant(); + return hole == 0 ? "*" : s[..hole].ToLowerInvariant(); + }); return string.Join('/', segments); } diff --git a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationAlertingTests.cs b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationAlertingTests.cs index d4abb23d..140a543e 100644 --- a/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationAlertingTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/CommandIntegrationAlertingTests.cs @@ -45,6 +45,27 @@ public void AlertingList_RendersRules() result.Output.Should().Contain("Prod-Fail"); } + [Fact] + public void AlertingCatalog_RendersFieldsAndChannels() + { + using var h = new CommandTestHarness(); + h.Server.Given(Request.Create().WithPath("/api/alerting/catalog").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + eventTypes = new object[] { new { name = "ExecutionFailed", category = "execution", scopeable = true } }, + eventFields = new object[] { new { name = "workflowName", applies = "execution", type = "string", values = (string[]?)null } }, + channels = new[] { "Email", "Webhook" }, + dedupTemplateFields = new[] { "eventType" }, + })); + + var result = h.Run("alerting", "catalog"); + + result.ExitCode.Should().Be(ExitCodes.Success); + result.Output.Should().Contain("workflowName"); + result.Output.Should().Contain("Email"); + result.Output.Should().Contain("ExecutionFailed"); + } + [Fact] public void AlertingCreate_TranslatesEventsAndRoutes_AndPostsBody() { diff --git a/tests/NodePilot.Cli.Tests/Commands/WorkflowResolverTests.cs b/tests/NodePilot.Cli.Tests/Commands/WorkflowResolverTests.cs index 349751a9..5147e608 100644 --- a/tests/NodePilot.Cli.Tests/Commands/WorkflowResolverTests.cs +++ b/tests/NodePilot.Cli.Tests/Commands/WorkflowResolverTests.cs @@ -40,37 +40,35 @@ public async Task ByGuid_HitsGetWorkflowDirectly() } [Fact] - public async Task ByName_UniqueMatch_Resolves() + public async Task ByName_UniqueMatch_ResolvesViaByNameEndpoint() { - var idA = Guid.NewGuid(); - var idB = Guid.NewGuid(); - _server.Given(Request.Create().WithPath("/api/workflows").UsingGet()) - .RespondWith(Response.Create().WithStatusCode(200).WithBody(List( - (idA, "Build"), (idB, "Report")))); + var id = Guid.NewGuid(); + _server.Given(Request.Create().WithPath("/api/workflows/by-name/Report").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Single(id, "Report"))); var w = await WorkflowResolver.ResolveAsync(_client, "Report", CancellationToken.None); - w.Id.Should().Be(idB); + w.Id.Should().Be(id); + // The whole workflow list no longer travels over the wire to resolve one name. + _server.LogEntries.Should().NotContain(e => e.RequestMessage!.AbsolutePath == "/api/workflows" && e.RequestMessage!.Method == "GET"); } [Fact] - public async Task ByName_CaseInsensitive() + public async Task ByName_EscapesTheName() { var id = Guid.NewGuid(); - _server.Given(Request.Create().WithPath("/api/workflows").UsingGet()) - .RespondWith(Response.Create().WithStatusCode(200).WithBody(List((id, "Build")))); + _server.Given(Request.Create().WithPath("/api/workflows/by-name/Nightly Backup").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Single(id, "Nightly Backup"))); - var w = await WorkflowResolver.ResolveAsync(_client, "build", CancellationToken.None); + var w = await WorkflowResolver.ResolveAsync(_client, "Nightly Backup", CancellationToken.None); w.Id.Should().Be(id); } [Fact] - public async Task ByName_AmbiguousMatch_Throws() + public async Task ByName_Ambiguous_TranslatesTheConflict() { - var idA = Guid.NewGuid(); - var idB = Guid.NewGuid(); - _server.Given(Request.Create().WithPath("/api/workflows").UsingGet()) - .RespondWith(Response.Create().WithStatusCode(200).WithBody(List( - (idA, "Build"), (idB, "Build")))); + _server.Given(Request.Create().WithPath("/api/workflows/by-name/Build").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(409) + .WithBody("""{"message":"Multiple workflows named 'Build' — disambiguate with the GUID."}""")); var ex = await Assert.ThrowsAsync(() => WorkflowResolver.ResolveAsync(_client, "Build", CancellationToken.None)); @@ -80,8 +78,8 @@ public async Task ByName_AmbiguousMatch_Throws() [Fact] public async Task ByName_NotFound_Throws() { - _server.Given(Request.Create().WithPath("/api/workflows").UsingGet()) - .RespondWith(Response.Create().WithStatusCode(200).WithBody("[]")); + _server.Given(Request.Create().WithPath("/api/workflows/by-name/Missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); var ex = await Assert.ThrowsAsync(() => WorkflowResolver.ResolveAsync(_client, "Missing", CancellationToken.None)); @@ -96,10 +94,4 @@ private static string Single(Guid id, string name) => $$""" "lastExecution": null, "successCount": 0, "totalCount": 0, "avgDurationMs": null, "checkedOutByUserId": null, "checkedOutByUserName": null, "checkedOutAt": null } """; - - private static string List(params (Guid Id, string Name)[] rows) - { - var items = rows.Select(r => Single(r.Id, r.Name)); - return "[" + string.Join(",", items) + "]"; - } } diff --git a/tests/NodePilot.Mcp.Tests/Tools/AlertingToolsTests.cs b/tests/NodePilot.Mcp.Tests/Tools/AlertingToolsTests.cs index 2cb5b5e3..8b5447b0 100644 --- a/tests/NodePilot.Mcp.Tests/Tools/AlertingToolsTests.cs +++ b/tests/NodePilot.Mcp.Tests/Tools/AlertingToolsTests.cs @@ -42,6 +42,26 @@ public async Task ListAlertingRules_ReturnsSummaries_WithoutSecrets() json.Should().NotContain("__unchanged__", "the route secret must never be surfaced"); } + [Fact] + public async Task GetAlertingCatalog_ReturnsTheRuleVocabulary() + { + using var api = new TestApi(); + api.Server.Given(Request.Create().WithPath("/api/alerting/catalog").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new + { + eventTypes = new object[] { new { name = "ExecutionFailed", category = "execution", scopeable = true } }, + eventFields = new object[] { new { name = "workflowName", applies = "execution", type = "string", values = (string[]?)null } }, + channels = new[] { "Email", "GenericWebhook" }, + dedupTemplateFields = new[] { "eventType" }, + })); + + var tools = new AlertingTools(api.Client()); + var json = JsonSerializer.Serialize(await tools.GetAlertingCatalog()); + + json.Should().Contain("ExecutionFailed").And.Contain("workflowName") + .And.Contain("GenericWebhook").And.Contain("dedupTemplateFields"); + } + [Fact] public async Task CreateAlertingRule_PostsRoutes_AndReturnsId() {