* fix(bpmn): keep a top-level call activity's fire-and-forget flag through the document PUT
A call activity's call options (vw:waitForCompletion) live only on its CallProcess
work binding, which the bpmnDefinitions document cannot carry, so the document PUT
rebuilt every call activity fresh and silently turned a fire-and-forget call into a
waiting one. Reuse the stored options for a top-level call activity whose element id
and calledElement are unchanged, mirroring how a kept subprocess already carries its
own bindings across; a changed calledElement still binds fresh rather than inheriting
options authored for a different process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): read the stored BPMN source once per document PUT
ImportDocumentAsync's two carry-across helpers each independently
looked up SourceXmlCustomPropertyKey and parsed it, so every document
PUT read and parsed the stored BPMN source twice. Read and parse it
once in ImportDocumentAsync and pass the result to both helpers.
Also extends the no-op PUT ETag-stability theory to the
top-level-call-activity.bpmn fixture, which was not previously covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): filter call activities with Where
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): let a process with only a plain start event publish (#8078)
An imported root BpmnProcess is an ITrigger with CanStartWorkflow set
(IsRootScope). For a process whose start events carry no event definition
it rightly returns no payloads, but TriggerIndexer then stored a
null-payload placeholder row and ValidateWorkflowRequestHandler refused
publication with "Trigger should have a payload". That blocked import,
bind, publish and run for most Camunda models.
Adds an additive, opt-in seam: TriggerIndexingContext.RegistersNoTriggers.
A trigger that sets it and returns no payloads gets no row. It has no
effect when the trigger returns payloads, and a trigger that throws still
gets the placeholder, so a failure is never read as a deliberate decline.
Every other ITrigger indexes exactly as before.
BpmnProcess sets it only for a root scope none of whose start events
carries an event definition. A declared start that resolves to nothing,
such as a process whose only start is a refused timer, keeps the
placeholder and still fails publication. Nested scopes are opted out
before that check and are unchanged, and IsRootScope/CanStartWorkflow
semantics are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): publish for real in the stale-after-publish tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): name the publish helper for what it does
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): let the graph hash alone decide BPMN source staleness
A metadata-only save (a rename, a variable edit) bumps a published definition
to a new draft version without touching the graph, but the stale check
compared the version unconditionally, refusing export/document GET even
though the graph the stored source describes had not moved. Once the
graph-hash marker is present it now decides staleness on its own; the version
check remains only as a fallback for definitions imported before that marker
existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(bpmn): share the simulated-publish helper
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bpmn): add document GET/PUT endpoints for BPMN JSON round-tripping (W21)
Studio holds a BPMN process as the library's own JSON payload, not as .bpmn
XML, so binding a task (W11) or moving a shape (W14) had no write path back
to the server: bpmn/import only takes XML, and saving the activity JSON
through the ordinary definition save leaves Bpmn:SourceXml stale, so export
then refuses with 422.
Adds GET/PUT bpmn/definitions/{definitionId}/document, sharing the same
analyze-then-commit path Import runs (BpmnInterchangeDocumentService.ReadDocument/
ImportDocumentAsync), so a document read by GET and written back unchanged by
PUT can never disagree with what Import or Export would do with the same
bytes. Records which process a document was imported from
(Bpmn:SourceProcessId) so a multi-process document keeps importing the same
process on every edit. Binds and writes the document body with plain
System.Text.Json defaults, not FastEndpoints' configured serializer, since
Bpmn.Model's JSON payload format (integer enums, explicit property names)
disagrees with Elsa's own API-wide JSON conventions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(bpmn): deduplicate document endpoint response and exception handling
Import and the document Put endpoint shared a byte-identical Response type and
an identical exception-to-status-code cascade; Export and the document Get
endpoint shared an identical cascade too. Extract both into a shared
BpmnImportResponse and two small cascade helpers under Endpoints/Bpmn, used by
all four endpoints with unchanged status codes and error messages. Also add
endpoint coverage for PUT against a definition imported before
Bpmn:SourceProcessId existed, for both the single- and two-process cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): add optimistic concurrency to the document endpoints and restore Import.Response
GET bpmn/definitions/{id}/document now returns a strong ETag derived from the
definition's Version, its Bpmn:SourceVersion custom property, and a new
Bpmn:DocumentRevision counter (needed because an unpublished draft is edited
in place, so Version/SourceVersion alone do not always change on save). PUT
requires a matching If-Match: missing returns 428, stale returns 412 (checked
before any import work), and a successful PUT returns a new, different ETag.
Also restores the public Elsa.Bpmn.Interchange.Endpoints.Bpmn.Import.Response
type the prior dedupe commit renamed to BpmnImportResponse without cause,
which would have broken source and binary consumers of the preview package;
Import and the document Put endpoint now both use Import.Response again.
Also replaces a foreach-with-continue over a JSON array with .Where(...) in
the document Put test helper, per CodeQL, with no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(bpmn): derive the document ETag from the stored document and graph
The document ETag combined the definition's Version, Bpmn:SourceVersion and a
Bpmn:DocumentRevision counter only the document PUT incremented. An unpublished
draft is edited in place under the same version, and both the workflow
definition importer and the designer's save replace CustomProperties
wholesale, so a POST bpmn/import into the same definition or a designer save
of the draft left all three unchanged. A client holding the pre-write ETag
could then PUT and silently overwrite that write.
The ETag is now a SHA-256 hash over the stored definition's id, version, BPMN
source (Bpmn:SourceXml) and activity graph (StringData), computed in one place
for GET and for PUT's precondition check. Every writer changes at least one
input: a document PUT or an import rewrites the source, a designer save
rewrites the graph, a new draft version changes the id and version. Identical
stored content now yields an identical ETag, so a PUT of unchanged content
returns the ETag GET did.
Bpmn:DocumentRevision and the PUT's pre-import read of it are gone. The ETag a
PUT returns is computed from the definition its import persisted. If-Match
must equal the current strong ETag exactly (weak tags and lists never match,
412), and the wildcard "*" is refused along with a missing header (428),
since it matches whatever is stored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* 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>