Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
29a5fa6
skip discard flows if not required by process
paed01 Aug 28, 2025
bd9160e
mv feature test
paed01 Sep 9, 2025
3c95907
closing in fixing shake functionality
paed01 Sep 10, 2025
7ea891d
shake link event definition
paed01 Sep 14, 2025
9ee69e0
refactor converging parallel gateway
paed01 Nov 14, 2025
c626bcf
wip
paed01 May 9, 2026
a7249cd
fix linked event definition
paed01 May 9, 2026
b76e2cc
parallel gateway listens for peer enter
paed01 May 9, 2026
01a81a3
revisit link event definition
paed01 May 10, 2026
1082805
prepare for dts-buddy bundling of types
paed01 May 10, 2026
bb76a3f
gather constants
paed01 May 10, 2026
6469ec0
no more default exports
paed01 May 10, 2026
a59f354
build types from implementation
paed01 May 11, 2026
eec7eb9
annotate with types
paed01 May 28, 2026
270fc9c
type annotate activities
paed01 May 30, 2026
87b3032
prototype Message, Escalation and Signal behvaiour
paed01 May 30, 2026
1b5dc81
import from bpmn-elements in tests
paed01 May 31, 2026
e021756
drop sub module bundling files
paed01 May 31, 2026
cb08d5b
definition must be instantiated with new
paed01 May 31, 2026
79f6422
update docs
paed01 May 31, 2026
255b477
handle link events as any inbound triggers
paed01 Jun 3, 2026
5df370f
allow activities to discard all outbound flows
paed01 Jun 6, 2026
cb26c42
cache parallel gateway peers
paed01 Jun 6, 2026
b86fc21
stop discarding sequence flows all together
paed01 Jun 6, 2026
9be635a
multiple start events are mutually exclusive entry points
paed01 Jun 9, 2026
0ab64cb
remove intermediate skipDiscard flag
paed01 Jun 11, 2026
80ec068
remove remnants of discarded flows
paed01 Jun 12, 2026
f0f88bb
enforce mutually exclusive start events on recover
paed01 Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22, 24, latest]
node-version: [20, 22, 24, latest]
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ coverage*
*.iml
.vscode

# Agent files (AGENTS.md is the canonical, agent-agnostic doc)
.claude
CLAUDE.md

# Compiled client resources
/public/

Expand All @@ -27,6 +31,9 @@ coverage*
*.swp
*.swo

# TS
*.d.ts.map

# eslint
.eslintcache

Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
18
20
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ coverage/
tmp/
test/resources/*.bpmn
*.bpmn
types/index.d.ts
types/index.d.ts.map
81 changes: 81 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# AGENTS.md

This file provides guidance to coding agents (Claude Code, and any tool that reads `AGENTS.md`) when working with code in this repository.

## Workflow

- **TDD is the default.** Red → green → refactor: write or adjust a failing test before changing implementation. Don't delete or weaken existing assertions to land a change — extend them.
- **Performance and coverage are the project's USP.** Avoid regressions in either. On hot paths (broker dispatch, flow traversal, activity activation, joins, multi-instance loops), prefer existing `Context` Maps/refs over rebuilt scans, and avoid per-message allocations/closures where they can be hoisted.
- **JSDoc is concise.** Short intent descriptions are fine; never describe internal implementation.
- Before declaring done: `npm test` (full suite + lint + `dist` rebuild). For coverage-sensitive work, also `npm run cov:html`.

## Commands

- `npm test` — run the full suite in parallel (mocha, `mocha-cakes-2` UI, hot-bev reporter, 3000ms timeout). `posttest` then runs lint and rebuilds `dist/`.
- `npm run lint` — `eslint . --cache && prettier . --check --cache`.
- `npm run dist` — Babel transpile `src/` → `dist/` (also runs on `prepack`).
- `npm run cov:html` — c8 HTML coverage report.
- `npm run test:md` — run texample against code blocks in the documentation markdown files.
- Single test file: `npx mocha test/feature/activity-feature.js`. `.mocharc.json` auto-loads `mocha-cakes-2` and `test/helpers/setup.js` (which registers chai `expect` globally and sets `NODE_ENV=test`).
- Single scenario: add `-g "scenario name"` to the mocha invocation above.
- Note: default mocharc timeout is 1000ms; the `npm test` script bumps it to 3000ms. Long-running scenarios may need `-t 3000` when run standalone.

## Architecture

The library executes BPMN 2.0 workflows. The execution model is message-driven — almost nothing happens by direct method call — so this section focuses on what you cannot learn from any single file.

### Execution hierarchy: Definition → Process → Activity

Each layer pairs a structural wrapper with a dedicated execution orchestrator:

- `src/definition/Definition.js` + `src/definition/DefinitionExecution.js` — top-level, manages executable processes and inter-process messaging.
- `src/process/Process.js` + `src/process/ProcessExecution.js` — owns one `<bpmn:process>`, handles flow traversal, joins, and parallel activation.
- `src/activity/Activity.js` + `src/activity/ActivityExecution.js` — wraps any element (task, event, gateway), tracks postponed/waiting state, drives a per-run behavior instance.

### Message-driven core via `smqp`

All coordination is async message passing on an in-memory AMQP-like broker (`smqp`, a runtime dependency). Each element owns its own `EventBroker` (`src/EventBroker.js`) with exchanges named `event`, `run`, `format`, `execution`, and `api`. Per-element factories wire these up: `DefinitionBroker`, `ProcessBroker`, `ActivityBroker`, `MessageFlowBroker`.

Execution is driven by publishing routing keys like `execute.start`, `execute.completed`, `execute.error`, `run.enter`, `run.end`, `run.discard`, and subscribing via `broker.subscribeTmp()` / `subscribeOnce()`. Messages with `mandatory: true` surface errors if undelivered. The `EventBroker` exposes convenience methods: `on`, `once`, `waitFor`, `emit`, `emitFatal`. If you try to read `ActivityExecution` or `ProcessExecution` as imperative code you will get lost — keep the publish/subscribe model in mind.

### Activity vs Behaviour

An element type like `ServiceTask` is not a class. It is a factory function that returns an `Activity` constructed with a `Behaviour` class:

- `Activity` holds structural info: id, type, inbound/outbound flows, broker, lifecycle state.
- `Behaviour` implements the element-specific `execute(executeMessage)` logic, publishing results back through the broker.

When an activity is activated, `ActivityExecution` instantiates the Behaviour and calls its `execute`. To replace an element type entirely, supply a new Behaviour — see `docs/Extend.md`.

To identify an element's kind at runtime, compare its `Behaviour` (`entity.Behaviour === StartEvent`) rather than the `type` string — type strings can be customized via the `types` extension.

### `Context` and `Environment`

- `src/Context.js` is a per-execution **registry and lazy factory**. It stores activities, flows, and processes in `refs` Maps and instantiates them on first access via `upsertActivity` / `upsertSequenceFlow` / `upsertProcess`. It bridges the parsed moddle context (from `bpmn-moddle` via `moddle-context-serializer`) to runtime instances and wires extensions through `ExtensionsMapper`. Contexts are cheap to clone and are isolated per execution scope.
- `src/Environment.js` holds global execution config: `variables`, injected `services`, `timers`, `Scripts` engine, `expressions`, `Logger` factory, and settings such as `batchSize`. Cloned and merged per Definition.

### Api objects

`src/Api.js` produces `ActivityApi` / `ProcessApi` / `DefinitionApi` / `FlowApi`. These are lightweight wrappers over broker messages that event listeners receive (e.g. `definition.on('end', api => …)`). They expose `.signal()`, `.cancel()`, `.fail()`, `.stop()`, `.discard()`, `.resolveExpression()` and serialize running state via `content` and `messageProperties`.

### Extension models

Documented in `docs/Extend.md` and `docs/Extension.md`:

1. **Replace a Behaviour** by passing `{ types: { 'bpmn:StartEvent': MyStartEvent } }` to `Definition`. Use when you need full control over an element's execution.
2. **Non-invasive extension hooks** via `{ extensions: { myExt(activity, context) { … } } }`. Each extension runs once per activity after instantiation and typically attaches listeners or publishes format messages — used for cross-cutting concerns (forms, logging, output capture).

### State & behavioral invariants

- **No flow discards.** Outbound sequence flows are never discarded; flow and activity `discarded` counters stay `0`. There is no `skipDiscard` setting. Parallel joins rely on cached gateway peers, not on discarded flows.
- **Multiple start events are mutually exclusive entry points.** The first start event to fire discards the others still armed, so two start branches can never both run.
- **`stateVersion`.** `Definition.getState()` stamps `stateVersion` (the package major, hardcoded in `src/constants.js`); recovering an older major triggers migrations (e.g. start event reconciliation on resume). Unstamped legacy states are treated as version `0`. Bump the constant on each major release.

## Testing patterns

- Framework: mocha + `mocha-cakes-2` BDD UI. `Feature` / `Scenario` / `Given` / `When` / `Then` / `And` / `But` are globals in test files (declared in `eslint.config.js`). Chai `expect` is registered globally via `test/helpers/setup.js`.
- Layout: scenario-style coverage in `test/feature/*.js`; unit tests mirror the `src/` directory tree (`test/activity`, `test/process`, `test/gateways`, `test/tasks`, `test/eventDefinitions`, `test/flows`, …).
- BPMN sources: raw XML templates in `test/helpers/factory.js` (helpers like `factory.valid()`, `factory.userTask()`, `factory.resource('name')`) plus `.bpmn` files under `test/resources/`.
- Primary helper: `test/helpers/testHelpers.js` — `context(source, options)` parses BPMN via `bpmn-moddle`, serializes via `moddle-context-serializer`, and returns a runtime `Context`. Also exposes `Logger`, `emptyContext`, and `AssertMessage` for asserting broker message sequences.
- `test/helpers/JavaScripts.js` is a mock Scripts engine for isolating ScriptTask tests.
- Don't assert on logging — captured `logger.warn`/`debug` output is not part of the tested contract.
41 changes: 40 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
# Changelog

## Unreleased
## v18.0.1 - 2026-06-13

### Fixes

- enforce mutually exclusive start events on recover: a recovered state where one entry point already won, or a legacy state serialized before the `isStartEvent` flag existed, now correctly discards the start events still left armed instead of resuming them as live entry points

### Additions

- serialized definition state is stamped with a `stateVersion` tracking the package major; recovering an older major (legacy unstamped states are treated as version `0`) triggers migrations such as the start event reconciliation above

## v18.0.0 - 2026-06-11

Refactor parallel converging and forking gateways, and treat multiple start events as mutually exclusive entry points. As a result of the parallel gateway keeping track of peers there is no need for discarding a sequence flows.

### Breaking

- `Definition` must be called with `new`
- parallel gateways now enter execution as soon as the first inbound sequence flow is touched
- removed discarding of outbound sequence flows altogether — activities no longer publish flow discards, so sequence flow and downstream activity `discarded` counters stay at `0`
- IntermediateCatchEvent cannot be used as a starting element, or it can but will not be started by default
- non-gateway activities end the branch when all conditional outbound flows are falsy instead of throwing; only exclusive and inclusive gateways still require a taken or default flow
- multiple start events are mutually exclusive entry points — the first start event to fire discards the others still waiting to be triggered, so two start events can no longer both run (e.g. into a parallel join, or a joining task taken twice)
- start activities that are not start events (e.g. a starting receive task, or an activity without an inbound flow) are no longer auto-discarded; they are genuine tokens that must be signalled or completed
- shake sequence has changed

### Additions

- expose throwable error classes via new `bpmn-elements/errors` subpath: `import { ActivityError, BpmnError, RunError } from 'bpmn-elements/errors'`
- activity readonly property `isParallelJoin` indicating a parallel converging gateway
- activity readonly property `isStartEvent` indicating a start event
- new activity event `activity.converge` published when parallel gateway is executed
- fix link event definition shaking
- fix `Activity.recover()` to return the activity when called without state
- a condition expression resolving to a service function is now invoked with the flow execution scope, supporting sync (return) and async (callback) results
- converging parallel gateways cache their discovered peers per runtime instance, skipping the start-up shake on repeated runs (loops, stop/resume); the cache is rebuilt on recover

### Types

- runtime types are now generated from JSDoc and bundled with [dts-buddy](https://github.com/Rich-Harris/dts-buddy)
- expose `isStartEvent` and `isParallelGateway` on the `Activity` interface

## v17.3.0 - 2025-12-03

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The following elements are tested and supported.
- ErrorEventDefinition
- throw
- catch
- [Escalation](/docs/MessageElements.md)
- EscalationEventDefinition
- throw
- catch
Expand All @@ -42,6 +43,7 @@ The following elements are tested and supported.
- LinkEventDefinition
- throw
- catch
- [Message](/docs/MessageElements.md)
- MessageEventDefinition
- throw
- catch
Expand All @@ -58,7 +60,7 @@ The following elements are tested and supported.
- [ServiceTask](/docs/ServiceTask.md)
- BusinessRuleTask: Same behaviour as ServiceTask
- SendTask: Same behaviour as ServiceTask
- Signal
- [Signal](/docs/MessageElements.md)
- SignalEventDefinition
- throw
- catch
Expand Down
83 changes: 83 additions & 0 deletions dist/Api.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,54 @@ exports.FlowApi = FlowApi;
exports.ProcessApi = ProcessApi;
var _messageHelper = require("./messageHelper.js");
var _shared = require("./shared.js");
/**
* Build an activity-scoped Api wrapper. Routing keys are published under `activity.*`.
* @param {any} broker
* @param {import('#types').ElementBrokerMessage} apiMessage
* @param {import('#types').Environment} [environment]
*/
function ActivityApi(broker, apiMessage, environment) {
return new Api('activity', broker, apiMessage, environment);
}

/**
* Build a definition-scoped Api wrapper. Routing keys are published under `definition.*`.
* @param {any} broker
* @param {import('#types').ElementBrokerMessage} apiMessage
* @param {import('#types').Environment} [environment]
*/
function DefinitionApi(broker, apiMessage, environment) {
return new Api('definition', broker, apiMessage, environment);
}

/**
* Build a process-scoped Api wrapper. Routing keys are published under `process.*`.
* @param {any} broker
* @param {import('#types').ElementBrokerMessage} apiMessage
* @param {import('#types').Environment} [environment]
*/
function ProcessApi(broker, apiMessage, environment) {
return new Api('process', broker, apiMessage, environment);
}

/**
* Build a flow-scoped Api wrapper. Routing keys are published under `flow.*`.
* @param {any} broker
* @param {import('#types').ElementBrokerMessage} apiMessage
* @param {import('#types').Environment} [environment]
*/
function FlowApi(broker, apiMessage, environment) {
return new Api('flow', broker, apiMessage, environment);
}

/**
* Lightweight wrapper over the broker that exposes signal/cancel/fail/stop and other api actions.
* @param {string} pfx Message prefix, e.g. `activity`, `process`, `definition`, `flow`
* @param {any} broker
* @param {import('#types').ElementBrokerMessage} sourceMessage Cloned to back the api
* @param {import('#types').Environment} [environment] Defaults to `broker.owner.environment`
* @throws {Error} when sourceMessage is missing
*/
function Api(pfx, broker, sourceMessage, environment) {
if (!sourceMessage) throw new Error('Api requires message');
const apiMessage = (0, _messageHelper.cloneMessage)(sourceMessage);
Expand All @@ -43,34 +79,71 @@ function Api(pfx, broker, sourceMessage, environment) {
this.owner = broker.owner;
this.messagePrefix = pfx;
}

/**
* Send a cancel api message.
* @param {import('#types').signalMessage} [message]
* @param {any} [options]
*/
Api.prototype.cancel = function cancel(message, options) {
this.sendApiMessage('cancel', {
message
}, options);
};

/**
* Send a discard api message.
*/
Api.prototype.discard = function discard() {
this.sendApiMessage('discard');
};

/**
* Send an error api message that fails the activity.
* @param {Error} error
*/
Api.prototype.fail = function fail(error) {
this.sendApiMessage('error', {
error
});
};

/**
* Send a signal api message.
* @param {import('#types').signalMessage} [message]
* @param {any} [options]
*/
Api.prototype.signal = function signal(message, options) {
this.sendApiMessage('signal', {
message
}, options);
};

/**
* Send a stop api message.
*/
Api.prototype.stop = function stop() {
this.sendApiMessage('stop');
};

/**
* Resolve an expression with the api message as scope and the broker owner as context.
* @param {string} expression
*/
Api.prototype.resolveExpression = function resolveExpression(expression) {
return this.environment.resolveExpression(expression, {
fields: this.fields,
content: this.content,
properties: this.messageProperties
}, this.owner);
};

/**
* Publish a custom api message to the broker.
* @param {string} action Routing key suffix, e.g. `signal`, `cancel`
* @param {import('#types').signalMessage} [content] Merged into the message content
* @param {any} [options]
*/
Api.prototype.sendApiMessage = function sendApiMessage(action, content, options) {
const correlationId = options?.correlationId || (0, _shared.getUniqueId)(`${this.id || this.messagePrefix}_signal`);
let key = `${this.messagePrefix}.${action}`;
Expand All @@ -81,11 +154,21 @@ Api.prototype.sendApiMessage = function sendApiMessage(action, content, options)
type: action
});
};

/**
* List currently postponed activities, falling back to a sub-process execution when applicable.
* @param {import('#types').filterPostponed} [filterFn]
*/
Api.prototype.getPostponed = function getPostponed(...args) {
if (this.owner.getPostponed) return this.owner.getPostponed(...args);
if (this.owner.isSubProcess && this.owner.execution) return this.owner.execution.getPostponed(...args);
return [];
};

/**
* Build a message body by merging the given content onto the source content.
* @param {Record<string, any>} [content]
*/
Api.prototype.createMessage = function createMessage(content) {
return {
...this.content,
Expand Down
Loading