@nanobpm/urban 0.83.0 → 0.85.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.
Files changed (32) hide show
  1. package/dist/runtime/core/host.d.ts +87 -1
  2. package/dist/runtime/core/host.js +4 -0
  3. package/dist/runtime/core/modules/api.d.ts +5 -0
  4. package/dist/runtime/core/modules/api.js +2 -2
  5. package/dist/runtime/core/modules/mcp.d.ts +57 -0
  6. package/dist/runtime/core/modules/mcp.js +709 -0
  7. package/dist/runtime/core/modules/surfaces.js +11 -0
  8. package/dist/runtime/engine/nanosdk.d.ts +41 -1
  9. package/dist/runtime/engine/nanosdk.js +118 -0
  10. package/dist/runtime/index.d.ts +1 -1
  11. package/package.json +2 -1
  12. package/src/runtime/core/host.ts +89 -0
  13. package/src/runtime/core/modules/actions.test.ts +4 -0
  14. package/src/runtime/core/modules/agent.test.ts +4 -0
  15. package/src/runtime/core/modules/api.test.ts +4 -0
  16. package/src/runtime/core/modules/api.ts +2 -2
  17. package/src/runtime/core/modules/cancel.test.ts +4 -0
  18. package/src/runtime/core/modules/connectors.test.ts +4 -0
  19. package/src/runtime/core/modules/datasource.migrations.test.ts +4 -0
  20. package/src/runtime/core/modules/instance-tracking.test.ts +4 -0
  21. package/src/runtime/core/modules/mcp.test.ts +687 -0
  22. package/src/runtime/core/modules/mcp.ts +814 -0
  23. package/src/runtime/core/modules/pages.test.ts +4 -0
  24. package/src/runtime/core/modules/surfaces.test.ts +24 -0
  25. package/src/runtime/core/modules/surfaces.ts +11 -0
  26. package/src/runtime/core/modules/triggers.test.ts +4 -0
  27. package/src/runtime/core/modules/workers.test.ts +4 -0
  28. package/src/runtime/core/runtime.extensions.test.ts +6 -0
  29. package/src/runtime/core/runtime.test.ts +6 -0
  30. package/src/runtime/engine/nanosdk.test.ts +103 -0
  31. package/src/runtime/engine/nanosdk.ts +152 -0
  32. package/src/runtime/index.ts +4 -0
@@ -51,6 +51,10 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
51
51
  async searchElementInstances() { return []; },
52
52
  async searchElementInstanceWaitStates() { return []; },
53
53
  async getElementInstance() { return null; },
54
+ async searchIncidents() { return []; },
55
+ async resolveIncident() {},
56
+ async updateJobRetries() {},
57
+ async setVariables() {},
54
58
  async registerWorker(jobType) { return { jobType, unsubscribe: async () => {} }; },
55
59
  async close() {},
56
60
  };
@@ -37,6 +37,10 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
37
37
  searchElementInstances: async () => [],
38
38
  searchElementInstanceWaitStates: async () => [],
39
39
  getElementInstance: async () => null,
40
+ searchIncidents: async () => [],
41
+ resolveIncident: async () => {},
42
+ updateJobRetries: async () => {},
43
+ setVariables: async () => {},
40
44
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
41
45
  close: async () => {},
42
46
  };
@@ -56,6 +60,10 @@ const fakeEngine: EngineClient = {
56
60
  searchElementInstances: async () => [],
57
61
  searchElementInstanceWaitStates: async () => [],
58
62
  getElementInstance: async () => null,
63
+ searchIncidents: async () => [],
64
+ resolveIncident: async () => {},
65
+ updateJobRetries: async () => {},
66
+ setVariables: async () => {},
59
67
  registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
60
68
  close: async () => {},
61
69
  };
@@ -400,3 +408,19 @@ test("/api/complete rejects a body with no userTaskKey", async () => {
400
408
  assert.equal(res.status, 400);
401
409
  });
402
410
 
411
+
412
+ test("MCP is listed as an active surface only when config-enabled, but its route is always mounted", () => {
413
+ // Default: MCP enabled -> listed as an active surface.
414
+ const on = mountSurfaces(ctxWith({}), fakeApp);
415
+ const onEnabled = on.describe().enabled;
416
+ assert.ok(Array.isArray(onEnabled) && onEnabled.includes("mcp@/app/mcp"), "an enabled MCP surface is listed as active");
417
+
418
+ // Config-disabled (URBAN_MCP_ENABLED=false): the handler 404s, so it must NOT appear as active,
419
+ // yet the route stays mounted for a stable address.
420
+ const base = ctxWith({});
421
+ const offCtx = { ...base, host: { ...base.host, env: (v: string) => (v === "URBAN_MCP_ENABLED" ? "false" : undefined) } };
422
+ const off = mountSurfaces(offCtx, fakeApp);
423
+ const offEnabled = off.describe().enabled;
424
+ assert.ok(Array.isArray(offEnabled) && !offEnabled.includes("mcp@/app/mcp"), "a disabled MCP surface must not be listed as active");
425
+ assert.ok(off.routes.some((r) => r.path === "/app/mcp"), "the MCP route stays mounted even when disabled");
426
+ });
@@ -14,6 +14,7 @@ import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
14
14
  import { mountActions } from "./actions.ts";
15
15
  import { mountAgent } from "./agent.ts";
16
16
  import { mountApi } from "./api.ts";
17
+ import { mountMcp } from "./mcp.ts";
17
18
  import { mountPages } from "./pages.ts";
18
19
 
19
20
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -211,6 +212,16 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
211
212
  routes.push(...agent.routes);
212
213
  enabled.push("agent@/app/agent");
213
214
 
215
+ // The runtime-served MCP surface (ADR 0067): `/app/mcp`, a Streamable-HTTP MCP endpoint mounted
216
+ // unconditionally (like `/app/agent`) so an MCP client always has a stable address. It projects
217
+ // the app's read-only OpenAPI operations as tools from the SAME enumeration `mountApi` routes
218
+ // from, plus framework-owned read-only process-debugging tools — zero app-side MCP code.
219
+ const mcp = mountMcp(ctx, app, api.routes);
220
+ routes.push(...mcp.routes);
221
+ // The route is always mounted (stable address), but only claim MCP as an active surface when it
222
+ // actually answers — a config-disabled surface 404s, so listing it here would be inaccurate.
223
+ if (mcp.enabled) enabled.push("mcp@/app/mcp");
224
+
214
225
  // The pages surface (the schema-driven page runtime) mounts its own routes.
215
226
  const pages = mountPages(ctx, app);
216
227
  if (pages.routes.length > 0) {
@@ -38,6 +38,10 @@ class MiniEngine implements EngineClient {
38
38
  async searchElementInstances(): Promise<[]> { return []; }
39
39
  async searchElementInstanceWaitStates(): Promise<[]> { return []; }
40
40
  async getElementInstance(): Promise<null> { return null; }
41
+ async searchIncidents(): Promise<never[]> { return []; }
42
+ async resolveIncident(): Promise<void> {}
43
+ async updateJobRetries(): Promise<void> {}
44
+ async setVariables(): Promise<void> {}
41
45
  async registerWorker(jobType: string): Promise<{ jobType: string; unsubscribe(): Promise<void> }> {
42
46
  return { jobType, unsubscribe: async () => {} };
43
47
  }
@@ -35,6 +35,10 @@ class MiniEngine implements EngineClient {
35
35
  async searchElementInstances(): Promise<[]> { return []; }
36
36
  async searchElementInstanceWaitStates(): Promise<[]> { return []; }
37
37
  async getElementInstance(): Promise<null> { return null; }
38
+ async searchIncidents(): Promise<never[]> { return []; }
39
+ async resolveIncident(): Promise<void> {}
40
+ async updateJobRetries(): Promise<void> {}
41
+ async setVariables(): Promise<void> {}
38
42
  async close(): Promise<void> {}
39
43
  async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
40
44
  this.workers.set(jobType, handler);
@@ -41,6 +41,12 @@ class NoopEngine implements EngineClient {
41
41
  async getElementInstance() {
42
42
  return null;
43
43
  }
44
+ async searchIncidents() {
45
+ return [];
46
+ }
47
+ async resolveIncident() {}
48
+ async updateJobRetries() {}
49
+ async setVariables() {}
44
50
  async registerWorker(jobType: string): Promise<WorkerSubscription> {
45
51
  return { jobType, unsubscribe: async () => {} };
46
52
  }
@@ -62,6 +62,12 @@ class FakeEngine implements EngineClient {
62
62
  async getElementInstance() {
63
63
  return null;
64
64
  }
65
+ async searchIncidents() {
66
+ return [];
67
+ }
68
+ async resolveIncident() {}
69
+ async updateJobRetries() {}
70
+ async setVariables() {}
65
71
  async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
66
72
  this.workers.set(jobType, handler);
67
73
  return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
@@ -88,6 +88,22 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
88
88
  calls.push("getElementInstance");
89
89
  return {};
90
90
  },
91
+ async searchIncidents() {
92
+ calls.push("searchIncidents");
93
+ return { items: [] };
94
+ },
95
+ async resolveIncident(input) {
96
+ calls.push("resolveIncident");
97
+ return { ...input };
98
+ },
99
+ async updateJob(input) {
100
+ calls.push("updateJob");
101
+ return { ...input };
102
+ },
103
+ async createElementInstanceVariables(input) {
104
+ calls.push("createElementInstanceVariables");
105
+ return { ...input };
106
+ },
91
107
  async completeUserTask() {
92
108
  calls.push("completeUserTask");
93
109
  return {};
@@ -729,6 +745,93 @@ test("getElementInstance returns a mapped summary, and null for a blank key or a
729
745
  assert.equal(await engine.getElementInstance("missing"), null);
730
746
  });
731
747
 
748
+ test("searchIncidents normalizes the processInstanceKey selector (trims, drops blank)", async () => {
749
+ let seenInput: { filter?: Record<string, unknown> } | undefined;
750
+ const client = fakeSdkClient({
751
+ async searchIncidents(input) {
752
+ seenInput = input;
753
+ return { items: [] };
754
+ },
755
+ });
756
+ const engine = new SdkEngineClient(client);
757
+
758
+ // A padded key is trimmed before it reaches the engine filter.
759
+ await engine.searchIncidents({ processInstanceKey: " 7 " });
760
+ assert.deepEqual(seenInput?.filter, { processInstanceKey: "7" });
761
+
762
+ // A whitespace-only key is treated as absent, not forwarded as a garbage filter segment.
763
+ await engine.searchIncidents({ processInstanceKey: " " });
764
+ assert.deepEqual(seenInput?.filter, {});
765
+
766
+ // The state selector still passes through untouched.
767
+ await engine.searchIncidents({ state: "ACTIVE" });
768
+ assert.deepEqual(seenInput?.filter, { state: "ACTIVE" });
769
+ });
770
+
771
+ test("resolveIncident normalizes the incidentKey and rejects a blank one", async () => {
772
+ let seen: { incidentKey: string } | undefined;
773
+ const client = fakeSdkClient({
774
+ async resolveIncident(input) {
775
+ seen = input;
776
+ return { ...input };
777
+ },
778
+ });
779
+ const engine = new SdkEngineClient(client);
780
+
781
+ await engine.resolveIncident({ incidentKey: " inc-1 " });
782
+ assert.deepEqual(seen, { incidentKey: "inc-1" });
783
+
784
+ await assert.rejects(
785
+ engine.resolveIncident({ incidentKey: " " }),
786
+ /incidentKey must be a non-empty key/,
787
+ );
788
+ });
789
+
790
+ test("setVariables normalizes the scopeKey and rejects a blank one", async () => {
791
+ let seen: Record<string, unknown> | undefined;
792
+ const client = fakeSdkClient({
793
+ async createElementInstanceVariables(input) {
794
+ seen = input;
795
+ return { ...input };
796
+ },
797
+ });
798
+ const engine = new SdkEngineClient(client);
799
+
800
+ await engine.setVariables({ scopeKey: " ei-9 ", variables: { fixed: true } });
801
+ assert.deepEqual(seen, { elementInstanceKey: "ei-9", variables: { fixed: true } });
802
+
803
+ await assert.rejects(
804
+ engine.setVariables({ scopeKey: " ", variables: { fixed: true } }),
805
+ /scopeKey must be a non-empty key/,
806
+ );
807
+ });
808
+
809
+ test("updateJobRetries normalizes the jobKey and rejects a blank key or an invalid retry count", async () => {
810
+ let seen: { jobKey: string; changeset: Record<string, unknown> } | undefined;
811
+ const client = fakeSdkClient({
812
+ async updateJob(input) {
813
+ seen = input;
814
+ return { ...input };
815
+ },
816
+ });
817
+ const engine = new SdkEngineClient(client);
818
+
819
+ await engine.updateJobRetries({ jobKey: " j-1 ", retries: 3 });
820
+ assert.deepEqual(seen, { jobKey: "j-1", changeset: { retries: 3 } });
821
+
822
+ await assert.rejects(
823
+ engine.updateJobRetries({ jobKey: " ", retries: 3 }),
824
+ /jobKey must be a non-empty key/,
825
+ );
826
+ for (const retries of [-1, 1.5, Number.NaN]) {
827
+ await assert.rejects(
828
+ engine.updateJobRetries({ jobKey: "j-1", retries }),
829
+ /retries must be a non-negative integer/,
830
+ `retries=${retries} must be rejected`,
831
+ );
832
+ }
833
+ });
834
+
732
835
  test("registerWorker creates a worker the SDK auto-starts, and dispatches + completes", async () => {
733
836
  const client = fakeSdkClient();
734
837
  const engine = new SdkEngineClient(client);
@@ -21,6 +21,9 @@ import type {
21
21
  WaitStateType,
22
22
  EngineJob,
23
23
  FormSchema,
24
+ IncidentFilter,
25
+ IncidentState,
26
+ IncidentSummary,
24
27
  JobHandler,
25
28
  ProcessInstanceSnapshot,
26
29
  ProcessInstanceState,
@@ -114,6 +117,26 @@ export function normalizeWaitStateType(raw: unknown): WaitStateType | undefined
114
117
  }
115
118
  }
116
119
 
120
+ /** Map the engine's incident `state` onto the transport-agnostic {@link IncidentState} union,
121
+ * falling back to `"UNKNOWN"` for an unrecognized/absent value (an incident is never dropped
122
+ * for an odd state — unlike element/process rows, whose state is a hard identity field — so a
123
+ * caller still sees the fault). Case-insensitive so a casing tweak doesn't mis-map. */
124
+ export function normalizeIncidentState(raw: unknown): IncidentState {
125
+ if (typeof raw !== "string") return "UNKNOWN";
126
+ switch (raw.toUpperCase()) {
127
+ case "ACTIVE":
128
+ return "ACTIVE";
129
+ case "MIGRATED":
130
+ return "MIGRATED";
131
+ case "PENDING":
132
+ return "PENDING";
133
+ case "RESOLVED":
134
+ return "RESOLVED";
135
+ default:
136
+ return "UNKNOWN";
137
+ }
138
+ }
139
+
117
140
  /** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
118
141
  * whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
119
142
  * either way) but never `String(...)`-coerces an arbitrary object into a garbage
@@ -128,6 +151,20 @@ function presentEngineKey(value: unknown): string | undefined {
128
151
  return trimmed === "" ? undefined : trimmed;
129
152
  }
130
153
 
154
+ /** The non-empty, trimmed string form of a *required* engine key, or throws when the value is
155
+ * absent/blank (including a whitespace-only string). A mutating seam operation addresses a
156
+ * single entity by key, so a blank key is a caller bug that must fail fast with an actionable
157
+ * message rather than reach the engine as an empty path segment / invalid changeset and surface
158
+ * an opaque transport error. Reuses {@link presentEngineKey}'s trim-and-coerce rule so a padded
159
+ * key like `" 5 "` is normalized identically to how read paths resolve it. */
160
+ function requireEngineKey(value: unknown, name: string): string {
161
+ const key = presentEngineKey(value);
162
+ if (key === undefined) {
163
+ throw new Error(`${name} must be a non-empty key`);
164
+ }
165
+ return key;
166
+ }
167
+
131
168
  /** A required text field (a wait-state discriminator's `jobType`/`messageName`/`signalName`),
132
169
  * or `undefined` when absent or blank — the caller skips the row rather than emitting a typed
133
170
  * wait state carrying an empty required string. Returns the trimmed value, matching the
@@ -285,6 +322,44 @@ export function mapElementInstanceWaitStateRow(
285
322
  }
286
323
  }
287
324
 
325
+ /** Map one engine incident row onto an {@link IncidentSummary}, or `undefined` when the row is
326
+ * malformed (missing the `incidentKey` or the owning `processInstanceKey` — the two identity
327
+ * fields a caller needs to resolve/act on it). `jobKey`/`elementInstanceKey`/`errorType`/
328
+ * `errorMessage` are best-effort diagnostics: present when the engine reports them, omitted
329
+ * otherwise. The single source of truth `searchIncidents` extracts through. */
330
+ export function mapIncidentRow(
331
+ row: Record<string, unknown>,
332
+ log: Log,
333
+ ): IncidentSummary | undefined {
334
+ const incidentKey = presentEngineKey(row.incidentKey);
335
+ if (incidentKey === undefined) {
336
+ log("warn", "skipping incident with no incidentKey in engine response");
337
+ return undefined;
338
+ }
339
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
340
+ if (processInstanceKey === undefined) {
341
+ log("warn", "skipping incident with no processInstanceKey in engine response", {
342
+ incidentKey,
343
+ });
344
+ return undefined;
345
+ }
346
+ const elementId = presentText(row.elementId);
347
+ const elementInstanceKey = presentEngineKey(row.elementInstanceKey);
348
+ const jobKey = presentEngineKey(row.jobKey);
349
+ const errorType = presentText(row.errorType);
350
+ const errorMessage = presentText(row.errorMessage);
351
+ return {
352
+ incidentKey,
353
+ processInstanceKey,
354
+ ...(elementId ? { elementId } : {}),
355
+ ...(elementInstanceKey ? { elementInstanceKey } : {}),
356
+ ...(jobKey ? { jobKey } : {}),
357
+ ...(errorType ? { errorType } : {}),
358
+ ...(errorMessage ? { errorMessage } : {}),
359
+ state: normalizeIncidentState(row.state),
360
+ };
361
+ }
362
+
288
363
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
289
364
  * acknowledgement actions the handler must call. */
290
365
  export interface NanoSdkActivatedJob {
@@ -383,6 +458,23 @@ export interface NanoSdkClient {
383
458
  consistency?: unknown,
384
459
  options?: unknown,
385
460
  ): Promise<Record<string, unknown>>;
461
+ searchIncidents(
462
+ input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
463
+ consistency?: unknown,
464
+ options?: unknown,
465
+ ): Promise<Record<string, unknown>>;
466
+ resolveIncident(
467
+ input: { incidentKey: string },
468
+ options?: unknown,
469
+ ): Promise<unknown>;
470
+ updateJob(
471
+ input: { jobKey: string; changeset: Record<string, unknown> },
472
+ options?: unknown,
473
+ ): Promise<unknown>;
474
+ createElementInstanceVariables(
475
+ input: { elementInstanceKey: string; variables: Record<string, unknown>; local?: boolean },
476
+ options?: unknown,
477
+ ): Promise<unknown>;
386
478
  createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
387
479
  /** Stop every worker created on this client. Used on teardown to also drain a
388
480
  * REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
@@ -666,6 +758,66 @@ export class SdkEngineClient implements EngineClient {
666
758
  return mapElementInstanceRow(body, this.log) ?? null;
667
759
  }
668
760
 
761
+ async searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]> {
762
+ // Same shape as `searchElementInstances`: build the engine filter from the transport-agnostic
763
+ // selectors, read at zero-wait consistency (an incident search is eventually consistent), and
764
+ // map each row through the single `mapIncidentRow` gate, dropping malformed rows.
765
+ const f: Record<string, unknown> = {};
766
+ // Normalize the request selectors the same way the response mapper normalizes keys: a
767
+ // whitespace-only/padded `processInstanceKey` must not reach the engine as a garbage filter
768
+ // segment (which would silently return surprising empty results).
769
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
770
+ if (processInstanceKey) f.processInstanceKey = processInstanceKey;
771
+ if (filter?.state) f.state = filter.state;
772
+ const body = await this.client.searchIncidents(
773
+ { filter: f },
774
+ { consistency: { waitUpToMs: 0 } },
775
+ );
776
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
777
+ return items.flatMap((it) => {
778
+ const mapped = mapIncidentRow(it, this.log);
779
+ return mapped ? [mapped] : [];
780
+ });
781
+ }
782
+
783
+ async resolveIncident(input: { incidentKey: string }): Promise<void> {
784
+ await this.client.resolveIncident({
785
+ incidentKey: requireEngineKey(input.incidentKey, "incidentKey"),
786
+ });
787
+ }
788
+
789
+ async updateJobRetries(input: { jobKey: string; retries: number }): Promise<void> {
790
+ // The "retry a failed job" operation: a job's remaining retries are the `retries` field of
791
+ // the `updateJob` changeset. Bumping them back above zero makes a failed job activatable so a
792
+ // paired `resolveIncident` can return it to the pool.
793
+ const jobKey = requireEngineKey(input.jobKey, "jobKey");
794
+ // `retries` serializes straight into the engine changeset, and `number` admits `NaN`,
795
+ // fractional, and negative values — none of which is a valid retry count. Reject them up
796
+ // front so a tool-driven call fails with a clear message instead of an opaque engine error.
797
+ if (!Number.isInteger(input.retries) || input.retries < 0) {
798
+ throw new Error("retries must be a non-negative integer");
799
+ }
800
+ await this.client.updateJob({
801
+ jobKey,
802
+ changeset: { retries: input.retries },
803
+ });
804
+ }
805
+
806
+ async setVariables(input: {
807
+ scopeKey: string;
808
+ variables: Record<string, unknown>;
809
+ local?: boolean;
810
+ }): Promise<void> {
811
+ // A scope is addressed by its element-instance key (the process instance's root scope is an
812
+ // element instance too), so `scopeKey` maps straight onto `elementInstanceKey`. `local`
813
+ // defaults to engine behaviour (propagate to the outermost scope) when unset.
814
+ await this.client.createElementInstanceVariables({
815
+ elementInstanceKey: requireEngineKey(input.scopeKey, "scopeKey"),
816
+ variables: input.variables,
817
+ ...(input.local === undefined ? {} : { local: input.local }),
818
+ });
819
+ }
820
+
669
821
  async registerWorker(
670
822
  jobType: string,
671
823
  handler: JobHandler,
@@ -96,6 +96,10 @@ export type {
96
96
  HttpRequest,
97
97
  HttpResponse,
98
98
  HttpServer,
99
+ IncidentState,
100
+ EngineIncidentState,
101
+ IncidentFilter,
102
+ IncidentSummary,
99
103
  JobHandler,
100
104
  ProcessInstanceSnapshot,
101
105
  ProcessInstanceState,