@nanobpm/workflow 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,9 +44,10 @@ import type {
44
44
  StepContract,
45
45
  StepHandler,
46
46
  SwitchCase,
47
+ TimerStart,
47
48
  } from "./types.js";
48
49
  import type { Envelope, EnvelopeField } from "./envelope.js";
49
- import { assertIdent, assertJobType, escapeXml, jobType, messageName } from "./xml.js";
50
+ import { assertIdent, assertJobType, assertTimerCycle, assertTimerDate, assertTimerDuration, escapeXml, jobType, messageName } from "./xml.js";
50
51
 
51
52
  // --- Authoring surface -------------------------------------------------------
52
53
 
@@ -100,6 +101,26 @@ export interface FlowBuilder<C extends FlowContracts = Record<string, never>> {
100
101
  * payload envelope, if any, comes from the contract's `in`.
101
102
  */
102
103
  signal<K extends string>(name: K, opts: { correlationKey: string }): FlowBuilder<C>;
104
+ /**
105
+ * A durable wait for a point in time (a BPMN timer intermediate catch event).
106
+ * Pass exactly one of `{ after }` — an ISO-8601 delay (`PT1M30S`, `P1DT6H`) or
107
+ * FEEL `=`-expression measured from when the token arrives — or `{ at }` — an
108
+ * absolute ISO-8601 instant (or FEEL expression). The engine holds the token
109
+ * durably until the timer fires, then continues. Use it for in-flow delays and
110
+ * scheduled continuations (e.g. "wait 24h, then re-poll").
111
+ */
112
+ timer<K extends string>(name: K, opts: { after: string } | { at: string }): FlowBuilder<C>;
113
+ /**
114
+ * Make this flow's start event a durable TIMER start rather than an explicit
115
+ * `client.start(...)`. Must be the first statement, at the top level. Pass
116
+ * exactly one of `{ cycle }` — a recurring ISO-8601 interval (`R/PT1H`,
117
+ * `R5/PT30M`) the engine re-fires each period — `{ after }` — a one-shot delay
118
+ * from deployment — or `{ at }` — a one-shot absolute instant. A `cycle` start
119
+ * is the model-native, durable, single-fire-per-cluster replacement for an
120
+ * app-side cron: the schedule lives in the deployable model, and the engine
121
+ * (not each app replica) owns firing it exactly once.
122
+ */
123
+ startOn(spec: { cycle: string } | { after: string } | { at: string }): FlowBuilder<C>;
103
124
  /**
104
125
  * A multi-way exclusive choice (a BPMN exclusive gateway). `subject` is a FEEL
105
126
  * expression (usually a variable name); each case routes when `subject` equals
@@ -132,6 +153,8 @@ interface BuilderCtx {
132
153
  handlers: Record<string, StepHandler>;
133
154
  seen: Set<string>;
134
155
  loopDepth: number;
156
+ /** Set by `startOn()` (root builder only); lifted onto the flow. */
157
+ startTimer?: TimerStart;
135
158
  }
136
159
 
137
160
  /** Ids the emitter generates for structural nodes / flows / messages. A step
@@ -163,7 +186,12 @@ function contractEnvelopes(ctx: BuilderCtx, name: string): { in?: Envelope; out?
163
186
  /** Build a FlowBuilder that appends its nodes to `out`, sharing the flow-wide
164
187
  * `ctx` (contracts, handler registry, name set, loop nesting). Structural
165
188
  * combinators recurse with a fresh `out` array for each nested body. */
166
- function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx: BuilderCtx): FlowBuilder<C> {
189
+ function makeBuilder<C extends FlowContracts>(
190
+ id: string,
191
+ out: FlowNode[],
192
+ ctx: BuilderCtx,
193
+ isRoot = false,
194
+ ): FlowBuilder<C> {
167
195
  const child = (fn: Block<C>, inLoop: boolean): FlowNode[] => {
168
196
  const body: FlowNode[] = [];
169
197
  const depth = inLoop ? ctx.loopDepth + 1 : ctx.loopDepth;
@@ -192,6 +220,49 @@ function makeBuilder<C extends FlowContracts>(id: string, out: FlowNode[], ctx:
192
220
  out.push({ kind: "signal", name, correlationKey: opts.correlationKey, payload: ctx.contracts[name]?.in });
193
221
  return b as unknown as FlowBuilder<C>;
194
222
  },
223
+ timer(name: string, opts: { after?: string; at?: string }): FlowBuilder<C> {
224
+ claimName(ctx, id, name);
225
+ const hasAfter = typeof opts?.after === "string";
226
+ const hasAt = typeof opts?.at === "string";
227
+ if (hasAfter === hasAt) {
228
+ throw new Error(`timer("${name}") needs exactly one of { after } (a delay) or { at } (an instant)`);
229
+ }
230
+ if (hasAfter) {
231
+ const after = (opts.after as string).trim();
232
+ assertTimerDuration(`timer("${name}") after`, after);
233
+ out.push({ kind: "timer", name, after });
234
+ } else {
235
+ const at = (opts.at as string).trim();
236
+ assertTimerDate(`timer("${name}") at`, at);
237
+ out.push({ kind: "timer", name, at });
238
+ }
239
+ return b as unknown as FlowBuilder<C>;
240
+ },
241
+ startOn(spec: { cycle?: string; after?: string; at?: string }): FlowBuilder<C> {
242
+ if (!isRoot) {
243
+ throw new Error(`startOn() is only valid at the top level of flow "${id}" (not inside switch/branch/loop)`);
244
+ }
245
+ if (out.length !== 0) throw new Error(`startOn() must be the first statement in flow "${id}"`);
246
+ if (ctx.startTimer) throw new Error(`startOn() may be called only once in flow "${id}"`);
247
+ const set = (["cycle", "after", "at"] as const).filter((k) => typeof spec?.[k] === "string");
248
+ if (set.length !== 1) {
249
+ throw new Error(`startOn() needs exactly one of { cycle }, { after }, or { at } in flow "${id}"`);
250
+ }
251
+ if (spec.cycle !== undefined) {
252
+ const cycle = spec.cycle.trim();
253
+ assertTimerCycle(`startOn cycle`, cycle);
254
+ ctx.startTimer = { cycle };
255
+ } else if (spec.after !== undefined) {
256
+ const after = spec.after.trim();
257
+ assertTimerDuration(`startOn after`, after);
258
+ ctx.startTimer = { after };
259
+ } else {
260
+ const at = (spec.at as string).trim();
261
+ assertTimerDate(`startOn at`, at);
262
+ ctx.startTimer = { at };
263
+ }
264
+ return b as unknown as FlowBuilder<C>;
265
+ },
195
266
  switch(subject: string, cases: Record<string, Block<C>> & { default?: Block<C> }): FlowBuilder<C> {
196
267
  if (typeof subject !== "string" || subject.trim() === "") {
197
268
  throw new Error(`switch() needs a non-empty subject expression`);
@@ -263,9 +334,11 @@ export function defineFlow(
263
334
  const steps: FlowNode[] = [];
264
335
  const handlers: Record<string, StepHandler> = {};
265
336
  const ctx: BuilderCtx = { contracts, handlers, seen: new Set(), loopDepth: 0 };
266
- build(makeBuilder<FlowContracts>(id, steps, ctx));
337
+ build(makeBuilder<FlowContracts>(id, steps, ctx, true));
267
338
  if (steps.length === 0) throw new Error(`flow "${id}" declared no steps`);
268
- return { kind: "declarative", id, steps, handlers };
339
+ const flow: DeclarativeFlow = { kind: "declarative", id, steps, handlers };
340
+ if (ctx.startTimer) flow.startTimer = ctx.startTimer;
341
+ return flow;
269
342
  }
270
343
 
271
344
  // --- Tree walkers ------------------------------------------------------------
@@ -404,6 +477,24 @@ class Compiler {
404
477
  });
405
478
  }
406
479
 
480
+ private addTimerCatchEvent(node: { name: string; after?: string; at?: string }): void {
481
+ const id = node.name;
482
+ const body =
483
+ node.after !== undefined
484
+ ? ` <bpmn:timeDuration>${escapeXml(node.after)}</bpmn:timeDuration>\n`
485
+ : ` <bpmn:timeDate>${escapeXml(node.at as string)}</bpmn:timeDate>\n`;
486
+ this.nodes.push({
487
+ id,
488
+ render: (inc, outg) =>
489
+ ` <bpmn:intermediateCatchEvent id="${escapeXml(id)}" name="${escapeXml(id)}">\n` +
490
+ incomingOutgoing(inc, outg) +
491
+ ` <bpmn:timerEventDefinition>\n` +
492
+ body +
493
+ ` </bpmn:timerEventDefinition>\n` +
494
+ ` </bpmn:intermediateCatchEvent>`,
495
+ });
496
+ }
497
+
407
498
  private addGateway(id: string, name?: string): RenderNode {
408
499
  const gwNode: RenderNode = {
409
500
  id,
@@ -441,6 +532,11 @@ class Compiler {
441
532
  this.recordEnvelope(node.payload);
442
533
  return [this.newEdge(node.name)];
443
534
  }
535
+ case "timer": {
536
+ this.addTimerCatchEvent(node);
537
+ this.connect(incoming, node.name);
538
+ return [this.newEdge(node.name)];
539
+ }
444
540
  case "switch": {
445
541
  const id = `Gw_${this.gw++}`;
446
542
  const gw = this.addGateway(id, node.subject);
@@ -494,11 +590,32 @@ class Compiler {
494
590
  }
495
591
  }
496
592
 
593
+ /** Render the Start event — a plain none start, or a timer start when the
594
+ * flow declared `startOn(...)`. */
595
+ private renderStart(outg: string[]): string {
596
+ const t = this.flow.startTimer;
597
+ if (!t) return ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`;
598
+ const body =
599
+ t.cycle !== undefined
600
+ ? `<bpmn:timeCycle>${escapeXml(t.cycle)}</bpmn:timeCycle>`
601
+ : t.after !== undefined
602
+ ? `<bpmn:timeDuration>${escapeXml(t.after)}</bpmn:timeDuration>`
603
+ : `<bpmn:timeDate>${escapeXml(t.at as string)}</bpmn:timeDate>`;
604
+ return (
605
+ ` <bpmn:startEvent id="Start">\n` +
606
+ ` ${outgoingOnly(outg)}\n` +
607
+ ` <bpmn:timerEventDefinition>\n` +
608
+ ` ${body}\n` +
609
+ ` </bpmn:timerEventDefinition>\n` +
610
+ ` </bpmn:startEvent>`
611
+ );
612
+ }
613
+
497
614
  compile(): string {
498
615
  // Start → top-level sequence → End.
499
616
  this.nodes.push({
500
617
  id: "Start",
501
- render: (_inc, outg) => ` <bpmn:startEvent id="Start">${outgoingOnly(outg)}</bpmn:startEvent>`,
618
+ render: (_inc, outg) => this.renderStart(outg),
502
619
  });
503
620
  const s0 = this.newEdge("Start");
504
621
  const finalDanglers = this.emitList(this.flow.steps, [s0], null);
package/src/index.ts CHANGED
@@ -55,7 +55,13 @@ export type {
55
55
  FieldTs,
56
56
  } from "./envelope.js";
57
57
  export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
58
- export type { WorkflowClientOptions, ActivateOptions } from "./client.js";
58
+ export type {
59
+ WorkflowClientOptions,
60
+ NanoSdkClient,
61
+ NanoJobWorker,
62
+ JobWorkerConfig,
63
+ ActivatedJob,
64
+ } from "./client.js";
59
65
  export { Worker } from "./worker.js";
60
66
  export type { WorkerOptions, ActivityEvent } from "./worker.js";
61
67
  export type {
@@ -67,6 +73,7 @@ export type {
67
73
  DeclarativeStep,
68
74
  FlowNode,
69
75
  SwitchCase,
76
+ TimerStart,
70
77
  NodeEnvelopes,
71
78
  StepContract,
72
79
  FlowContracts,
package/src/types.ts CHANGED
@@ -53,12 +53,34 @@ export type FlowNode =
53
53
  | { kind: "run"; name: string; envelopes?: NodeEnvelopes }
54
54
  | { kind: "task"; name: string; envelopes?: NodeEnvelopes; jobType?: string }
55
55
  | { kind: "signal"; name: string; correlationKey: string; payload?: Envelope }
56
+ | ({ kind: "timer"; name: string } & TimerAt)
56
57
  | { kind: "switch"; subject: string; cases: SwitchCase[]; default?: FlowNode[] }
57
58
  | { kind: "branch"; condition: string; then: FlowNode[]; else?: FlowNode[] }
58
59
  | { kind: "loop"; body: FlowNode[] }
59
60
  | { kind: "break" }
60
61
  | { kind: "continue" };
61
62
 
63
+ /** A timer intermediate-catch definition: exactly one of `after` (an ISO-8601
64
+ * delay or FEEL expression) or `at` (an absolute instant or FEEL expression).
65
+ * Encoded as an XOR so downstream consumers can rely on exactly-one. */
66
+ export type TimerAt =
67
+ | { after: string; at?: never }
68
+ | { at: string; after?: never };
69
+
70
+ /** A flow's start-timer: the plain none start event becomes a durable timer
71
+ * start the engine fires on schedule. Exactly one of:
72
+ * - `cycle` — a recurring ISO-8601 interval (`R/PT1H`, `R5/PT30M`) or bare
73
+ * duration; the engine re-fires on each period. This is the model-native,
74
+ * durable, single-fire-per-cluster replacement for an app-side cron.
75
+ * - `after` — a one-shot ISO-8601 delay (`PT10S`) measured from deployment.
76
+ * - `at` — a one-shot absolute instant (an ISO-8601 date-time, or a FEEL
77
+ * `=` expression).
78
+ * Encoded as an XOR so the exactly-one invariant holds at the type level. */
79
+ export type TimerStart =
80
+ | { cycle: string; after?: never; at?: never }
81
+ | { after: string; cycle?: never; at?: never }
82
+ | { at: string; cycle?: never; after?: never };
83
+
62
84
  /** One case of a `switch`: routed when `subject = value` (FEEL equality). */
63
85
  export interface SwitchCase {
64
86
  value: string;
@@ -75,6 +97,9 @@ export interface DeclarativeFlow {
75
97
  /** The flow's node tree (top-level sequence). */
76
98
  steps: FlowNode[];
77
99
  handlers: Record<string, StepHandler>;
100
+ /** When set, the flow's start event is a durable timer start (see
101
+ * {@link TimerStart}) rather than a plain none start. */
102
+ startTimer?: TimerStart;
78
103
  }
79
104
 
80
105
  // --- Imperative surface (Strategy B: a replayed orchestration function) -------
package/src/worker.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The Worker runtime — hosts one or more workflows against a gateway and drives
2
- // them: long-polls each derived job type, dispatches jobs to handlers, completes
3
- // them. It generalises the two surfaces:
2
+ // them: it registers one nano-sdk job worker per derived job type, dispatches
3
+ // each activated job to its handler, and completes it. It generalises the two
4
+ // surfaces:
4
5
  //
5
6
  // - a declarative flow contributes one job type per `run` step, dispatched to
6
7
  // the user handler (external `task` steps are intentionally NOT hosted here —
@@ -8,11 +9,13 @@
8
9
  // - an imperative workflow contributes its single orchestrator job type,
9
10
  // dispatched to the replay engine (which advances the journal one step).
10
11
  //
11
- // It is resilient to the gateway disappearing (each poll loop backs off and
12
- // reconnects on its own), so it survives an engine crash/restart — the property
13
- // the ADR 0044 spike proved.
12
+ // Transport (activation, completion, failure, long-poll, backoff/reconnect) is
13
+ // the nano-sdk job worker (ADR 0055), reached through `WorkflowClient.sdk`. The
14
+ // nano-sdk worker reschedules its poll on an activation error, so the runtime
15
+ // survives an engine crash/restart — the property the ADR 0044 spike proved.
14
16
 
15
17
  import { WorkflowClient } from "./client.js";
18
+ import type { ActivatedJob, NanoJobWorker } from "./client.js";
16
19
  import { replayOnce } from "./imperative.js";
17
20
  import { walkNodes } from "./declarative.js";
18
21
  import type { Job, JsonObject, Workflow } from "./types.js";
@@ -28,46 +31,66 @@ export interface ActivityEvent {
28
31
  step?: string;
29
32
  }
30
33
 
31
- export interface WorkerOptions {
32
- /** Provide a baseUrl (a client is created) or an existing client. */
33
- baseUrl?: string;
34
- client?: WorkflowClient;
34
+ /** Options common to both ways of building a `Worker`. */
35
+ interface WorkerCommon {
35
36
  workflows: Workflow[];
36
37
  /** Worker name reported to the gateway. Default "nanobpm-workflow". */
37
38
  name?: string;
39
+ /** Transport for the worker's own client (used only when `client` is not
40
+ * supplied): "auto" | "falcon" | "rest". Defaults to "rest" — job serving is
41
+ * resilience-critical, and REST long-poll reconnects with backoff after an
42
+ * engine restart (the ADR 0044 property), whereas the Falcon push transport
43
+ * does not recover a mid-stream disconnect. Instance creation, by contrast,
44
+ * defaults to Falcon on its own `WorkflowClient` for throughput. */
45
+ transport?: "auto" | "falcon" | "rest";
38
46
  /** Long-poll timeout per activation, ms. Also bounds stop() latency. Default 10000. */
39
47
  pollTimeoutMs?: number;
40
48
  /** Job lock timeout, ms. Default 30000. */
41
49
  jobTimeoutMs?: number;
42
- /** Backoff after a transport error, ms. Default 500. */
43
- backoffMs?: number;
50
+ /** Max jobs handled in parallel per derived job type. Default 1. */
51
+ maxParallelJobs?: number;
44
52
  onActivity?: (e: ActivityEvent) => void | Promise<void>;
45
53
  onError?: (err: Error, context: { type: string }) => void;
46
54
  }
47
55
 
48
- type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
56
+ /** Build a `Worker` from **either** a `baseUrl` (a `WorkflowClient` is created)
57
+ * **or** an existing `client`. The union makes TypeScript enforce that exactly
58
+ * one is supplied, matching the constructor's runtime requirement. */
59
+ export type WorkerOptions =
60
+ | (WorkerCommon & {
61
+ /** Base URL of the nanobpmn gateway; a `WorkflowClient` is created for you. */
62
+ baseUrl: string;
63
+ client?: never;
64
+ })
65
+ | (WorkerCommon & {
66
+ /** An existing `WorkflowClient` to serve jobs through. */
67
+ client: WorkflowClient;
68
+ baseUrl?: never;
69
+ });
49
70
 
50
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
71
+ type Handler = (job: Job) => Promise<{ variables: JsonObject; step?: string }>;
51
72
 
52
73
  export class Worker {
53
74
  private readonly client: WorkflowClient;
54
75
  private readonly name: string;
55
76
  private readonly pollTimeoutMs: number;
56
77
  private readonly jobTimeoutMs: number;
57
- private readonly backoffMs: number;
78
+ private readonly maxParallelJobs: number;
58
79
  private readonly onActivity?: WorkerOptions["onActivity"];
59
80
  private readonly onError?: WorkerOptions["onError"];
60
81
  /** job type → { workflowId, handle } */
61
82
  private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
62
83
  private running = false;
63
- private loops: Promise<void>[] = [];
84
+ private workers: NanoJobWorker[] = [];
64
85
 
65
86
  constructor(opts: WorkerOptions) {
66
- this.client = opts.client ?? new WorkflowClient({ baseUrl: requireBaseUrl(opts) });
87
+ this.client =
88
+ opts.client ??
89
+ new WorkflowClient({ baseUrl: requireBaseUrl(opts), transport: opts.transport ?? "rest" });
67
90
  this.name = opts.name ?? "nanobpm-workflow";
68
91
  this.pollTimeoutMs = opts.pollTimeoutMs ?? 10000;
69
92
  this.jobTimeoutMs = opts.jobTimeoutMs ?? 30000;
70
- this.backoffMs = opts.backoffMs ?? 500;
93
+ this.maxParallelJobs = opts.maxParallelJobs ?? 1;
71
94
  this.onActivity = opts.onActivity;
72
95
  this.onError = opts.onError;
73
96
  for (const wf of opts.workflows) this.register(wf);
@@ -152,61 +175,84 @@ export class Worker {
152
175
  return [...this.routes.keys()];
153
176
  }
154
177
 
155
- /** Begin polling. Resolves once the loops are running (they run until stop()). */
178
+ /** Begin serving. Creates one nano-sdk job worker per derived job type with
179
+ * `autoStart: false`, then starts each explicitly, so the poll lifecycle is
180
+ * deterministic and owned here rather than left to the SDK's auto-start
181
+ * default (matching the `JobWorkerConfig.autoStart` contract). Nothing polls
182
+ * before `start()` is called. */
156
183
  start(): void {
157
184
  if (this.running) return;
158
185
  this.running = true;
159
- this.loops = [...this.routes.entries()].map(([type, route]) => this.pollLoop(type, route));
186
+ this.workers = [...this.routes.entries()].map(([type, route]) => {
187
+ const worker = this.client.sdk.createJobWorker({
188
+ jobType: type,
189
+ workerName: this.name,
190
+ maxParallelJobs: this.maxParallelJobs,
191
+ jobTimeoutMs: this.jobTimeoutMs,
192
+ pollTimeoutMs: this.pollTimeoutMs,
193
+ autoStart: false,
194
+ jobHandler: (job) => this.handleJob(type, route, job),
195
+ });
196
+ worker.start();
197
+ return worker;
198
+ });
160
199
  }
161
200
 
162
- /** Stop polling and wait for in-flight loops to unwind (≤ pollTimeoutMs). */
201
+ /** Stop serving and wait for in-flight jobs to drain. Stopping is best-effort
202
+ * per worker: the Falcon worker's `stop` throws if it never bound a transport
203
+ * (detection still pending), which must not fail the whole shutdown. */
163
204
  async stop(): Promise<void> {
164
205
  this.running = false;
165
- await Promise.all(this.loops);
166
- this.loops = [];
206
+ await Promise.all(
207
+ this.workers.map(async (w) => {
208
+ try {
209
+ await (w.stopGracefully
210
+ ? w.stopGracefully({ waitUpToMs: this.pollTimeoutMs })
211
+ : w.stop());
212
+ } catch {
213
+ /* worker never fully started (transport unbound); nothing to drain */
214
+ }
215
+ }),
216
+ );
217
+ this.workers = [];
167
218
  }
168
219
 
169
- private async pollLoop(type: string, route: { workflowId: string; handle: Handler }): Promise<void> {
170
- while (this.running) {
171
- let jobs: Job[] = [];
220
+ /** Dispatch one activated job to its route handler and acknowledge it. Returns
221
+ * the nano-sdk action receipt (its `complete`/`fail` result). */
222
+ private async handleJob(
223
+ type: string,
224
+ route: { workflowId: string; handle: Handler },
225
+ job: ActivatedJob,
226
+ ): Promise<unknown> {
227
+ try {
228
+ const { variables, step } = await route.handle(job);
229
+ const receipt = await job.complete(variables);
230
+ // Job is now completed; the observer hook is isolated (emitActivity
231
+ // swallows its own errors) so it can never fall into the failure path.
232
+ await this.emitActivity({
233
+ workflowId: route.workflowId,
234
+ type,
235
+ jobKey: job.jobKey,
236
+ elementId: job.elementId,
237
+ step,
238
+ });
239
+ return receipt;
240
+ } catch (e) {
241
+ // A handler or completion failed. Report; the engine will redeliver the
242
+ // job after its lock times out (at-least-once → handlers must be
243
+ // idempotent). Best-effort surface it as an incident-worthy failure.
244
+ // Normalise non-Error throws (strings, objects) so emitError always gets
245
+ // an Error and errorMessage is never undefined in the failure report.
246
+ const err = e instanceof Error ? e : new Error(String(e));
247
+ this.emitError(err, type);
172
248
  try {
173
- jobs = await this.client.activateJobs(type, {
174
- worker: this.name,
175
- maxJobsToActivate: 1,
176
- timeout: this.jobTimeoutMs,
177
- requestTimeout: this.pollTimeoutMs,
178
- });
179
- } catch (e) {
180
- // Transport/gateway error (e.g. engine restarting): back off, reconnect.
181
- this.emitError(e as Error, type);
182
- await sleep(this.backoffMs);
183
- continue;
184
- }
185
- for (const job of jobs) {
186
- if (!this.running) break;
187
- try {
188
- const { variables, step } = await route.handle(job);
189
- await this.client.completeJob(job.jobKey, variables);
190
- // Job is now completed; the observer hook is isolated (emitActivity
191
- // swallows its own errors) so it can never fall into the failure path.
192
- await this.emitActivity({
193
- workflowId: route.workflowId,
194
- type,
195
- jobKey: job.jobKey,
196
- elementId: job.elementId,
197
- step,
198
- });
199
- } catch (e) {
200
- // A handler or completion failed. Report; the engine will redeliver the
201
- // job after its lock times out (at-least-once → handlers must be
202
- // idempotent). Best-effort surface it as an incident-worthy failure.
203
- this.emitError(e as Error, type);
204
- try {
205
- await this.client.failJob(job.jobKey, (e as Error).message, 0);
206
- } catch {
207
- /* engine may be down; the lock will expire and redeliver */
208
- }
209
- }
249
+ return await job.fail({ errorMessage: err.message, retries: 0 });
250
+ } catch {
251
+ // Even reporting the failure failed (e.g. the gateway is unreachable).
252
+ // Awaited + swallowed so a rejected `fail` can't escape as an unhandled
253
+ // rejection out of the SDK worker's handler; the engine redelivers the
254
+ // job once its lock times out regardless.
255
+ return undefined;
210
256
  }
211
257
  }
212
258
  }
package/src/xml.ts CHANGED
@@ -40,6 +40,68 @@ export function assertJobType(kind: string, value: string): void {
40
40
  }
41
41
  }
42
42
 
43
+ // --- Timer definitions (BPMN timerEventDefinition bodies) --------------------
44
+ //
45
+ // The engine (engine-core/src/bpmn.rs) accepts a nested `timeDuration`,
46
+ // `timeCycle`, or `timeDate` as either a static ISO-8601 literal or a FEEL
47
+ // expression (a leading `=`). We validate the literal forms at authoring time so
48
+ // a malformed timer fails fast in the DSL rather than at deploy.
49
+
50
+ /** An ISO-8601 duration with integer week/day/hour/minute/second components
51
+ * (no years/months — ambiguous length — matching the engine's parser). */
52
+ const ISO_DURATION = /^P(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/;
53
+ function isIsoDuration(v: string): boolean {
54
+ if (!ISO_DURATION.test(v) || v === "P" || v === "PT" || v.endsWith("T")) return false;
55
+ return /\d/.test(v);
56
+ }
57
+
58
+ /** A recurring ISO-8601 interval: an optional `R[n]/` repeat prefix + a
59
+ * duration, or a bare duration (the engine repeats it unboundedly). */
60
+ function isIsoCycle(v: string): boolean {
61
+ const slash = v.indexOf("/");
62
+ if (slash < 0) return isIsoDuration(v);
63
+ const repeat = v.slice(0, slash);
64
+ const interval = v.slice(slash + 1);
65
+ return /^R\d*$/.test(repeat) && isIsoDuration(interval);
66
+ }
67
+
68
+ /** An ISO-8601 date-time instant (a `timeDate`). A `timeDate` is an absolute
69
+ * instant, so a zone designator (`Z` or an explicit `±HH:MM` offset) is
70
+ * required — a bare local datetime like `2026-01-01T09:00:00` is ambiguous. */
71
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
72
+
73
+ /** A FEEL expression body (a leading `=`, non-empty). */
74
+ function isFeel(v: string): boolean {
75
+ return v.startsWith("=") && v.length > 1;
76
+ }
77
+
78
+ export function assertTimerDuration(kind: string, value: string): void {
79
+ const v = typeof value === "string" ? value.trim() : "";
80
+ if (v === "" || (!isFeel(v) && !isIsoDuration(v))) {
81
+ throw new Error(
82
+ `${kind} "${value}" is not an ISO-8601 duration (e.g. PT1M30S, P1DT6H) or a FEEL expression (=...)`,
83
+ );
84
+ }
85
+ }
86
+
87
+ export function assertTimerCycle(kind: string, value: string): void {
88
+ const v = typeof value === "string" ? value.trim() : "";
89
+ if (v === "" || (!isFeel(v) && !isIsoCycle(v))) {
90
+ throw new Error(
91
+ `${kind} "${value}" is not an ISO-8601 repeating interval (e.g. R/PT1H, R5/PT30M), a bare duration (e.g. PT1H), or a FEEL expression (=...)`,
92
+ );
93
+ }
94
+ }
95
+
96
+ export function assertTimerDate(kind: string, value: string): void {
97
+ const v = typeof value === "string" ? value.trim() : "";
98
+ if (v === "" || (!isFeel(v) && !ISO_DATE.test(v))) {
99
+ throw new Error(
100
+ `${kind} "${value}" is not an ISO-8601 instant (e.g. 2026-01-01T09:00:00Z) or a FEEL expression (=...)`,
101
+ );
102
+ }
103
+ }
104
+
43
105
  export function assertWorkflowIds(wf: Workflow): void {
44
106
  assertIdent("workflow id", wf.id);
45
107
  if (wf.kind === "declarative") {
@@ -54,6 +116,9 @@ function assertNodeNames(nodes: FlowNode[]): void {
54
116
  case "signal":
55
117
  assertIdent("step name", node.name);
56
118
  break;
119
+ case "timer":
120
+ assertIdent("step name", node.name);
121
+ break;
57
122
  case "task":
58
123
  assertIdent("step name", node.name);
59
124
  if (node.jobType !== undefined) assertJobType("job type", node.jobType);