Commit graph

3 commits

Author SHA1 Message Date
Sipke Schoorstra fe83d2b385
test(bpmn): event subprocesses, dormant and listener-backed (#7963)
* test(bpmn): event subprocesses, dormant and listener-backed (#7933)

Both flavours, and the three responsibilities the host has for them. The
production diff is empty: the applier and binder need no event-subprocess
code path, which is what #7909 measured and what this pins.

A dormant catcher -- error or escalation -- rides the FaultSignal seam and the
escalation signal path, and reaches the host as an ordinary StartWork for its
body. A listener-backed one gets a second StartWork for its listenerBindingRef
at scope start and a CancelWorkSubtree for it when the scope completes. Both
already apply like any other command.

The host responsibilities, each with the failure it would otherwise hide:

- The listener is armed at scope start and observable in the scope's own ledger
  before anything fires, not inferred from a fire that worked.
- A completing scope retires a still-armed listener. Pinned twice: at the root,
  where Elsa's own container completion would cancel the child regardless and
  only the scope's ledger tells the two apart, and inside a subprocess the
  workflow outlives, where a listener left behind is one something could still
  resume into.
- The start-element hint reaches the body and nothing else inherits it. The
  body's only start event is event-defined, so a body that never received the
  hint faults bpmn.start.none-available rather than starting somewhere
  plausible; the ordinary subprocess inside it faults bpmn.start.unresolved-hint
  if the hint travels where it must not. The scope's invocation correlation is
  read back after the body has run work of its own, because the dictionary is
  fixed for the scope's lifetime and the hint is read from it.
- A non-interrupting listener fired twice re-arms onto the slot the first fire
  vacated, holding one live record and one bookmark at a time. This is the case
  the completed-work-removed-before-the-interpreter-is-asked ordering exists
  for, and it is now observable; BpmnHostInvariantTests points at it.

The library's declaration rules are pinned as refusals rather than gaps: a body
with more than one start event, a second error-triggered event subprocess in a
scope, and a non-interrupting error event subprocess are each refused when the
scope builds its graph, before any work starts. The last is additionally
dropped at import, with the rest of the document reading as written -- the
dropped body carries an undeclared serviceTask, so an import that still
succeeds is what proves the drop took its bindings with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): pin the catch-all escalation event subprocess refusal

Two error-triggered event subprocesses per scope was pinned but its sibling
rule -- at most one code-less catch-all escalation event subprocess -- had
no test. Add TwoCatchAllEscalationEventSubprocesses_AreRefused, mirroring
the error refusal test, and let Escalation() build a code-less definition.

Also record in BpmnCommandApplier why CancelSubtreeAsync's explicit
subtree cancellation is redundant on the scope-completion path (Elsa's own
container-completion behaviour already covers it) while the ledger removal
above it is not, so a future reader does not "simplify" the ledger removal
away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:02:51 +02:00
Sipke Schoorstra 457e2a6185
feat(bpmn): publish-time validation gate for unbound tasks (#7958)
* feat(bpmn): refuse to publish a definition with an unbound BPMN task

BpmnWorkBinder already refuses an UnboundTask with no elsa:activityBinding
at import - that is the first net. A definition can be edited after
import, through Elsa's own designer rather than the BPMN document, and
that edit can remove the activity a task was bound to without touching
the document snapshot the scope still carries. ValidateBpmnProcessBindings
is the second net: a WorkflowDefinitionValidating handler that walks the
materialized workflow graph (not the inert BpmnProcessDefinition snapshot
or the stored BPMN source, both of which a graph-only edit leaves
untouched) and fails publication for any task-family element whose
binding no longer resolves to an activity in the graph, naming the
offending element id.

Import-time Dropped/Degraded findings from BpmnImportAnalysis are not
persisted anywhere a publish-time handler can reach, and BpmnImportIssue
carries no field distinguishing a Dropped finding that changes executable
meaning from one that does not; WorkflowValidationError has no severity
concept either. Extending this gate to those findings would mean guessing
at a classification the library does not expose, so it is left alone -
see the delivery notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): refuse to publish a BPMN task bound to a missing activity type

The publish gate only checked that a binding pointed at some activity id;
it did not check the activity actually resolved, so a binding left
pointing at an uninstalled type passed the gate and failed at run time
instead. Report that case distinctly from "not bound at all", name the
containing BpmnProcess node (not the BPMN element id) as the error's
ActivityId to match how the rest of the codebase reports it, and prove
the BpmnProcess-inside-Flowchart graph-walk with a dedicated test. Also
de-duplicate the publish-gate test fixture's binding helpers by deriving
from BpmnBindingTestBase instead of re-declaring them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bpmn): filter the publish gate's loops explicitly

Replace the implicit-filter foreach/continue pattern in
ValidateBpmnProcessBindings with .OfType/.Where so each loop only
iterates the elements it acts on, without changing behaviour, error
messages, or error ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:25:31 +02:00
Sipke Schoorstra 8955f9ad34
feat(bpmn): interchange endpoints (analyze, import, export) (#7954)
* feat(bpmn): add Analyze/Import/Export endpoints to Elsa.Bpmn.Interchange

Thin FastEndpoints wrappers over Bpmn.Interchange, sharing one
BpmnInterchangeDocumentService so Analyze and Import can never disagree
about what a document costs. Import surfaces capability refusal
(BpmnCapabilityRequirements.Analyze, walked into nested processes) with
the missing capability and offending element ids, and reuses
BpmnWorkBinder to bind the root BpmnProcess scope. Export re-reads the
original XML persisted alongside the workflow definition and re-runs it
through BpmnXmlWriter, so retained extension elements, foreign
attributes and BPMN DI layout survive the round trip without being
reconstructed from the reduced Elsa activity graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): make a stale BPMN export refuse instead of mislead

Export now refuses with 422 (naming the reason) when a workflow definition's
BPMN source is missing or no longer matches the definition's version, rather
than exporting stale or absent content while reporting success. Import
records the definition's version alongside the source XML so Export can
detect drift caused by a later save replacing custom properties wholesale.

Also: the interchange package now consumes the runtime host's declared
capability set from a new public Elsa.Bpmn.Hosting.BpmnRuntimeCapabilities
instead of restating it (one value, one home); the Import endpoint's
capability-refusal message no longer misattributes driving elements across
capabilities; the three BPMN REST endpoints get HTTP-level test coverage
(multipart validation, exception-to-status-code mapping, permission gating);
and the wiki documents the endpoints and Export's known limitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): alert when the library defines a capability Elsa does not declare

Restores the deleted comparison between BpmnRuntimeCapabilities.Declared (ours)
and BpmnHostCapabilities.Full (the library's) — these are two different
constants, not the tautology the earlier deletion assumed. The pinned
Bpmn.Semantics 0.1.1-preview.19 currently defines exactly the four flags Elsa
declares, so capability refusal at import/build is wired but unreachable; this
test is what will say the moment a library bump changes that, and its failure
message names the decision (implement and declare, or leave undeclared on
purpose) rather than just failing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): return 400 for a malformed export version and clarify a partial-import refusal

- Export/Endpoint.cs: a non-numeric or out-of-range VersionOptions query value now returns a
  400 naming the offending value instead of throwing through FromString and bubbling into a 500.
- BpmnInterchangeDocumentService: the message shown when a definition carries BPMN source but not
  its version marker (a second save that never completed after ImportAsync's first) now says so
  explicitly, distinct from "never imported" and "stale".
- BpmnInterchangeDocumentService: replace the implicit filter in EnsureCapabilitiesSatisfied's
  foreach with an explicit .Where(...), same behaviour.
- Test projects: extract the duplicated ReadAsset/Path.Combine helper in
  BpmnInterchangeTestBase and BpmnInterchangeEndpointTests into a single BpmnAssetReader, guarded
  against a rooted or nested file name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(bpmn): write both BPMN import markers in one save

Move the BPMN source XML off the pre-import model and onto the same
explicit save that already records the definition's version, so a
failed or cancelled post-import save leaves neither custom property
behind instead of a partial, undiagnosable state. Update
BpmnAssetReader to use Path.Join instead of Path.Combine so its
rooted/nested-name guard is defence-in-depth rather than the only
thing standing between the code and a wrong path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 05:20:42 +02:00