@nanobpm/urban 0.86.0 → 0.87.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.
@@ -296,10 +296,19 @@ export interface ElementInstanceSummary {
296
296
 
297
297
  /**
298
298
  * The kind of wait an element instance is parked in, as a
299
- * `/v2/element-instances/wait-states/search` result reports it. Unlike a user-task search,
300
- * this surfaces *every* park — a `JOB` (a service task awaiting a worker), a `MESSAGE`
301
- * (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well as a
302
- * `USER_TASK` — so a consumer can read "what is this instance blocked on" beyond user tasks.
299
+ * `/v2/element-instances/wait-states/search` result reports it. This is the *canonical*
300
+ * six-type contract (Zeebe's, as of 2026): a `JOB` (a service task awaiting a worker), a
301
+ * `MESSAGE` (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well
302
+ * as a `USER_TASK`.
303
+ *
304
+ * **Not every deployed engine implements the whole union.** It is forward-looking on
305
+ * purpose (the response mapper and {@link normalizeWaitStateType} tolerate all six so a
306
+ * newer engine's rows map cleanly), but the *currently deployed* nanobpmn gateway's
307
+ * read-model floor is narrower — see {@link DEPLOYED_WAIT_STATE_TYPES}. A
308
+ * {@link ElementInstanceWaitStateFilter} whose `waitStateType` falls outside that floor is
309
+ * rejected (the gateway answers HTTP 422); {@link assertDeployedWaitStateType} enforces the
310
+ * same floor client-side so the divergence surfaces as a clear error offline, in tests, and
311
+ * against a live engine alike. Ref: Magikcraft/nano-bpm#1042.
303
312
  */
304
313
  export type WaitStateType =
305
314
  | "JOB"
@@ -309,6 +318,59 @@ export type WaitStateType =
309
318
  | "SIGNAL"
310
319
  | "CONDITION";
311
320
 
321
+ /**
322
+ * The `waitStateType` values the *currently deployed* nanobpmn gateway's wait-state read
323
+ * model actually implements — the **deployed floor**, a strict subset of the canonical
324
+ * {@link WaitStateType} union. The gateway's read model is a snapshot of the Zeebe contract
325
+ * taken 2026-06-09 (the same day Zeebe extended it with `USER_TASK`, then `TIMER`/`SIGNAL`/
326
+ * `CONDITION`), so it recognizes only `JOB | MESSAGE` and rejects any other filter value
327
+ * with HTTP 422.
328
+ *
329
+ * This constant is the **single source of truth** for that floor: the SDK-backed and
330
+ * WASM-backed {@link EngineClient} adapters both gate through {@link assertDeployedWaitStateType},
331
+ * and the WASM emulation synthesizes only these park kinds — so a test cannot pass against
332
+ * the emulation while a real gateway would 422 (No Drift Surfaces). Widen it (adding a leg
333
+ * backed by the real gateway) only once the engine actually serves the richer type — the
334
+ * tracking gap is Magikcraft/nano-bpm#1042.
335
+ */
336
+ export const DEPLOYED_WAIT_STATE_TYPES: readonly WaitStateType[] = ["JOB", "MESSAGE"];
337
+
338
+ /**
339
+ * Thrown when a wait-state search is asked to filter on a {@link WaitStateType} the deployed
340
+ * gateway's read model does not implement (outside {@link DEPLOYED_WAIT_STATE_TYPES}). It
341
+ * fails fast client-side with a clear, offline-detectable error rather than letting the call
342
+ * reach the gateway and surface as an opaque HTTP 422 "did not match any variant of untagged
343
+ * enum WaitStateTypeFilterProperty". Ref: Magikcraft/nano-bpm#1042.
344
+ */
345
+ export class UnsupportedWaitStateTypeError extends Error {
346
+ readonly waitStateType: string;
347
+ readonly supported: readonly WaitStateType[];
348
+ constructor(waitStateType: string) {
349
+ super(
350
+ `unsupported waitStateType "${waitStateType}": the deployed nanobpmn gateway's ` +
351
+ `wait-state read model implements only ${DEPLOYED_WAIT_STATE_TYPES.join(" | ")} and ` +
352
+ `rejects any other value with HTTP 422. Ref: Magikcraft/nano-bpm#1042.`,
353
+ );
354
+ this.name = "UnsupportedWaitStateTypeError";
355
+ this.waitStateType = waitStateType;
356
+ this.supported = DEPLOYED_WAIT_STATE_TYPES;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Assert a wait-state filter's `waitStateType` is within the deployed floor
362
+ * ({@link DEPLOYED_WAIT_STATE_TYPES}), throwing {@link UnsupportedWaitStateTypeError}
363
+ * otherwise. An absent selector is always allowed (the search is unfiltered by type). Both
364
+ * {@link EngineClient} adapters call this before issuing a wait-state search, so the floor is
365
+ * enforced identically whether the query runs against a live gateway or the in-process
366
+ * emulation. No-ops for an in-floor type.
367
+ */
368
+ export function assertDeployedWaitStateType(waitStateType?: WaitStateType): void {
369
+ if (waitStateType !== undefined && !DEPLOYED_WAIT_STATE_TYPES.includes(waitStateType)) {
370
+ throw new UnsupportedWaitStateTypeError(waitStateType);
371
+ }
372
+ }
373
+
312
374
  /**
313
375
  * A single parked (waiting) element instance as
314
376
  * {@link EngineClient.searchElementInstanceWaitStates} reports it. Every variant carries the
@@ -352,6 +414,10 @@ export interface ElementInstanceFilter {
352
414
  * Selectors for an element-instance *wait-state* search: which process instance, element,
353
415
  * and/or kind of wait to match. Used by {@link EngineClient.searchElementInstanceWaitStates}.
354
416
  * There is deliberately no lifecycle-`state` selector — a wait state is always an active park.
417
+ *
418
+ * A `waitStateType` selector must fall within the deployed floor
419
+ * ({@link DEPLOYED_WAIT_STATE_TYPES}); a value outside it is rejected with
420
+ * {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422).
355
421
  */
356
422
  export interface ElementInstanceWaitStateFilter {
357
423
  processInstanceKey?: string;
@@ -512,11 +578,19 @@ export interface EngineClient {
512
578
  ): Promise<ElementInstanceSummary[]>;
513
579
  /**
514
580
  * Search the *wait states* of element instances
515
- * (`POST /v2/element-instances/wait-states/search`) — every park, not only user tasks. A
516
- * `JOB` park (a service task awaiting a worker), a `MESSAGE` park (an event awaiting
517
- * correlation), a `TIMER`/`SIGNAL`/`CONDITION`, plus `USER_TASK`. A zero-wait read; each
518
- * result is discriminated by `waitStateType` (see {@link ElementInstanceWaitState}), so a
519
- * consumer can read the job/message parks a {@link searchUserTasks} cannot surface.
581
+ * (`POST /v2/element-instances/wait-states/search`) — the parks the deployed engine's
582
+ * read model serves. A zero-wait read; each result is discriminated by `waitStateType`
583
+ * (see {@link ElementInstanceWaitState}), so a consumer can read the job/message parks a
584
+ * {@link searchUserTasks} cannot surface.
585
+ *
586
+ * **Deployed floor.** The currently deployed nanobpmn gateway implements only
587
+ * `JOB | MESSAGE` ({@link DEPLOYED_WAIT_STATE_TYPES}) — a `USER_TASK` park is read via
588
+ * {@link searchUserTasks}, not here. A `waitStateType` filter outside the floor is rejected
589
+ * with {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422); an
590
+ * unfiltered search returns only in-floor parks. The `waitStateType` union is
591
+ * forward-looking (richer engines may serve more), but this adapter contract pins the
592
+ * floor so a query cannot pass in emulation while a real engine rejects it. Ref:
593
+ * Magikcraft/nano-bpm#1042.
520
594
  */
521
595
  searchElementInstanceWaitStates(
522
596
  filter?: ElementInstanceWaitStateFilter,
@@ -327,6 +327,35 @@ test("a projected tool's input schema matches the spec's params", async () => {
327
327
  });
328
328
  });
329
329
 
330
+ test("a $ref-bodied tool projects a self-contained body schema (no $ref, explicit type)", async () => {
331
+ // createInvoice's requestBody is `{ $ref: "#/components/schemas/Invoice" }` — a bare $ref with no
332
+ // inline `type`. An MCP client cannot resolve `#/components/...` outside the document, so the
333
+ // projected tool schema MUST inline the concrete Invoice shape (ADR 0067). Regression guard for the
334
+ // leak that shipped an opaque `#/components/...` pointer into `inputSchema.properties.body`.
335
+ const { router } = buildHarness();
336
+ const session = await connect(router);
337
+ const list = await rpc(router, session, "tools/list", {});
338
+ const tools = list.result?.tools;
339
+ assert.ok(Array.isArray(tools));
340
+ const createInvoice = tools.find((t) => Reflect.get(t, "name") === "createInvoice");
341
+ assert.ok(createInvoice, "createInvoice must be present in tools/list before checking its schema");
342
+ const schema = Reflect.get(createInvoice, "inputSchema");
343
+ assert.deepEqual(schema, {
344
+ type: "object",
345
+ properties: {
346
+ body: {
347
+ type: "object",
348
+ properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
349
+ required: ["id", "amount"],
350
+ additionalProperties: false,
351
+ },
352
+ },
353
+ required: ["body"],
354
+ });
355
+ // Self-containment holds structurally: the serialized schema carries no `$ref` token at all.
356
+ assert.ok(!JSON.stringify(schema).includes("$ref"), "projected body schema must not leak a $ref");
357
+ });
358
+
330
359
  test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
331
360
  let seenParams: Record<string, unknown> | undefined;
332
361
  const { router, imported } = buildHarness({
@@ -55,6 +55,7 @@ import {
55
55
  parseSpec,
56
56
  type SecurityDecision,
57
57
  sharedSecretSchemeName,
58
+ toolInputSchema,
58
59
  } from "../../../openapi/spec.ts";
59
60
  import type { AppApi, RuntimeContext } from "../context.ts";
60
61
  import { errorMessage } from "../guards.ts";
@@ -250,26 +251,10 @@ function isMutatingOperation(op: OperationInfo): boolean {
250
251
  return isMutatingMethod(op.method);
251
252
  }
252
253
 
253
- /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
254
- * path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
255
- * spec means the tool input schema and the HTTP validation share one source of truth. */
256
- function toolInputSchema(op: OperationInfo): Record<string, unknown> {
257
- const properties: Record<string, unknown> = {};
258
- const required: string[] = [];
259
- for (const p of op.parameters) {
260
- if (p.in === "path" || p.in === "query") {
261
- properties[p.name] = p.schema ?? {};
262
- if (p.required) required.push(p.name);
263
- }
264
- }
265
- if (op.requestBodySchema) {
266
- properties.body = op.requestBodySchema;
267
- if (op.requestBodyRequired) required.push("body");
268
- }
269
- const schema: Record<string, unknown> = { type: "object", properties };
270
- if (required.length > 0) schema.required = required;
271
- return schema;
272
- }
254
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input is derived once, in
255
+ * `openapi/spec.ts`'s {@link toolInputSchema}, from an operation's resolved (`$ref`-free) parameter
256
+ * and request-body metadata — so the tool input schema the runtime advertises and the schema the CI
257
+ * parity guard pins are the SAME derivation (one source of truth, ADR 0053/0067). */
273
258
 
274
259
  /** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
275
260
  * the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */
@@ -13,6 +13,11 @@ import {
13
13
  type NanoSdkJobWorkerConfig,
14
14
  } from "./nanosdk.ts";
15
15
  import { BpmnError } from "../core/host.ts";
16
+ import {
17
+ assertDeployedWaitStateType,
18
+ DEPLOYED_WAIT_STATE_TYPES,
19
+ UnsupportedWaitStateTypeError,
20
+ } from "../core/host.ts";
16
21
  import { __resetExecStoreForTests, installExecStore, runInJobContext } from "../core/execContext.ts";
17
22
  import { readLineage } from "../core/lineage.ts";
18
23
  import { createNodeHost } from "../adapters/node.ts";
@@ -498,6 +503,27 @@ test("normalizeWaitStateType maps the wait-state kinds (case-insensitive) and re
498
503
  assert.equal(normalizeWaitStateType(3), undefined);
499
504
  });
500
505
 
506
+ test("assertDeployedWaitStateType: allows the floor and an absent selector, rejects the rest", () => {
507
+ // The deployed floor is exactly JOB|MESSAGE — the single source of truth both adapters gate on.
508
+ assert.deepEqual([...DEPLOYED_WAIT_STATE_TYPES], ["JOB", "MESSAGE"]);
509
+ // In-floor and absent selectors are no-ops (no throw).
510
+ assert.doesNotThrow(() => assertDeployedWaitStateType(undefined));
511
+ assert.doesNotThrow(() => assertDeployedWaitStateType("JOB"));
512
+ assert.doesNotThrow(() => assertDeployedWaitStateType("MESSAGE"));
513
+ // Every canonical type outside the floor is rejected with a typed, descriptive error.
514
+ for (const waitStateType of ["USER_TASK", "TIMER", "SIGNAL", "CONDITION"] as const) {
515
+ assert.throws(
516
+ () => assertDeployedWaitStateType(waitStateType),
517
+ (err: unknown) => {
518
+ assert.ok(err instanceof UnsupportedWaitStateTypeError);
519
+ assert.equal(err.waitStateType, waitStateType);
520
+ assert.match(err.message, /HTTP 422/);
521
+ return true;
522
+ },
523
+ );
524
+ }
525
+ });
526
+
501
527
  test("searchElementInstances passes zero-wait consistency, maps rows, and forwards selectors", async () => {
502
528
  let seenInput: unknown;
503
529
  let seenConsistency: unknown;
@@ -650,35 +676,67 @@ test("searchElementInstanceWaitStates forwards a waitStateType selector and read
650
676
  {
651
677
  elementInstanceKey: "9",
652
678
  processInstanceKey: "3",
653
- elementId: "ut",
654
- waitStateType: "USER_TASK",
655
- taskKey: "42",
679
+ elementId: "catch",
680
+ waitStateType: "MESSAGE",
681
+ messageName: "Go",
682
+ correlationKey: "K1",
656
683
  },
657
684
  {
658
685
  elementInstanceKey: "11",
659
686
  processInstanceKey: "3",
660
- elementId: "ut2",
661
- waitStateType: "USER_TASK",
662
- }, // USER_TASK with no taskKey/userTaskKey → dropped (invalid identity)
687
+ elementId: "catch2",
688
+ waitStateType: "MESSAGE",
689
+ }, // MESSAGE with no messageName → dropped (invalid identity)
663
690
  { elementInstanceKey: "10", processInstanceKey: "3", elementId: "?" }, // no waitStateType → dropped
664
691
  ],
665
692
  };
666
693
  },
667
694
  });
668
695
  const engine = new SdkEngineClient(client);
669
- const out = await engine.searchElementInstanceWaitStates({ waitStateType: "USER_TASK" });
670
- assert.deepEqual(seenInput, { filter: { waitStateType: "USER_TASK" } });
696
+ const out = await engine.searchElementInstanceWaitStates({ waitStateType: "MESSAGE" });
697
+ assert.deepEqual(seenInput, { filter: { waitStateType: "MESSAGE" } });
671
698
  assert.deepEqual(out, [
672
699
  {
673
700
  elementInstanceKey: "9",
674
701
  processInstanceKey: "3",
675
- elementId: "ut",
676
- waitStateType: "USER_TASK",
677
- userTaskKey: "42",
702
+ elementId: "catch",
703
+ waitStateType: "MESSAGE",
704
+ messageName: "Go",
705
+ correlationKey: "K1",
678
706
  },
679
707
  ]);
680
708
  });
681
709
 
710
+ test("searchElementInstanceWaitStates rejects a waitStateType outside the deployed floor without calling the gateway", async () => {
711
+ // The deployed gateway's read model implements only JOB|MESSAGE and 422s any other filter
712
+ // value. The SDK adapter must fail fast client-side (not issue the call), so an app authoring
713
+ // `waitStateType: "USER_TASK"` cannot ship green then blow up at runtime (issue
714
+ // nanobpm/nano-ide#497).
715
+ let called = false;
716
+ const client = fakeSdkClient({
717
+ searchElementInstanceWaitStates: async () => {
718
+ called = true;
719
+ return { items: [] };
720
+ },
721
+ });
722
+ const engine = new SdkEngineClient(client);
723
+ for (const waitStateType of ["USER_TASK", "TIMER", "SIGNAL", "CONDITION"] as const) {
724
+ await assert.rejects(
725
+ engine.searchElementInstanceWaitStates({ waitStateType }),
726
+ (err: unknown) => {
727
+ assert.ok(err instanceof Error);
728
+ assert.match(err.message, /unsupported waitStateType/);
729
+ assert.match(err.message, new RegExp(waitStateType));
730
+ return true;
731
+ },
732
+ );
733
+ }
734
+ assert.equal(called, false, "the gateway must not be called for an out-of-floor filter");
735
+ // An in-floor filter still reaches the gateway.
736
+ await engine.searchElementInstanceWaitStates({ waitStateType: "JOB" });
737
+ assert.equal(called, true);
738
+ });
739
+
682
740
  test("searchElementInstanceWaitStates drops parks missing their required discriminator field", async () => {
683
741
  const client = fakeSdkClient({
684
742
  searchElementInstanceWaitStates: async () => ({
@@ -32,7 +32,7 @@ import type {
32
32
  UserTaskFilter,
33
33
  WorkerSubscription,
34
34
  } from "../core/host.ts";
35
- import { isBpmnError } from "../core/host.ts";
35
+ import { assertDeployedWaitStateType, isBpmnError } from "../core/host.ts";
36
36
  import {
37
37
  buildFormSchema,
38
38
  parseFormSchema,
@@ -709,10 +709,14 @@ export class SdkEngineClient implements EngineClient {
709
709
  async searchElementInstanceWaitStates(
710
710
  filter?: ElementInstanceWaitStateFilter,
711
711
  ): Promise<ElementInstanceWaitState[]> {
712
- // The wait-states search surfaces *every* park (job/message/timer/signal/condition/user
713
- // task), not just user tasks. Same zero-wait read + per-row mapping-gate shape as
714
- // `searchElementInstances`; the SDK nests park-specific fields under `details`, which
715
- // `mapElementInstanceWaitStateRow` unwraps.
712
+ // The wait-states search surfaces the parks the deployed engine's read model serves.
713
+ // A `waitStateType` selector outside the deployed floor (`JOB | MESSAGE`) is rejected by
714
+ // the gateway with HTTP 422; fail fast client-side with a clear error instead, so the
715
+ // divergence is caught offline and identically to the WASM adapter (No Drift Surfaces).
716
+ assertDeployedWaitStateType(filter?.waitStateType);
717
+ // Same zero-wait read + per-row mapping-gate shape as `searchElementInstances`; the SDK
718
+ // nests park-specific fields under `details`, which `mapElementInstanceWaitStateRow`
719
+ // unwraps.
716
720
  const f: Record<string, unknown> = {};
717
721
  if (filter?.processInstanceKey) f.processInstanceKey = filter.processInstanceKey;
718
722
  if (filter?.elementId) f.elementId = filter.elementId;
@@ -133,6 +133,14 @@ export { BpmnError, isBpmnError } from "./core/host.ts";
133
133
  // double / adapter iterates it to prove at runtime that it implements the full interface,
134
134
  // catching the "fake lags the SDK" seam-lag class categorically.
135
135
  export { ENGINE_CLIENT_METHODS } from "./core/host.ts";
136
+ // Deployed wait-state floor (issue nanobpm/nano-ide#497): the `waitStateType` values the
137
+ // deployed gateway actually serves, plus the shared guard both EngineClient adapters use so
138
+ // a filter outside the floor fails identically in emulation and against a live engine.
139
+ export {
140
+ assertDeployedWaitStateType,
141
+ DEPLOYED_WAIT_STATE_TYPES,
142
+ UnsupportedWaitStateTypeError,
143
+ } from "./core/host.ts";
136
144
 
137
145
  // Connector packs (ADR 0050, in-process port)
138
146
  export {