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
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,37 @@ 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).
- **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 +80,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 +95,8 @@ 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).
- 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
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,14 @@
public static Dictionary<string, string> BuildOutputVariableAliasMap(IReadOnlyList<WorkflowNode> allNodes)
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var node in allNodes)
{
if (node.Data.OutputVariable is { Length: > 0 } variable && variable != node.Id)
dict[variable] = node.Id;
if (node.Data.Disabled) continue;
if (node.Data.OutputVariable is { Length: > 0 } variable)
{
if (!dict.TryAdd(variable, node.Id))
throw new InvalidOperationException($"Duplicate active output variable '{variable}'.");
}
Comment on lines +196 to 200
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

return dict;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static WorkflowDefinitionValidationResult Validate(JsonElement definition
return WorkflowDefinitionValidationResult.Invalid("root must be a JSON object");

var nodeIds = new HashSet<string>(StringComparer.Ordinal);
var outputVariables = new HashSet<string>(StringComparer.Ordinal);
var outputVariables = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var activityTypeByNodeId = new Dictionary<string, string?>(StringComparer.Ordinal);
if (definition.TryGetProperty("nodes", out var nodes))
{
Expand Down Expand Up @@ -162,8 +162,11 @@ private static WorkflowDefinitionValidationResult ValidateNode(
return WorkflowDefinitionValidationResult.Invalid(error!);
}

if (data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable))
outputVariables.Add(outputVariable.GetString()!);
var disabled = data.TryGetProperty("disabled", out var disabledValue) && disabledValue.ValueKind == JsonValueKind.True;
if (!disabled && data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable)
&& !outputVariables.Add(outputVariable.GetString()!))
return WorkflowDefinitionValidationResult.Invalid(
$"duplicate active output variable '{outputVariable.GetString()}'", "dup-output-variable", id);

return WorkflowDefinitionValidationResult.Valid;
}
Expand Down
2 changes: 1 addition & 1 deletion src/nodepilot-docs-ui/content/de/activities-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Bei Shutdown und Restart ist `force` standardmäßig `true`. Mit `force=false` m

**Remote.** Erfordert meist Admin auf dem Ziel.

- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force`
- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (Default SYSTEM; ein eingebautes Konto oder ein Managed Service Account wie `DOMAIN\gmsa$`, kein normaler Domänenbenutzer), `runLevel` (limited/highest), `description`, `force`
- **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime`
- **Provider-Fallback:** Aktionen auf vorhandenen Tasks verwenden zuerst die PowerShell-Cmdlets. Nur wenn der ScheduledTasks-CIM-Provider mit `0x80041318` fehlschlägt, greift die Activity innerhalb derselben lokalen/WinRM-PowerShell-Session auf die Task-Scheduler-Automation-API zurück. `register` bleibt ausschließlich Cmdlet-basiert.

Expand Down
2 changes: 1 addition & 1 deletion src/nodepilot-docs-ui/content/en/activities-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ For shutdown and restart, `force` defaults to `true`. With `force=false`, `delay

**Remote.** Usually requires admin rights on the target.

- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force`
- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM; a built-in account or a managed service account such as `DOMAIN\gmsa$`, not an ordinary domain user), `runLevel` (limited/highest), `description`, `force`
- **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime`
- **Provider fallback:** actions on existing tasks use the PowerShell cmdlets first. Only if the ScheduledTasks CIM provider fails with `0x80041318` does the activity fall back to the Task Scheduler automation API within the same local/WinRM PowerShell session. `register` stays cmdlet-based exclusively.

Expand Down
3 changes: 3 additions & 0 deletions src/nodepilot-ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ Applies to `src/nodepilot-ui/`. Project-wide rules are in the root `CLAUDE.md`.
- UI strings via `react-i18next` (namespaces per `src/i18n/locales/{de,en}/`, default DE) — new visible strings in **both** languages.
- **Colours always via design tokens/CSS variables** — never hardcode Tailwind colour literals (`text-gray-900`, `bg-white` break the dark skins). Native `<select>`: `option:hover` is not stylable in Chromium → use the custom dropdown component.
- Client state via Zustand stores (`src/stores/`), server state via TanStack React Query (`refetchOnWindowFocus:false`, SignalR invalidates caches).
- **Workflow references** change only through `lib/workflowReferences.ts`: templates, dynamic node bindings and condition operands share one traversal. Copy and duplicate give each node a fresh alias in `lib/workflowFragment.ts`; a copy never keeps an existing producer's alias.
- **Graph history** is recorded **before** the graph changes. `useWorkflowHistory.commitPropertyEdit` merges a typing burst into its first snapshot; the command palette deletes through the same commands as the canvas, so dirty state and Undo match.
- **Async completions** belong to the editing session that started them: CRUD editors and workflow execution use `useAsyncIdentity`, workflow saves additionally check the draft generation in `useWorkflowPersistence`. A late response must not close, reset or run a record opened after it; forms stay locked while a save is pending.
- **Typography:** `IBM Plex Sans Variable` (`--font-headline`/`--font-body`/`--font-label`, all three identical) + `IBM Plex Mono` (`--font-mono`), declared in the `@theme` of `index.css`. Both are self-hosted via fontsource — **no external font requests**: the prod CSP has no `font-src` and falls back to `default-src 'self'`, so a CDN font would be blocked in production. Body carries a `font-size-adjust` as the only density compensation (Plex has a smaller x-height than the previously used Inter); monospace is exempt. Monaco cannot use a CSS variable and keeps the stack as `MONO_FONT_STACK` in `lib/monacoSetup.ts` — `fontTokens.test.ts` keeps both sides identical. The docs website deliberately runs its own type system (Geist + JetBrains Mono).

- **Design tokens in JS:** The prod build minifies the CSS with Lightning CSS and shortens colours **inside** custom properties (`#ffffff` → `#fff`, `#ff0000` → `red`); the dev server does not — a raw token value is therefore not a reliable hex colour. Every JS reader of a `--color-*` token therefore normalises via `cssColorToHex` from `lib/cssColor.ts`: the Monaco theme bridge in `designer/ScriptEditorDialog.tsx` (Monaco accepts only 6-/8-digit hex and **throws** otherwise) and the ECharts tokens in `lib/chartTheme.ts`. The helper handles hex, `rgb()/rgba()` and named colours; `oklch()`/`color-mix()` deliberately return `null` → fallback of the caller. Regression protection is `e2e/script-editor.spec.ts`, because only the E2E suite runs against the **built** bundle.
Expand Down
Loading
Loading