Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,40 @@ Planned version: **1.5.4**.
own session file, so profiles such as `prod.a` and `prod_a` no longer share one login (#620).
- A `matches` condition with an invalid pattern, a regex timeout or an input longer than 1,048,576
characters fails the run and names the edge, instead of counting as "no match" (#680).
- Two active nodes can no longer share an output variable, regardless of case: saving, importing
or an MCP change with such a pair is rejected. A disabled node is ignored, so a disabled copy can
no longer take over the alias of the active one (#703).

### Fixed

- **Designer**
- Renaming an output variable updates every reference to it: templates in any field, a dynamic
target machine or credential, breakpoint conditions, decision cases and edge conditions (#689).
- Copying, pasting and duplicating give each copy its own output variable (`name_2`) and rewrite
the references inside the copy, so it no longer takes over the original's bindings (#703).
- Inserting an activity on a conditional edge keeps the condition in front of the new activity,
so the branch still runs only when the condition holds (#693).
- Deleting an edge from the command palette marks the workflow as changed and can be undone
(#694).
- Undo right after typing in a property field restores the value from before the edit (#595).
- A save, lock change, publish or run that finishes after you switched to another workflow no
longer acts on the workflow you opened in the meantime (#692).
- Undo and a restored draft also update open property panels: an edge switches back to its
expression, an agent's host list shows the restored values (#695).
- Text-file delete can switch to line-range and pattern mode again (#601).
- `waitForCondition` in `portOpen` mode saves the default port 443 it shows, and a probe without
host or valid port is flagged (#702).
- `scheduledTask` registration accepts only built-in or managed service accounts and no longer
suggests ordinary domain users with delegation (#705).
- The step details of a past run label the workflow's current configuration as such and show the
target machine the run actually used (#603).
- **Management pages and settings**
- Saving credentials, machines, users, global variables, maintenance windows, custom activities,
alerting rules and policies or a settings section locks the form until the server answers. A
late answer no longer closes or resets a record opened in the meantime (#690).
- **AI**
- The AI chats keep working when browser storage is full or blocked. A note says what survives a
reload; once the storage budget is reached, only the newest history is kept (#701).
- **Running workflows**
- A breakpoint condition can use the run's inputs (`{{manual.*}}`) (#685).
- Input values that look like `{{…}}` stay plain text and no longer fail the step as unresolved
Expand All @@ -52,7 +83,8 @@ Planned version: **1.5.4**.
- The Windows Update health example applies `freshnessDays` (#734). The temp-file cleanup
example writes a new archive per run and keeps the earlier ones (#723). The README showcase
reports a failed probe instead of success (#724). The tech demo uses one name for its child
workflow (#722).
workflow (#722). The SCCM/AD example of the browser demo is valid PowerShell again, and CI
parses every demo fixture script (#698).
- The `powerManagement` test moves to the invasive set and needs a disposable host, because
`abort` cancels a pending host restart (#720). Acknowledgement files accept only GUID
correlation IDs (#721). Verification checks every run in the window and fails when required
Expand All @@ -66,6 +98,14 @@ Planned version: **1.5.4**.

### Security

- Copying nodes to the clipboard also masks recognised secrets in edge conditions and in lists, not
only in node fields (#574).
- Script generation sends the current script only to the AI profile the approval was given for. If
the profile, its endpoint or model changed in between, the request fails with
`409 LLM_RECIPIENT_CHANGED` and the dialog asks again. The workflow assistant also treats a model
change as a new recipient for its chat history (#688).
- A write query in the database viewer runs only after the confirmation phrase is typed; pressing
Run again while the confirmation is open no longer starts it (#691).
- The CLI, the MCP server and the Python admin scripts under `scripts/` no longer follow redirects
with the login token, so the token is never sent to another host (#711).
- Logging out with the CLI also ends the MCP server's session right away (#712).
Expand Down
12 changes: 7 additions & 5 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,15 @@ themselves require a service restart. A proxy does not replace a missing network
The workflow assistant masks named secrets before the model call. With
`ShareScriptsWithAssistant` enabled, however, the provider receives scripts, SQL, and arguments
to the extent that secret detection does not mask them. This detection is heuristic.
Changing the profile, endpoint, or sharing switch excludes the previous
chat history from the next call. The global knowledge chat applies the same rule and also
treats a model change as a new context; the answer then notes that the earlier conversation
was not sent.
Changing the profile, its endpoint or model, or the sharing switch excludes the previous
chat history from the next call. The global knowledge chat applies the same rule; the answer
then notes that the earlier conversation was not sent.

Script generation sends the current script only with the separate approval
`IncludeCurrentScript=true`. It receives upstream variables as a schema without their values.
`IncludeCurrentScript=true`, and only to the profile the approval was given for: if the active
profile, its endpoint or model changed in between, the request fails with
`409 LLM_RECIPIENT_CHANGED` and the editor asks again. It receives upstream variables as a
schema without their values.
Generated scripts are shown in the editor before they are run. Proposals of the
workflow assistant keep existing secrets and layouts when merged.
Changes to masked conditions may have to be made manually.
Expand Down
19 changes: 18 additions & 1 deletion scripts/Test-ExampleWorkflowScripts.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#requires -Version 5.1
# Runs the scripts of the shipped example workflows against synthetic evidence: the temp
# cleanup keeps every run's archive, the host health check alerts on faults and missing
# evidence, and the Windows Update check applies freshnessDays.
# evidence, and the Windows Update check applies freshnessDays. The scripts of the browser
# demo fixtures are parsed only, never run.
[CmdletBinding()]
param()
Set-StrictMode -Version 3.0
Expand Down Expand Up @@ -159,6 +160,22 @@ try {
$fresh = (Invoke-StepScript -Script $tailScript -Capture 'updateFresh').updateFresh
Assert-Example ($fresh -eq $case.Fresh) "Hotfix ages '$($case.Ages -join ',')' ($($script:hotfixMode)) gave updateFresh '$fresh' instead of '$($case.Fresh)'."
}

# --- browser demo fixtures: every runScript parses (they are exportable) --------------
$fixtures = Join-Path $PSScriptRoot '..\src\nodepilot-ui\demo\seed\fixtures'
foreach ($file in Get-ChildItem -LiteralPath $fixtures -Filter '*.workflow.json') {
$definition = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
$scripts = @($definition.nodes | Where-Object {
$_.PSObject.Properties['data'] -and $_.data.PSObject.Properties['activityType'] -and $_.data.activityType -eq 'runScript' })
Assert-Example ($scripts.Count -gt 0) "$($file.Name) has no runScript step to parse."
foreach ($node in $scripts) {
$tokens = $null
$parseErrors = $null
$null = [System.Management.Automation.Language.Parser]::ParseInput([string]$node.data.config.script, [ref]$tokens, [ref]$parseErrors)
$detail = $(if ($parseErrors.Count -gt 0) { $parseErrors[0].Message } else { '' })
Assert-Example ($parseErrors.Count -eq 0) "$($file.Name) step '$($node.id)' does not parse: $detail"
}
}
} finally {
Remove-Item -LiteralPath $sandbox -Recurse -Force
}
Expand Down
3 changes: 2 additions & 1 deletion src/NodePilot.Ai/AiContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public sealed record GenerateScriptRequest(
string? StepId,
IReadOnlyList<UpstreamVariableDto> UpstreamVariables,
string? CurrentScript,
bool IncludeCurrentScript = false);
bool IncludeCurrentScript = false,
string? ExpectedRecipientKey = null);

/// <summary>
/// Request body for <c>POST /api/ai/generate-workflow</c>. Just a free-text prompt — all activity
Expand Down
5 changes: 1 addition & 4 deletions src/NodePilot.Ai/Knowledge/KnowledgeAssistantService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using NodePilot.Core.Interfaces;

Expand Down Expand Up @@ -62,8 +60,7 @@ public async IAsyncEnumerable<ChatStreamEvent> StreamAskAsync(
var options = llmOptions.CurrentValue;
if (!options.TryResolveActiveProfile(out var profile))
throw LlmClientFactory.NoActiveProfile();
var recipientKey = WorkflowAssistantService.HistoryContextKey(options.ActiveProfileId.Trim(), profile);
var contextKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{recipientKey}\n{profile.Model}")), 0, 8).ToLowerInvariant();
var contextKey = LlmHistoryContext.Key(options.ActiveProfileId, profile);
var historyDropped = request.History is { Count: > 0 }
&& !string.Equals(request.HistoryContextKey, contextKey, StringComparison.Ordinal);
var turn = historyDropped ? request with { History = Array.Empty<AiChatTurnDto>() } : request;
Expand Down
1 change: 1 addition & 0 deletions src/NodePilot.Ai/LlmException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum LlmErrorKind
RateLimited,
UpstreamError,
MalformedResponse,
RecipientChanged,
}

/// <summary>
Expand Down
15 changes: 15 additions & 0 deletions src/NodePilot.Ai/LlmHistoryContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Security.Cryptography;
using System.Text;

namespace NodePilot.Ai;

public static class LlmHistoryContext
{
// The model is part of the recipient: behind a gateway, one endpoint routes to different providers.
public static string Key(string profileId, LlmProfileOptions profile)
{
var material = $"{profileId.Trim()}\n{profile.BaseUrl}\n{profile.Model}\n{profile.ShareScriptsWithAssistant}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(material));
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
}
16 changes: 14 additions & 2 deletions src/NodePilot.Ai/ScriptGenerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;

namespace NodePilot.Ai;

Expand All @@ -20,19 +21,30 @@ public sealed class ScriptGenerationService
private static readonly JsonSerializerOptions IndentedOptions = new() { WriteIndented = true };
private readonly ILlmClientFactory _llmFactory;
private readonly PromptCatalog _prompts;
private readonly IOptionsMonitor<LlmOptions> _options;

// The factory, not a pre-built client: Create() resolves the active LLM profile and throws
// when none is configured, so it has to run inside the call, after the controller's gate.
public ScriptGenerationService(ILlmClientFactory llmFactory, PromptCatalog prompts)
public ScriptGenerationService(ILlmClientFactory llmFactory, PromptCatalog prompts,
IOptionsMonitor<LlmOptions> options)
{
_llmFactory = llmFactory;
_prompts = prompts;
_options = options;
}

public async IAsyncEnumerable<ScriptStreamEvent> StreamAsync(
GenerateScriptRequest request, [EnumeratorCancellation] CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var options = _options.CurrentValue;
if (!options.TryResolveActiveProfile(out var profile))
throw LlmClientFactory.NoActiveProfile();
if (request.IncludeCurrentScript && !string.IsNullOrWhiteSpace(request.CurrentScript)
&& !string.Equals(request.ExpectedRecipientKey,
LlmHistoryContext.Key(options.ActiveProfileId, profile), StringComparison.Ordinal))
throw new LlmException(LlmErrorKind.RecipientChanged,
"The LLM recipient or sharing policy changed. Confirm the current recipient before sharing the script.");

// Server-side hard cap, in case the frontend sends more variables than expected.
var capped = request.UpstreamVariables;
Expand All @@ -55,7 +67,7 @@ public async IAsyncEnumerable<ScriptStreamEvent> StreamAsync(
int? promptTokens = null, completionTokens = null;
string? finishReason = null;

var llm = _llmFactory.Create();
var llm = _llmFactory.Create(profile);
await foreach (var evt in llm.StreamAsync(
new LlmRequest(_prompts.ScriptSystemPrompt, userPrompt), ct))
{
Expand Down
18 changes: 3 additions & 15 deletions src/NodePilot.Ai/WorkflowAssistantService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public async IAsyncEnumerable<ChatStreamEvent> StreamChatAsync(
// Earlier turns may quote content the current provider must not see (scripts shared with
// another profile, or before sharing was switched off). Only history from the same context
// goes out again.
var contextKey = HistoryContextKey(options.ActiveProfileId.Trim(), profile);
var contextKey = LlmHistoryContext.Key(options.ActiveProfileId, profile);
var historyDropped = request.History.Count > 0
&& !string.Equals(request.HistoryContextKey, contextKey, StringComparison.Ordinal);
var turn = historyDropped ? request with { History = Array.Empty<AiChatTurnDto>() } : request;
Expand Down Expand Up @@ -240,18 +240,6 @@ IEnumerable<ChatStreamEvent> OnDelta(string delta)
};
}

/// <summary>
/// Identifies what a chat history may have been shown: the profile id, its endpoint (an
/// operator can repoint a profile from a local model to a cloud one) and whether scripts were
/// shared. History is only sent again under the same key.
/// </summary>
internal static string HistoryContextKey(string profileId, LlmProfileOptions profile)
{
var material = $"{profileId}\n{profile.BaseUrl}\n{profile.ShareScriptsWithAssistant}";
var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(material));
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}

/// <summary>
/// Fetches enabled custom activities keyed by their <c>custom:&lt;key&gt;</c> type. Empty when
/// no store is configured.
Expand Down Expand Up @@ -566,8 +554,8 @@ public sealed record ToolResultEvent(string ToolId, string ToolName, string Resu
/// <paramref name="CompletionTokens"/> by this, not by <paramref name="DurationMs"/>, for a
/// throughput that matches what the LLM server reports.
/// </param>
/// <param name="HistoryContextKey">The key the client sends back so
/// the next turn's history is accepted (see <c>WorkflowAssistantService.HistoryContextKey</c>).</param>
/// <param name="HistoryContextKey">The key the client sends back so the next turn's history
/// is accepted only for the same provider and sharing policy.</param>
/// <param name="HistoryDropped">The request's history was left out
/// because it came from another profile or sharing setting.</param>
/// <param name="ScriptsShared">Workflow assistant only: scripts, SQL and arguments were sent
Expand Down
1 change: 1 addition & 0 deletions src/NodePilot.Api/Ai/LlmErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal static class LlmErrorCodes
{
public static string For(LlmException ex) => ex.Kind switch
{
LlmErrorKind.RecipientChanged => "LLM_RECIPIENT_CHANGED",
LlmErrorKind.Unreachable => "LLM_UNREACHABLE",
LlmErrorKind.Timeout => "LLM_TIMEOUT",
LlmErrorKind.Unauthorized => "LLM_UNAUTHORIZED",
Expand Down
1 change: 1 addition & 0 deletions src/NodePilot.Api/Ai/LlmErrorResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public static ActionResult MapLlmException(
var code = LlmErrorCodes.For(ex);
return ex.Kind switch
{
LlmErrorKind.RecipientChanged => controller.Conflict(new { code, message = ex.Message }),
LlmErrorKind.Unreachable => controller.LlmServiceUnavailable(code, ex.Message),
LlmErrorKind.Timeout => controller.LlmServiceUnavailable(code, ex.Message),
LlmErrorKind.Unauthorized => controller.LlmServiceUnavailable(code,
Expand Down
14 changes: 9 additions & 5 deletions src/NodePilot.Api/Controllers/AiKnowledgeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,22 @@ public ActionResult<KnowledgeCapabilitiesDto> Capabilities()
var toolSourcesEnabled = enabled
&& llm.TryResolveActiveProfile(out var activeProfile)
&& activeProfile.EnableToolCalling;
var scriptContextTargetHost = llmUsable && User.IsPrivileged()
&& llm.TryResolveActiveProfile(out var scriptProfile)
? DisplayHost(scriptProfile.BaseUrl)
: null;
string? scriptContextTargetHost = null;
string? scriptContextRecipientKey = null;
if (llmUsable && User.IsPrivileged() && llm.TryResolveActiveProfile(out var scriptProfile))
{
scriptContextTargetHost = DisplayHost(scriptProfile.BaseUrl);
scriptContextRecipientKey = LlmHistoryContext.Key(llm.ActiveProfileId, scriptProfile);
}
return Ok(new KnowledgeCapabilitiesDto(
Enabled: enabled,
Llm: llmUsable,
Docs: toolSourcesEnabled && k.DocsEnabled,
Operational: toolSourcesEnabled && k.OperationalEnabled,
SourceCode: toolSourcesEnabled && k.SourceCodeEnabled && User.IsPrivileged(),
Db: toolSourcesEnabled && k.DbEnabled && User.IsAdmin(),
ScriptContextTargetHost: scriptContextTargetHost));
ScriptContextTargetHost: scriptContextTargetHost,
ScriptContextRecipientKey: scriptContextRecipientKey));
}

private static string? DisplayHost(string? baseUrl)
Expand Down
3 changes: 2 additions & 1 deletion src/NodePilot.Api/Dtos/AiDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ public sealed record KnowledgeCapabilitiesDto(
bool Operational,
bool SourceCode,
bool Db,
string? ScriptContextTargetHost);
string? ScriptContextTargetHost,
string? ScriptContextRecipientKey);
25 changes: 0 additions & 25 deletions src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ public static AnalysisResult Analyze(JsonElement definition)
if (FindCycle(doc.Adjacency) is { Count: > 0 } cycle)
findings.Add(new Finding("error", "cycle", null, $"Cycle detected: {string.Join(" → ", cycle)} → {cycle[0]}. The engine has no inDegree fallback; cyclic graphs Fail."));

AddDuplicateOutputVariableFindings(doc, findings);
AddDuplicatePublishedParamFindings(doc, findings);
AddUnresolvedReferenceFindings(doc, definition, findings);

Expand Down Expand Up @@ -278,30 +277,6 @@ private static void AddUnresolvedReferenceFindings(
}
}

private static void AddDuplicateOutputVariableFindings(WorkflowDefinitionDocument doc, List<Finding> findings)
{
var seen = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var node in doc.Nodes)
{
if (doc.DisabledNodeIds.Contains(node.Id)) continue;
if (IsAnnotation(node.Type)) continue;
var outputVariable = node.Data.OutputVariable;
if (string.IsNullOrWhiteSpace(outputVariable)) continue;

if (seen.TryGetValue(outputVariable, out var firstNodeId))
{
findings.Add(new Finding(
"error",
"dup-output-variable",
node.Id,
$"outputVariable '{outputVariable}' is already used by node '{firstNodeId}' — downstream references {{{{{outputVariable}.output}}}} are ambiguous."));
}
else
{
seen[outputVariable] = node.Id;
}
}
}

private static void AddStartJobRunspaceFindings(WorkflowDefinitionDocument doc, List<Finding> findings)
{
Expand Down
Loading
Loading