@nanobpm/urban 0.83.0 → 0.84.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.
- package/dist/runtime/core/host.d.ts +87 -1
- package/dist/runtime/core/host.js +4 -0
- package/dist/runtime/engine/nanosdk.d.ts +41 -1
- package/dist/runtime/engine/nanosdk.js +118 -0
- package/dist/runtime/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/runtime/core/host.ts +89 -0
- package/src/runtime/core/modules/actions.test.ts +4 -0
- package/src/runtime/core/modules/agent.test.ts +4 -0
- package/src/runtime/core/modules/api.test.ts +4 -0
- package/src/runtime/core/modules/cancel.test.ts +4 -0
- package/src/runtime/core/modules/connectors.test.ts +4 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +4 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +4 -0
- package/src/runtime/core/modules/pages.test.ts +4 -0
- package/src/runtime/core/modules/surfaces.test.ts +8 -0
- package/src/runtime/core/modules/triggers.test.ts +4 -0
- package/src/runtime/core/modules/workers.test.ts +4 -0
- package/src/runtime/core/runtime.extensions.test.ts +6 -0
- package/src/runtime/core/runtime.test.ts +6 -0
- package/src/runtime/engine/nanosdk.test.ts +103 -0
- package/src/runtime/engine/nanosdk.ts +152 -0
- package/src/runtime/index.ts +4 -0
|
@@ -318,6 +318,51 @@ export interface ElementInstanceWaitStateFilter {
|
|
|
318
318
|
elementId?: string;
|
|
319
319
|
waitStateType?: WaitStateType;
|
|
320
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* The engine's own incident-state enum — the lifecycle states an engine actually reports and
|
|
323
|
+
* that are valid as a search selector. An `ACTIVE` incident is an open fault a human/agent can
|
|
324
|
+
* act on (resolve/retry); the other values are terminal or transitional. This is the single
|
|
325
|
+
* source of truth for real engine states; {@link IncidentState} derives from it.
|
|
326
|
+
*/
|
|
327
|
+
export type EngineIncidentState = "ACTIVE" | "MIGRATED" | "PENDING" | "RESOLVED";
|
|
328
|
+
/**
|
|
329
|
+
* The lifecycle state of an incident, as {@link EngineClient.searchIncidents} reports it.
|
|
330
|
+
* Extends {@link EngineIncidentState} with the client-side `"UNKNOWN"` sentinel — a fallback
|
|
331
|
+
* used when the engine returns an unrecognized/absent value, so an incident is surfaced rather
|
|
332
|
+
* than dropped for an odd state. Because `"UNKNOWN"` is not a real engine state, it is not a
|
|
333
|
+
* valid {@link IncidentFilter} selector. A caller narrows on this union rather than probing a
|
|
334
|
+
* raw string.
|
|
335
|
+
*/
|
|
336
|
+
export type IncidentState = EngineIncidentState | "UNKNOWN";
|
|
337
|
+
/**
|
|
338
|
+
* Selectors for an incident search: which process instance and/or lifecycle state to match.
|
|
339
|
+
* Used by {@link EngineClient.searchIncidents}. An eventually consistent (zero-wait) read; an
|
|
340
|
+
* unset selector matches every incident.
|
|
341
|
+
*/
|
|
342
|
+
export interface IncidentFilter {
|
|
343
|
+
processInstanceKey?: string;
|
|
344
|
+
state?: EngineIncidentState;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* A single incident (a stuck token — a job out of retries, an unhandled error, a failed
|
|
348
|
+
* gateway evaluation) as {@link EngineClient.searchIncidents} reports it. Carries the
|
|
349
|
+
* `incidentKey` a caller passes to {@link EngineClient.resolveIncident}, the owning
|
|
350
|
+
* `processInstanceKey`, and — when the incident is job-backed — the `jobKey` a caller passes
|
|
351
|
+
* to {@link EngineClient.updateJobRetries} to make the job retriable before resolving.
|
|
352
|
+
* `errorType`/`errorMessage` are the engine's fault description; they are free-form across
|
|
353
|
+
* engines (the live SDK reports a Camunda error-type enum, the in-process engine a coarser
|
|
354
|
+
* `kind`), so treat them as diagnostic text, not a stable taxonomy.
|
|
355
|
+
*/
|
|
356
|
+
export interface IncidentSummary {
|
|
357
|
+
readonly incidentKey: string;
|
|
358
|
+
readonly processInstanceKey: string;
|
|
359
|
+
readonly elementId?: string;
|
|
360
|
+
readonly elementInstanceKey?: string;
|
|
361
|
+
readonly jobKey?: string;
|
|
362
|
+
readonly errorType?: string;
|
|
363
|
+
readonly errorMessage?: string;
|
|
364
|
+
readonly state: IncidentState;
|
|
365
|
+
}
|
|
321
366
|
/**
|
|
322
367
|
* A deployed form's form-js schema plus its identifying metadata, as
|
|
323
368
|
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
@@ -447,6 +492,47 @@ export interface EngineClient {
|
|
|
447
492
|
* {@link searchElementInstances}.
|
|
448
493
|
*/
|
|
449
494
|
getElementInstance(elementInstanceKey: string): Promise<ElementInstanceSummary | null>;
|
|
495
|
+
/**
|
|
496
|
+
* Search incidents by owning process instance and/or lifecycle state
|
|
497
|
+
* (`POST /v2/incidents/search`) — the open faults blocking one or more instances (a job out
|
|
498
|
+
* of retries, an unhandled error, a failed gateway evaluation). An eventually consistent
|
|
499
|
+
* (zero-wait) read; each {@link IncidentSummary} carries the `incidentKey` to pass to
|
|
500
|
+
* {@link resolveIncident} and, for a job-backed incident, the `jobKey` to pass to
|
|
501
|
+
* {@link updateJobRetries}. The debugging counterpart to the read accessors above: where
|
|
502
|
+
* they answer "how far has this instance progressed", this answers "why is it stuck".
|
|
503
|
+
*/
|
|
504
|
+
searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
|
|
505
|
+
/**
|
|
506
|
+
* Resolve an open incident by key (`POST /v2/incidents/{incidentKey}/resolution`), unblocking
|
|
507
|
+
* the parked token: a job incident returns the job to the activatable pool (so it must have
|
|
508
|
+
* retries left first — see {@link updateJobRetries}), a gateway incident re-evaluates, an
|
|
509
|
+
* uncaught-error incident re-creates the service-task job. A mutating operation.
|
|
510
|
+
*/
|
|
511
|
+
resolveIncident(input: {
|
|
512
|
+
incidentKey: string;
|
|
513
|
+
}): Promise<void>;
|
|
514
|
+
/**
|
|
515
|
+
* Update the remaining retries of a job (`PATCH /v2/jobs/{jobKey}`) — the "retry a failed
|
|
516
|
+
* job" operation. Setting a failed job's retries back above zero makes it activatable again;
|
|
517
|
+
* paired with {@link resolveIncident}, this is how a stuck `jobNoRetries` incident is cleared
|
|
518
|
+
* (bump retries, then resolve). A mutating operation.
|
|
519
|
+
*/
|
|
520
|
+
updateJobRetries(input: {
|
|
521
|
+
jobKey: string;
|
|
522
|
+
retries: number;
|
|
523
|
+
}): Promise<void>;
|
|
524
|
+
/**
|
|
525
|
+
* Set (merge) variables into a scope (`PUT /v2/element-instances/{scopeKey}/variables`).
|
|
526
|
+
* `scopeKey` is a process-instance key or an element-instance key; when `local` is `true` the
|
|
527
|
+
* variables are merged strictly into that local scope, otherwise (the default) they propagate
|
|
528
|
+
* to the outermost scope. A mutating operation used to repair state before resolving an
|
|
529
|
+
* incident or to steer an instance during debugging.
|
|
530
|
+
*/
|
|
531
|
+
setVariables(input: {
|
|
532
|
+
scopeKey: string;
|
|
533
|
+
variables: Record<string, unknown>;
|
|
534
|
+
local?: boolean;
|
|
535
|
+
}): Promise<void>;
|
|
450
536
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
451
537
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
452
538
|
workerName?: string;
|
|
@@ -470,4 +556,4 @@ export interface EngineClient {
|
|
|
470
556
|
* and `getForm` before it) that a purely structural `implements EngineClient` check no
|
|
471
557
|
* longer catches once the fake is published/compiled against an older `@nanobpm/urban`.
|
|
472
558
|
*/
|
|
473
|
-
export declare const ENGINE_CLIENT_METHODS: readonly ["deployResources", "createInstance", "cancelInstance", "publishMessage", "searchUserTasks", "openUserTasks", "getForm", "completeUserTask", "searchProcessInstances", "searchElementInstances", "searchElementInstanceWaitStates", "getElementInstance", "registerWorker", "close"];
|
|
559
|
+
export declare const ENGINE_CLIENT_METHODS: readonly ["deployResources", "createInstance", "cancelInstance", "publishMessage", "searchUserTasks", "openUserTasks", "getForm", "completeUserTask", "searchProcessInstances", "searchElementInstances", "searchElementInstanceWaitStates", "getElementInstance", "searchIncidents", "resolveIncident", "updateJobRetries", "setVariables", "registerWorker", "close"];
|
|
@@ -69,6 +69,10 @@ export const ENGINE_CLIENT_METHODS = [
|
|
|
69
69
|
"searchElementInstances",
|
|
70
70
|
"searchElementInstanceWaitStates",
|
|
71
71
|
"getElementInstance",
|
|
72
|
+
"searchIncidents",
|
|
73
|
+
"resolveIncident",
|
|
74
|
+
"updateJobRetries",
|
|
75
|
+
"setVariables",
|
|
72
76
|
"registerWorker",
|
|
73
77
|
"close",
|
|
74
78
|
];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -19,6 +19,11 @@ export declare function normalizeElementInstanceState(raw: unknown): ElementInst
|
|
|
19
19
|
* {@link WaitStateType} union, or `undefined` for an unrecognized value. Case-insensitive
|
|
20
20
|
* so a casing tweak doesn't silently drop parks. */
|
|
21
21
|
export declare function normalizeWaitStateType(raw: unknown): WaitStateType | undefined;
|
|
22
|
+
/** Map the engine's incident `state` onto the transport-agnostic {@link IncidentState} union,
|
|
23
|
+
* falling back to `"UNKNOWN"` for an unrecognized/absent value (an incident is never dropped
|
|
24
|
+
* for an odd state — unlike element/process rows, whose state is a hard identity field — so a
|
|
25
|
+
* caller still sees the fault). Case-insensitive so a casing tweak doesn't mis-map. */
|
|
26
|
+
export declare function normalizeIncidentState(raw: unknown): IncidentState;
|
|
22
27
|
/** Map one engine element-instance row onto an {@link ElementInstanceSummary}, or `undefined`
|
|
23
28
|
* when the row is malformed (missing key/elementId/process instance, or an unrecognized
|
|
24
29
|
* state). The single source of truth both `searchElementInstances` and `getElementInstance`
|
|
@@ -29,6 +34,12 @@ export declare function mapElementInstanceRow(row: Record<string, unknown>, log:
|
|
|
29
34
|
* Camunda SDK nests the park-specific fields under `details`; a plain REST body may inline
|
|
30
35
|
* them — read `details` first, then fall back to the row itself, so both wire shapes map. */
|
|
31
36
|
export declare function mapElementInstanceWaitStateRow(row: Record<string, unknown>, log: Log): ElementInstanceWaitState | undefined;
|
|
37
|
+
/** Map one engine incident row onto an {@link IncidentSummary}, or `undefined` when the row is
|
|
38
|
+
* malformed (missing the `incidentKey` or the owning `processInstanceKey` — the two identity
|
|
39
|
+
* fields a caller needs to resolve/act on it). `jobKey`/`elementInstanceKey`/`errorType`/
|
|
40
|
+
* `errorMessage` are best-effort diagnostics: present when the engine reports them, omitted
|
|
41
|
+
* otherwise. The single source of truth `searchIncidents` extracts through. */
|
|
42
|
+
export declare function mapIncidentRow(row: Record<string, unknown>, log: Log): IncidentSummary | undefined;
|
|
32
43
|
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
33
44
|
* acknowledgement actions the handler must call. */
|
|
34
45
|
export interface NanoSdkActivatedJob {
|
|
@@ -120,6 +131,22 @@ export interface NanoSdkClient {
|
|
|
120
131
|
getElementInstance(input: {
|
|
121
132
|
elementInstanceKey: string;
|
|
122
133
|
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
134
|
+
searchIncidents(input: {
|
|
135
|
+
filter?: Record<string, unknown>;
|
|
136
|
+
page?: Record<string, unknown>;
|
|
137
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
138
|
+
resolveIncident(input: {
|
|
139
|
+
incidentKey: string;
|
|
140
|
+
}, options?: unknown): Promise<unknown>;
|
|
141
|
+
updateJob(input: {
|
|
142
|
+
jobKey: string;
|
|
143
|
+
changeset: Record<string, unknown>;
|
|
144
|
+
}, options?: unknown): Promise<unknown>;
|
|
145
|
+
createElementInstanceVariables(input: {
|
|
146
|
+
elementInstanceKey: string;
|
|
147
|
+
variables: Record<string, unknown>;
|
|
148
|
+
local?: boolean;
|
|
149
|
+
}, options?: unknown): Promise<unknown>;
|
|
123
150
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
124
151
|
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
125
152
|
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
@@ -187,6 +214,19 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
187
214
|
searchElementInstances(filter?: ElementInstanceFilter): Promise<ElementInstanceSummary[]>;
|
|
188
215
|
searchElementInstanceWaitStates(filter?: ElementInstanceWaitStateFilter): Promise<ElementInstanceWaitState[]>;
|
|
189
216
|
getElementInstance(elementInstanceKey: string): Promise<ElementInstanceSummary | null>;
|
|
217
|
+
searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
|
|
218
|
+
resolveIncident(input: {
|
|
219
|
+
incidentKey: string;
|
|
220
|
+
}): Promise<void>;
|
|
221
|
+
updateJobRetries(input: {
|
|
222
|
+
jobKey: string;
|
|
223
|
+
retries: number;
|
|
224
|
+
}): Promise<void>;
|
|
225
|
+
setVariables(input: {
|
|
226
|
+
scopeKey: string;
|
|
227
|
+
variables: Record<string, unknown>;
|
|
228
|
+
local?: boolean;
|
|
229
|
+
}): Promise<void>;
|
|
190
230
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
191
231
|
workerName?: string;
|
|
192
232
|
maxParallelJobs?: number;
|
|
@@ -91,6 +91,26 @@ export function normalizeWaitStateType(raw) {
|
|
|
91
91
|
return undefined;
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
/** Map the engine's incident `state` onto the transport-agnostic {@link IncidentState} union,
|
|
95
|
+
* falling back to `"UNKNOWN"` for an unrecognized/absent value (an incident is never dropped
|
|
96
|
+
* for an odd state — unlike element/process rows, whose state is a hard identity field — so a
|
|
97
|
+
* caller still sees the fault). Case-insensitive so a casing tweak doesn't mis-map. */
|
|
98
|
+
export function normalizeIncidentState(raw) {
|
|
99
|
+
if (typeof raw !== "string")
|
|
100
|
+
return "UNKNOWN";
|
|
101
|
+
switch (raw.toUpperCase()) {
|
|
102
|
+
case "ACTIVE":
|
|
103
|
+
return "ACTIVE";
|
|
104
|
+
case "MIGRATED":
|
|
105
|
+
return "MIGRATED";
|
|
106
|
+
case "PENDING":
|
|
107
|
+
return "PENDING";
|
|
108
|
+
case "RESOLVED":
|
|
109
|
+
return "RESOLVED";
|
|
110
|
+
default:
|
|
111
|
+
return "UNKNOWN";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
94
114
|
/** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
|
|
95
115
|
* whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
|
|
96
116
|
* either way) but never `String(...)`-coerces an arbitrary object into a garbage
|
|
@@ -105,6 +125,19 @@ function presentEngineKey(value) {
|
|
|
105
125
|
const trimmed = value.trim();
|
|
106
126
|
return trimmed === "" ? undefined : trimmed;
|
|
107
127
|
}
|
|
128
|
+
/** The non-empty, trimmed string form of a *required* engine key, or throws when the value is
|
|
129
|
+
* absent/blank (including a whitespace-only string). A mutating seam operation addresses a
|
|
130
|
+
* single entity by key, so a blank key is a caller bug that must fail fast with an actionable
|
|
131
|
+
* message rather than reach the engine as an empty path segment / invalid changeset and surface
|
|
132
|
+
* an opaque transport error. Reuses {@link presentEngineKey}'s trim-and-coerce rule so a padded
|
|
133
|
+
* key like `" 5 "` is normalized identically to how read paths resolve it. */
|
|
134
|
+
function requireEngineKey(value, name) {
|
|
135
|
+
const key = presentEngineKey(value);
|
|
136
|
+
if (key === undefined) {
|
|
137
|
+
throw new Error(`${name} must be a non-empty key`);
|
|
138
|
+
}
|
|
139
|
+
return key;
|
|
140
|
+
}
|
|
108
141
|
/** A required text field (a wait-state discriminator's `jobType`/`messageName`/`signalName`),
|
|
109
142
|
* or `undefined` when absent or blank — the caller skips the row rather than emitting a typed
|
|
110
143
|
* wait state carrying an empty required string. Returns the trimmed value, matching the
|
|
@@ -253,6 +286,40 @@ export function mapElementInstanceWaitStateRow(row, log) {
|
|
|
253
286
|
return { ...base, waitStateType };
|
|
254
287
|
}
|
|
255
288
|
}
|
|
289
|
+
/** Map one engine incident row onto an {@link IncidentSummary}, or `undefined` when the row is
|
|
290
|
+
* malformed (missing the `incidentKey` or the owning `processInstanceKey` — the two identity
|
|
291
|
+
* fields a caller needs to resolve/act on it). `jobKey`/`elementInstanceKey`/`errorType`/
|
|
292
|
+
* `errorMessage` are best-effort diagnostics: present when the engine reports them, omitted
|
|
293
|
+
* otherwise. The single source of truth `searchIncidents` extracts through. */
|
|
294
|
+
export function mapIncidentRow(row, log) {
|
|
295
|
+
const incidentKey = presentEngineKey(row.incidentKey);
|
|
296
|
+
if (incidentKey === undefined) {
|
|
297
|
+
log("warn", "skipping incident with no incidentKey in engine response");
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
const processInstanceKey = presentEngineKey(row.processInstanceKey);
|
|
301
|
+
if (processInstanceKey === undefined) {
|
|
302
|
+
log("warn", "skipping incident with no processInstanceKey in engine response", {
|
|
303
|
+
incidentKey,
|
|
304
|
+
});
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
const elementId = presentText(row.elementId);
|
|
308
|
+
const elementInstanceKey = presentEngineKey(row.elementInstanceKey);
|
|
309
|
+
const jobKey = presentEngineKey(row.jobKey);
|
|
310
|
+
const errorType = presentText(row.errorType);
|
|
311
|
+
const errorMessage = presentText(row.errorMessage);
|
|
312
|
+
return {
|
|
313
|
+
incidentKey,
|
|
314
|
+
processInstanceKey,
|
|
315
|
+
...(elementId ? { elementId } : {}),
|
|
316
|
+
...(elementInstanceKey ? { elementInstanceKey } : {}),
|
|
317
|
+
...(jobKey ? { jobKey } : {}),
|
|
318
|
+
...(errorType ? { errorType } : {}),
|
|
319
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
320
|
+
state: normalizeIncidentState(row.state),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
256
323
|
/**
|
|
257
324
|
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
258
325
|
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
|
@@ -484,6 +551,57 @@ export class SdkEngineClient {
|
|
|
484
551
|
return null;
|
|
485
552
|
return mapElementInstanceRow(body, this.log) ?? null;
|
|
486
553
|
}
|
|
554
|
+
async searchIncidents(filter) {
|
|
555
|
+
// Same shape as `searchElementInstances`: build the engine filter from the transport-agnostic
|
|
556
|
+
// selectors, read at zero-wait consistency (an incident search is eventually consistent), and
|
|
557
|
+
// map each row through the single `mapIncidentRow` gate, dropping malformed rows.
|
|
558
|
+
const f = {};
|
|
559
|
+
// Normalize the request selectors the same way the response mapper normalizes keys: a
|
|
560
|
+
// whitespace-only/padded `processInstanceKey` must not reach the engine as a garbage filter
|
|
561
|
+
// segment (which would silently return surprising empty results).
|
|
562
|
+
const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
|
|
563
|
+
if (processInstanceKey)
|
|
564
|
+
f.processInstanceKey = processInstanceKey;
|
|
565
|
+
if (filter?.state)
|
|
566
|
+
f.state = filter.state;
|
|
567
|
+
const body = await this.client.searchIncidents({ filter: f }, { consistency: { waitUpToMs: 0 } });
|
|
568
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
569
|
+
return items.flatMap((it) => {
|
|
570
|
+
const mapped = mapIncidentRow(it, this.log);
|
|
571
|
+
return mapped ? [mapped] : [];
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
async resolveIncident(input) {
|
|
575
|
+
await this.client.resolveIncident({
|
|
576
|
+
incidentKey: requireEngineKey(input.incidentKey, "incidentKey"),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
async updateJobRetries(input) {
|
|
580
|
+
// The "retry a failed job" operation: a job's remaining retries are the `retries` field of
|
|
581
|
+
// the `updateJob` changeset. Bumping them back above zero makes a failed job activatable so a
|
|
582
|
+
// paired `resolveIncident` can return it to the pool.
|
|
583
|
+
const jobKey = requireEngineKey(input.jobKey, "jobKey");
|
|
584
|
+
// `retries` serializes straight into the engine changeset, and `number` admits `NaN`,
|
|
585
|
+
// fractional, and negative values — none of which is a valid retry count. Reject them up
|
|
586
|
+
// front so a tool-driven call fails with a clear message instead of an opaque engine error.
|
|
587
|
+
if (!Number.isInteger(input.retries) || input.retries < 0) {
|
|
588
|
+
throw new Error("retries must be a non-negative integer");
|
|
589
|
+
}
|
|
590
|
+
await this.client.updateJob({
|
|
591
|
+
jobKey,
|
|
592
|
+
changeset: { retries: input.retries },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
async setVariables(input) {
|
|
596
|
+
// A scope is addressed by its element-instance key (the process instance's root scope is an
|
|
597
|
+
// element instance too), so `scopeKey` maps straight onto `elementInstanceKey`. `local`
|
|
598
|
+
// defaults to engine behaviour (propagate to the outermost scope) when unset.
|
|
599
|
+
await this.client.createElementInstanceVariables({
|
|
600
|
+
elementInstanceKey: requireEngineKey(input.scopeKey, "scopeKey"),
|
|
601
|
+
variables: input.variables,
|
|
602
|
+
...(input.local === undefined ? {} : { local: input.local }),
|
|
603
|
+
});
|
|
604
|
+
}
|
|
487
605
|
async registerWorker(jobType, handler, options) {
|
|
488
606
|
const worker = this.client.createJobWorker({
|
|
489
607
|
jobType,
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
|
|
|
8
8
|
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
9
9
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
10
10
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
11
|
-
export type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
11
|
+
export type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
12
12
|
export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
|
|
13
13
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
14
14
|
export { createLogger } from "./core/logger.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -359,6 +359,55 @@ export interface ElementInstanceWaitStateFilter {
|
|
|
359
359
|
waitStateType?: WaitStateType;
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* The engine's own incident-state enum — the lifecycle states an engine actually reports and
|
|
364
|
+
* that are valid as a search selector. An `ACTIVE` incident is an open fault a human/agent can
|
|
365
|
+
* act on (resolve/retry); the other values are terminal or transitional. This is the single
|
|
366
|
+
* source of truth for real engine states; {@link IncidentState} derives from it.
|
|
367
|
+
*/
|
|
368
|
+
export type EngineIncidentState = "ACTIVE" | "MIGRATED" | "PENDING" | "RESOLVED";
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The lifecycle state of an incident, as {@link EngineClient.searchIncidents} reports it.
|
|
372
|
+
* Extends {@link EngineIncidentState} with the client-side `"UNKNOWN"` sentinel — a fallback
|
|
373
|
+
* used when the engine returns an unrecognized/absent value, so an incident is surfaced rather
|
|
374
|
+
* than dropped for an odd state. Because `"UNKNOWN"` is not a real engine state, it is not a
|
|
375
|
+
* valid {@link IncidentFilter} selector. A caller narrows on this union rather than probing a
|
|
376
|
+
* raw string.
|
|
377
|
+
*/
|
|
378
|
+
export type IncidentState = EngineIncidentState | "UNKNOWN";
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Selectors for an incident search: which process instance and/or lifecycle state to match.
|
|
382
|
+
* Used by {@link EngineClient.searchIncidents}. An eventually consistent (zero-wait) read; an
|
|
383
|
+
* unset selector matches every incident.
|
|
384
|
+
*/
|
|
385
|
+
export interface IncidentFilter {
|
|
386
|
+
processInstanceKey?: string;
|
|
387
|
+
state?: EngineIncidentState;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A single incident (a stuck token — a job out of retries, an unhandled error, a failed
|
|
392
|
+
* gateway evaluation) as {@link EngineClient.searchIncidents} reports it. Carries the
|
|
393
|
+
* `incidentKey` a caller passes to {@link EngineClient.resolveIncident}, the owning
|
|
394
|
+
* `processInstanceKey`, and — when the incident is job-backed — the `jobKey` a caller passes
|
|
395
|
+
* to {@link EngineClient.updateJobRetries} to make the job retriable before resolving.
|
|
396
|
+
* `errorType`/`errorMessage` are the engine's fault description; they are free-form across
|
|
397
|
+
* engines (the live SDK reports a Camunda error-type enum, the in-process engine a coarser
|
|
398
|
+
* `kind`), so treat them as diagnostic text, not a stable taxonomy.
|
|
399
|
+
*/
|
|
400
|
+
export interface IncidentSummary {
|
|
401
|
+
readonly incidentKey: string;
|
|
402
|
+
readonly processInstanceKey: string;
|
|
403
|
+
readonly elementId?: string;
|
|
404
|
+
readonly elementInstanceKey?: string;
|
|
405
|
+
readonly jobKey?: string;
|
|
406
|
+
readonly errorType?: string;
|
|
407
|
+
readonly errorMessage?: string;
|
|
408
|
+
readonly state: IncidentState;
|
|
409
|
+
}
|
|
410
|
+
|
|
362
411
|
/**
|
|
363
412
|
* A deployed form's form-js schema plus its identifying metadata, as
|
|
364
413
|
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
@@ -483,6 +532,42 @@ export interface EngineClient {
|
|
|
483
532
|
getElementInstance(
|
|
484
533
|
elementInstanceKey: string,
|
|
485
534
|
): Promise<ElementInstanceSummary | null>;
|
|
535
|
+
/**
|
|
536
|
+
* Search incidents by owning process instance and/or lifecycle state
|
|
537
|
+
* (`POST /v2/incidents/search`) — the open faults blocking one or more instances (a job out
|
|
538
|
+
* of retries, an unhandled error, a failed gateway evaluation). An eventually consistent
|
|
539
|
+
* (zero-wait) read; each {@link IncidentSummary} carries the `incidentKey` to pass to
|
|
540
|
+
* {@link resolveIncident} and, for a job-backed incident, the `jobKey` to pass to
|
|
541
|
+
* {@link updateJobRetries}. The debugging counterpart to the read accessors above: where
|
|
542
|
+
* they answer "how far has this instance progressed", this answers "why is it stuck".
|
|
543
|
+
*/
|
|
544
|
+
searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
|
|
545
|
+
/**
|
|
546
|
+
* Resolve an open incident by key (`POST /v2/incidents/{incidentKey}/resolution`), unblocking
|
|
547
|
+
* the parked token: a job incident returns the job to the activatable pool (so it must have
|
|
548
|
+
* retries left first — see {@link updateJobRetries}), a gateway incident re-evaluates, an
|
|
549
|
+
* uncaught-error incident re-creates the service-task job. A mutating operation.
|
|
550
|
+
*/
|
|
551
|
+
resolveIncident(input: { incidentKey: string }): Promise<void>;
|
|
552
|
+
/**
|
|
553
|
+
* Update the remaining retries of a job (`PATCH /v2/jobs/{jobKey}`) — the "retry a failed
|
|
554
|
+
* job" operation. Setting a failed job's retries back above zero makes it activatable again;
|
|
555
|
+
* paired with {@link resolveIncident}, this is how a stuck `jobNoRetries` incident is cleared
|
|
556
|
+
* (bump retries, then resolve). A mutating operation.
|
|
557
|
+
*/
|
|
558
|
+
updateJobRetries(input: { jobKey: string; retries: number }): Promise<void>;
|
|
559
|
+
/**
|
|
560
|
+
* Set (merge) variables into a scope (`PUT /v2/element-instances/{scopeKey}/variables`).
|
|
561
|
+
* `scopeKey` is a process-instance key or an element-instance key; when `local` is `true` the
|
|
562
|
+
* variables are merged strictly into that local scope, otherwise (the default) they propagate
|
|
563
|
+
* to the outermost scope. A mutating operation used to repair state before resolving an
|
|
564
|
+
* incident or to steer an instance during debugging.
|
|
565
|
+
*/
|
|
566
|
+
setVariables(input: {
|
|
567
|
+
scopeKey: string;
|
|
568
|
+
variables: Record<string, unknown>;
|
|
569
|
+
local?: boolean;
|
|
570
|
+
}): Promise<void>;
|
|
486
571
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
487
572
|
registerWorker(
|
|
488
573
|
jobType: string,
|
|
@@ -520,6 +605,10 @@ export const ENGINE_CLIENT_METHODS = [
|
|
|
520
605
|
"searchElementInstances",
|
|
521
606
|
"searchElementInstanceWaitStates",
|
|
522
607
|
"getElementInstance",
|
|
608
|
+
"searchIncidents",
|
|
609
|
+
"resolveIncident",
|
|
610
|
+
"updateJobRetries",
|
|
611
|
+
"setVariables",
|
|
523
612
|
"registerWorker",
|
|
524
613
|
"close",
|
|
525
614
|
] as const satisfies readonly (keyof EngineClient)[];
|
|
@@ -36,6 +36,10 @@ const engine: EngineClient = {
|
|
|
36
36
|
searchElementInstances: async () => [],
|
|
37
37
|
searchElementInstanceWaitStates: async () => [],
|
|
38
38
|
getElementInstance: async () => null,
|
|
39
|
+
searchIncidents: async () => [],
|
|
40
|
+
resolveIncident: async () => {},
|
|
41
|
+
updateJobRetries: async () => {},
|
|
42
|
+
setVariables: async () => {},
|
|
39
43
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
40
44
|
close: async () => {},
|
|
41
45
|
};
|
|
@@ -21,6 +21,10 @@ const fakeEngine: EngineClient = {
|
|
|
21
21
|
searchElementInstances: async () => [],
|
|
22
22
|
searchElementInstanceWaitStates: async () => [],
|
|
23
23
|
getElementInstance: async () => null,
|
|
24
|
+
searchIncidents: async () => [],
|
|
25
|
+
resolveIncident: async () => {},
|
|
26
|
+
updateJobRetries: async () => {},
|
|
27
|
+
setVariables: async () => {},
|
|
24
28
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
25
29
|
close: async () => {},
|
|
26
30
|
};
|
|
@@ -34,6 +34,10 @@ const engine: EngineClient = {
|
|
|
34
34
|
searchElementInstances: async () => [],
|
|
35
35
|
searchElementInstanceWaitStates: async () => [],
|
|
36
36
|
getElementInstance: async () => null,
|
|
37
|
+
searchIncidents: async () => [],
|
|
38
|
+
resolveIncident: async () => {},
|
|
39
|
+
updateJobRetries: async () => {},
|
|
40
|
+
setVariables: async () => {},
|
|
37
41
|
completeUserTask: async () => {},
|
|
38
42
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
39
43
|
close: async () => {},
|
|
@@ -88,6 +88,10 @@ function cancelEngine(opts: {
|
|
|
88
88
|
searchElementInstances: notUsed("searchElementInstances"),
|
|
89
89
|
searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
|
|
90
90
|
getElementInstance: notUsed("getElementInstance"),
|
|
91
|
+
searchIncidents: notUsed("searchIncidents"),
|
|
92
|
+
resolveIncident: notUsed("resolveIncident"),
|
|
93
|
+
updateJobRetries: notUsed("updateJobRetries"),
|
|
94
|
+
setVariables: notUsed("setVariables"),
|
|
91
95
|
registerWorker: notUsed("registerWorker"),
|
|
92
96
|
close: notUsed("close"),
|
|
93
97
|
};
|
|
@@ -24,6 +24,10 @@ class MiniEngine implements EngineClient {
|
|
|
24
24
|
async searchElementInstances(): Promise<[]> { return []; }
|
|
25
25
|
async searchElementInstanceWaitStates(): Promise<[]> { return []; }
|
|
26
26
|
async getElementInstance(): Promise<null> { return null; }
|
|
27
|
+
async searchIncidents(): Promise<never[]> { return []; }
|
|
28
|
+
async resolveIncident(): Promise<void> {}
|
|
29
|
+
async updateJobRetries(): Promise<void> {}
|
|
30
|
+
async setVariables(): Promise<void> {}
|
|
27
31
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
28
32
|
this.workers.set(jobType, handler);
|
|
29
33
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -23,6 +23,10 @@ const noEngine: EngineClient = {
|
|
|
23
23
|
searchElementInstances: async () => [],
|
|
24
24
|
searchElementInstanceWaitStates: async () => [],
|
|
25
25
|
getElementInstance: async () => null,
|
|
26
|
+
searchIncidents: async () => [],
|
|
27
|
+
resolveIncident: async () => {},
|
|
28
|
+
updateJobRetries: async () => {},
|
|
29
|
+
setVariables: async () => {},
|
|
26
30
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
27
31
|
close: async () => {},
|
|
28
32
|
};
|
|
@@ -93,6 +93,10 @@ function fakeEngine(
|
|
|
93
93
|
searchElementInstances: notUsed("searchElementInstances"),
|
|
94
94
|
searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
|
|
95
95
|
getElementInstance: notUsed("getElementInstance"),
|
|
96
|
+
searchIncidents: notUsed("searchIncidents"),
|
|
97
|
+
resolveIncident: notUsed("resolveIncident"),
|
|
98
|
+
updateJobRetries: notUsed("updateJobRetries"),
|
|
99
|
+
setVariables: notUsed("setVariables"),
|
|
96
100
|
registerWorker: notUsed("registerWorker"),
|
|
97
101
|
close: notUsed("close"),
|
|
98
102
|
};
|
|
@@ -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
|
};
|
|
@@ -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,
|
package/src/runtime/index.ts
CHANGED