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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Routen + Rollen-Gating stehen an den Controllern in `src/NodePilot.Api/Controlle

## Edit-Lifecycle (SCOrch-style Edit-Lock)

Workflows haben einen per-User-Edit-Lock (`CheckedOutByUserId` + `CheckedOutAt`). Mutierende Endpoints liefern `423 Locked` wenn Caller nicht Lock-Owner. `Disable` ist **nicht** lock-gegated (Incident-Kill-Switch).
Workflows haben einen per-User-Edit-Lock (`CheckedOutByUserId` + `CheckedOutAt`). Mutierende Endpoints liefern `423 Locked` wenn Caller nicht Lock-Owner. `PUT` und `publish` verlangen `expectedVersion`; ein veralteter Entwurf → `409 workflow_version_conflict`. `Disable` ist **nicht** lock-gegated (Incident-Kill-Switch).

| Endpoint | Verhalten |
|---|---|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,16 @@ foreach ($workflow in @($bundle.workflows)) {
Write-Host "Erstellt: $($workflow.name)"
$existing += $current
if ($workflow.isEnabled -eq $true) {
$body.expectedVersion = $current.version
$current = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/publish" -Body $body -Headers $headers
Write-Host "Veroeffentlicht und aktiviert: $($workflow.name)"
}
}
else {
# Existing workflows require an edit lock. Publish saves the definition, enables it and
# releases the lock in one step, so a re-run cannot leave a workflow half updated.
Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/lock" -Headers $headers | Out-Null
$current = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/lock" -Headers $headers
$body.expectedVersion = $current.version
$current = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/publish" -Body $body -Headers $headers
Write-Host "Aktualisiert und aktiviert: $($workflow.name)"
}
Expand Down
1 change: 1 addition & 0 deletions scripts/nodepilot_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def publish_workflow(http, identity, name, definition):
status, workflow = http("POST", "/api/workflows", token=token, body=payload)
if status != 201:
raise RuntimeError(f"Cannot create workflow (HTTP {status})")
payload["expectedVersion"] = workflow["version"]
status, published = http("POST", f"/api/workflows/{workflow['id']}/publish", token=token, body=payload)
if status != 200:
raise RuntimeError(f"Cannot publish workflow (HTTP {status}); no executions started")
Expand Down
1 change: 1 addition & 0 deletions scripts/stress-test/launch-40x.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ if ($wf) {
$body = $payload | ConvertTo-Json -Depth 50
$wf = Invoke-RestMethod -Method POST -Uri "$BaseUrl/api/workflows" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body))
}
$payload.expectedVersion = $wf.version
$body = $payload | ConvertTo-Json -Depth 50
$wf = Invoke-RestMethod -Method POST -Uri "$BaseUrl/api/workflows/$($wf.id)/publish" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body))
Log "Workflow id=$($wf.id) name=$($wf.name)"
Expand Down
4 changes: 4 additions & 0 deletions scripts/tech-demo/seed.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ if ($existingChild) {
}
$childResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows" -Headers $authHeaders -Body $childReq
$childId = $childResp.id
$childReq.expectedVersion = $childResp.version
Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows/$childId/publish" -Headers $authHeaders -Body $childReq | Out-Null
Write-Host "Created child workflow: $childId" -ForegroundColor Green
}
Expand All @@ -231,6 +232,7 @@ if ($existingMain) {
}
$mainResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows" -Headers $authHeaders -Body $mainReq
$mainId = $mainResp.id
$mainReq.expectedVersion = $mainResp.version
Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows/$mainId/publish" -Headers $authHeaders -Body $mainReq | Out-Null
Write-Host "Created main workflow: $mainId" -ForegroundColor Green
}
Expand All @@ -250,6 +252,7 @@ if ($includeXl) {
}
$xlResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows" -Headers $authHeaders -Body $xlReq
$xlId = $xlResp.id
$xlReq.expectedVersion = $xlResp.version
Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows/$xlId/publish" -Headers $authHeaders -Body $xlReq | Out-Null
Write-Host "Created XL workflow: $xlId" -ForegroundColor Green
}
Expand All @@ -270,6 +273,7 @@ if ($includePlanar) {
}
$planarResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows" -Headers $authHeaders -Body $planarReq
$planarId = $planarResp.id
$planarReq.expectedVersion = $planarResp.version
Invoke-JsonApi -Method POST -Url "$BaseUrl/api/workflows/$planarId/publish" -Headers $authHeaders -Body $planarReq | Out-Null
Write-Host "Created Planar workflow: $planarId" -ForegroundColor Green
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-suite/Install-TestSuite.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ foreach ($entry in @($manifest.workflows)) {
$current = Invoke-NodePilotJson -Method POST -Path '/api/workflows' -Body $body
$existing += $current
if ($wantEnabled) {
$body.expectedVersion = $current.version
$null = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/publish" -Body $body
Write-Host "created + published : $($wf.name)"
}
Expand All @@ -387,6 +388,7 @@ foreach ($entry in @($manifest.workflows)) {
if (-not $lockedByMe) {
$null = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/lock"
}
$body.expectedVersion = $current.version
if ($wantEnabled) {
$null = Invoke-NodePilotJson -Method POST -Path "/api/workflows/$($current.id)/publish" -Body $body
Write-Host "updated + published : $($wf.name)"
Expand Down
12 changes: 9 additions & 3 deletions scripts/test_nodepilot_admin_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,12 @@ def respond(self):
elif self.path == "/api/workflows/wf" and self.command == "DELETE":
code = 204
elif self.path == "/api/workflows/wf":
response = {"id": "wf", "name": "Stress-Test", "isEnabled": True,
response = {"id": "wf", "name": "Stress-Test", "isEnabled": True, "version": 3,
"folderId": "existing-folder", "checkedOutByUserId": fixture.owner}
elif self.path == "/api/workflows" and self.command == "POST":
code, response = 201, {"id": "wf", "name": body["name"], "checkedOutByUserId": "caller"}
code, response = 201, {"id": "wf", "name": body["name"], "version": 1, "checkedOutByUserId": "caller"}
elif self.path == "/api/workflows/wf/lock":
code, response = (409 if fixture.owner == "another-user" else 200), {"id": "wf"}
code, response = (409 if fixture.owner == "another-user" else 200), {"id": "wf", "version": 3}
elif self.path == "/api/workflows/wf/publish":
code, response = fixture.publish_status, {"id": "wf", "name": "Stress-Test"}
elif self.path == "/api/workflows/wf/execute":
Expand Down Expand Up @@ -168,6 +168,11 @@ def test_own_lock_is_reused(self):
self.assertEqual(0, self.run_python("stress-test/launch-40x.py"))
self.assertFalse(any(path.endswith("/lock") for _, path, _ in self.calls))

def test_publish_sends_the_version_it_read(self):
self.assertEqual(0, self.run_python("stress-test/launch-40x.py"))
publishes = [body for _, path, body in self.calls if path.endswith("/publish")]
self.assertEqual([3], [body["expectedVersion"] for body in publishes])

def test_foreign_lock_and_failed_publish_never_launch(self):
for owner, publish_status in (("another-user", 200), (None, 400)):
self.owner, self.publish_status = owner, publish_status
Expand Down Expand Up @@ -230,6 +235,7 @@ def test_demo_seed_publishes_every_new_workflow(self):
creates = [body for method, path, body in self.calls if method == "POST" and path == "/api/workflows"]
publishes = [body for _, path, body in self.calls if path.endswith("/publish")]
self.assertEqual(4, len(creates))
self.assertTrue(all(body.pop("expectedVersion") == 1 for body in publishes))
self.assertEqual(creates, publishes)
self.assertTrue(all(isinstance(body["definitionJson"], str) for body in creates))

Expand Down
8 changes: 6 additions & 2 deletions src/NodePilot.Api/Controllers/WorkflowEditingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,12 @@ public async Task<ActionResult<WorkflowResponse>> Publish(
if (await RequireWorkflowAccessAsync(workflow, NodePilot.Core.Interfaces.ResourceOp.Edit, ct) is { } d6) return d6;
if (await EnsureWriteLockAsync(workflow, ct) is { } locked) return locked;

const string publishConflictMessage =
"Workflow was published concurrently. Reload the workflow and retry publish.";
// The compare-and-swap below also matches on workflow.Version, so this check is atomic.
if (request.ExpectedVersion != workflow.Version)
return await LostCompareAndSwapAsync(id, publishConflictMessage, ct);

var sizeError = ValidateDefinitionJson(request.DefinitionJson);
if (sizeError is not null) return sizeError;
if (NodePilot.Api.Security.WorkflowActivationValidation.Validate(request.DefinitionJson) is { } activationError)
Expand All @@ -479,8 +485,6 @@ public async Task<ActionResult<WorkflowResponse>> Publish(

// Publish through the shared compare-and-swap (see CompareAndSwapWorkflowAsync); the
// setter chain additionally enables the workflow and releases the edit lock.
const string publishConflictMessage =
"Workflow was published concurrently. Reload the workflow and retry publish.";
int updated;
try
{
Expand Down
2 changes: 2 additions & 0 deletions src/NodePilot.Api/Controllers/WorkflowsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ public async Task<IActionResult> Update(Guid id, UpdateWorkflowRequest request,
if (await RequireWorkflowAccessAsync(workflow, ResourceOp.Edit, ct) is { } denied) return denied;

if (await EnsureWriteLockAsync(workflow, ct) is { } locked) return locked;
// The guarded update below also matches on workflow.Version, so this check is atomic.
if (request.ExpectedVersion != workflow.Version) return await WorkflowMutationConflictAsync(id, ct);
var lockOwnerId = this.GetCurrentUserId();

var sizeError = ValidateDefinitionJson(request.DefinitionJson);
Expand Down
16 changes: 14 additions & 2 deletions src/NodePilot.Api/Dtos/WorkflowDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ namespace NodePilot.Api.Dtos;
/// chosen folder before persisting.
/// </summary>
public record CreateWorkflowRequest(string Name, string? Description, string DefinitionJson, Guid? FolderId = null);
public record UpdateWorkflowRequest(string Name, string? Description, string DefinitionJson);
/// <summary>
/// Body for <c>PUT /api/workflows/{id}</c>. <see cref="ExpectedVersion"/> is the workflow version
/// the client's draft is based on; a mismatch returns 409 so a stale draft never replaces a newer save.
/// </summary>
public record UpdateWorkflowRequest(
string Name,
string? Description,
string DefinitionJson,
[property: System.Text.Json.Serialization.JsonRequired] int ExpectedVersion);

/// <summary>
/// Body for <c>PUT /api/workflows/{id}/concurrency-limit</c>. Null means unlimited.
Expand Down Expand Up @@ -183,4 +191,8 @@ public record WorkflowCapabilities(bool CanRead, bool CanRun, bool CanEdit, bool
/// — publish is "save current draft + enable + unlock" in one atomic transaction, so it
/// needs the full editable surface.
/// </summary>
public record PublishWorkflowRequest(string Name, string? Description, string DefinitionJson);
public record PublishWorkflowRequest(
string Name,
string? Description,
string DefinitionJson,
[property: System.Text.Json.Serialization.JsonRequired] int ExpectedVersion);
4 changes: 2 additions & 2 deletions src/NodePilot.Cli/Api/Dtos/WorkflowDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ public sealed record WorkflowCapabilities(

public sealed record CreateWorkflowRequest(
string Name, string? Description, string DefinitionJson, Guid? FolderId = null);
public sealed record UpdateWorkflowRequest(string Name, string? Description, string DefinitionJson);
public sealed record PublishWorkflowRequest(string Name, string? Description, string DefinitionJson);
public sealed record UpdateWorkflowRequest(string Name, string? Description, string DefinitionJson, int ExpectedVersion);
public sealed record PublishWorkflowRequest(string Name, string? Description, string DefinitionJson, int ExpectedVersion);
public sealed record SetWorkflowConcurrencyLimitRequest(int? MaxConcurrentExecutions);
public sealed record RollbackRequest(string? Reason);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ protected override async Task<int> RunAsync(CommandContext _, WorkflowPublishSet

var api = ClientFactory.Create(session);
var w = await WorkflowResolver.ResolveAsync(api, settings.IdOrName, ct);
var req = new PublishWorkflowRequest(settings.Name ?? w.Name, settings.Description ?? w.Description, json);
var req = new PublishWorkflowRequest(settings.Name ?? w.Name, settings.Description ?? w.Description, json, w.Version);
var published = await api.PublishWorkflowAsync(w.Id, req, ct);
writer.Success($"Workflow [bold]{Markup.Escape(published.Name)}[/] published (Version {published.Version}, Enabled).");
return ExitCodes.Success;
Expand Down
4 changes: 2 additions & 2 deletions src/NodePilot.Mcp/Api/Dtos/Dtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ public sealed record WorkflowCapabilities(

public sealed record CreateWorkflowRequest(
string Name, string? Description, string DefinitionJson, Guid? FolderId = null);
public sealed record UpdateWorkflowRequest(string Name, string? Description, string DefinitionJson);
public sealed record PublishWorkflowRequest(string Name, string? Description, string DefinitionJson);
public sealed record UpdateWorkflowRequest(string Name, string? Description, string DefinitionJson, int ExpectedVersion);
public sealed record PublishWorkflowRequest(string Name, string? Description, string DefinitionJson, int ExpectedVersion);
public sealed record SetWorkflowConcurrencyLimitRequest(int? MaxConcurrentExecutions);
public sealed record RollbackRequest(string? Reason);

Expand Down
4 changes: 2 additions & 2 deletions src/NodePilot.Mcp/Tools/WorkflowEditTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,10 @@ private async Task<object> PersistAsync(
{
if (publish)
{
var r = await ApiErrorMapper.Guard(() => _api.PublishWorkflowAsync(wf.Id, new PublishWorkflowRequest(name, description, definitionJson), ct));
var r = await ApiErrorMapper.Guard(() => _api.PublishWorkflowAsync(wf.Id, new PublishWorkflowRequest(name, description, definitionJson, wf.Version), ct));
return new { published = true, workflowId = r.Id, version = r.Version, enabled = r.IsEnabled, notes };
}
await ApiErrorMapper.Guard(() => _api.UpdateWorkflowAsync(wf.Id, new UpdateWorkflowRequest(name, description, definitionJson), ct));
await ApiErrorMapper.Guard(() => _api.UpdateWorkflowAsync(wf.Id, new UpdateWorkflowRequest(name, description, definitionJson, wf.Version), ct));
return new { saved = true, mode = "draft", workflowId = wf.Id, notes };
}

Expand Down
6 changes: 3 additions & 3 deletions src/nodepilot-docs-ui/content/de/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,18 @@ curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflow
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X PUT "$NP/api/workflows/21f1c0d4-..." \
-H 'Content-Type: application/json' \
-d '{ "name": "Deploy App v2", "description": "updated",
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}" }' -i # 204
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 3 }' -i # 204

# Publish (atomar), Duplicate, Cancel-All
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../publish" \
-H 'Content-Type: application/json' \
-d '{ "name": "Deploy App", "description": null,
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}" }'
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 4 }'
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../duplicate"
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../cancel-all" # {"total":3,"signalled":2}
```

409-Lock-Konflikt-Body: `{"message":"Workflow is already locked by alice.","lockedByUserName":"alice","lockedAt":"..."}`. 409-Version-Konflikt: `{"code":"workflow_version_conflict","currentVersion":4}`.
409-Lock-Konflikt-Body: `{"message":"Workflow is already locked by alice.","lockedByUserName":"alice","lockedAt":"..."}`. 409-Version-Konflikt: `{"code":"workflow_version_conflict","currentVersion":4}`. `PUT` und `publish` verlangen `expectedVersion`, die Version, auf der der Entwurf basiert; weicht sie von der gespeicherten ab, antwortet der Server mit diesem 409 und überschreibt nichts.

## Versionen & Contract

Expand Down
6 changes: 3 additions & 3 deletions src/nodepilot-docs-ui/content/de/api/workflow-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Workflows haben einen per-User Edit-Lock (`CheckedOutByUserId` + `CheckedOutAt`)
|---|---|
| `POST /lock` | Atomar `IsEnabled=false` + Lock-Fields setzen. 409 wenn schon gelockt. |
| `POST /unlock` | Lock-Fields auf null. `IsEnabled` bleibt unverändert. |
| `POST /publish` | Atomar: Save + `IsEnabled=true` + Unlock. Die übergebene Definition wird vorher validiert: ein schwaches Webhook-HMAC-Secret liefert `400 weak_webhook_hmac_secret`, eine für Quartz ungültige Cron-Expression liefert `400 invalid_cron_expression`. |
| `POST /publish` | Atomar: Save + `IsEnabled=true` + Unlock. Wie `PUT` verlangt es `expectedVersion`; ein veralteter Entwurf liefert `409 workflow_version_conflict`. Die übergebene Definition wird vorher validiert: ein schwaches Webhook-HMAC-Secret liefert `400 weak_webhook_hmac_secret`, eine für Quartz ungültige Cron-Expression liefert `400 invalid_cron_expression`. |
| `POST /force-unlock` | Admin-only. Bricht fremden Lock. |

## UX-Flow
Expand Down Expand Up @@ -148,13 +148,13 @@ curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflow
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X PUT "$NP/api/workflows/21f1c0d4-..." \
-H 'Content-Type: application/json' \
-d '{ "name":"Deploy App", "description":"wip",
"definitionJson":"{\"nodes\":[...],\"edges\":[...]}" }' -i # 204
"definitionJson":"{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 3 }' -i # 204

# Publish — atomar Save + Enable + Unlock. Workflow ist wieder Productive.
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../publish" \
-H 'Content-Type: application/json' \
-d '{ "name":"Deploy App", "description":null,
"definitionJson":"{\"nodes\":[...],\"edges\":[...]}" }'
"definitionJson":"{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 4 }'

# Exit ohne Publish: nur Unlock — Workflow bleibt Disabled
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../unlock"
Expand Down
6 changes: 3 additions & 3 deletions src/nodepilot-docs-ui/content/en/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,18 @@ curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflow
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X PUT "$NP/api/workflows/21f1c0d4-..." \
-H 'Content-Type: application/json' \
-d '{ "name": "Deploy App v2", "description": "updated",
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}" }' -i # 204
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 3 }' -i # 204

# Publish (atomic), duplicate, cancel-all
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../publish" \
-H 'Content-Type: application/json' \
-d '{ "name": "Deploy App", "description": null,
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}" }'
"definitionJson": "{\"nodes\":[...],\"edges\":[...]}", "expectedVersion": 4 }'
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../duplicate"
curl -s -b cookie.jar -H "X-CSRF-Token: $(csrf_token)" -X POST "$NP/api/workflows/21f1c0d4-.../cancel-all" # {"total":3,"signalled":2}
```

The 409 lock-conflict body: `{"message":"Workflow is already locked by alice.","lockedByUserName":"alice","lockedAt":"..."}`. A 409 version conflict: `{"code":"workflow_version_conflict","currentVersion":4}`.
The 409 lock-conflict body: `{"message":"Workflow is already locked by alice.","lockedByUserName":"alice","lockedAt":"..."}`. A 409 version conflict: `{"code":"workflow_version_conflict","currentVersion":4}`. `PUT` and `publish` require `expectedVersion`, the version the draft is based on; when it differs from the stored one the server answers with this 409 and overwrites nothing.

## Versions & contract

Expand Down
Loading
Loading