@nanobpm/urban 0.82.0 → 0.83.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 +121 -1
- package/dist/runtime/core/host.js +3 -0
- package/dist/runtime/core/modules/pages.js +5 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/engine/nanosdk.d.ts +36 -1
- package/dist/runtime/engine/nanosdk.js +269 -0
- package/dist/runtime/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +5 -1
- package/src/runtime/browser/runtime.browser.test.ts +16 -1
- package/src/runtime/core/host.ts +136 -0
- package/src/runtime/core/modules/actions.test.ts +3 -0
- package/src/runtime/core/modules/agent.test.ts +3 -0
- package/src/runtime/core/modules/api.test.ts +3 -0
- package/src/runtime/core/modules/cancel.test.ts +3 -0
- package/src/runtime/core/modules/connectors.test.ts +3 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +3 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +3 -0
- package/src/runtime/core/modules/pages.test.ts +13 -0
- package/src/runtime/core/modules/pages.ts +5 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.test.ts +6 -0
- package/src/runtime/core/modules/triggers.test.ts +3 -0
- package/src/runtime/core/modules/workers.test.ts +3 -0
- package/src/runtime/core/runtime.extensions.test.ts +9 -0
- package/src/runtime/core/runtime.test.ts +9 -0
- package/src/runtime/engine/nanosdk.test.ts +281 -0
- package/src/runtime/engine/nanosdk.ts +311 -0
- package/src/runtime/index.ts +6 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, 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. */
|
|
@@ -8,6 +8,27 @@ export declare function requireProcessInstanceKey(key: string | number | null |
|
|
|
8
8
|
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
9
9
|
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
10
10
|
export declare function normalizeProcessInstanceState(raw: unknown): ProcessInstanceState | undefined;
|
|
11
|
+
/** Map the engine's element-instance `state` onto the transport-agnostic
|
|
12
|
+
* {@link ElementInstanceState} union, or `undefined` for an unrecognized value (which the
|
|
13
|
+
* caller skips rather than surfacing a malformed row). Case-insensitive so a future casing
|
|
14
|
+
* tweak on the engine side doesn't silently drop element instances. Kept separate from
|
|
15
|
+
* {@link normalizeProcessInstanceState} — the two model distinct engine enums that only
|
|
16
|
+
* coincide today — so element and process state can diverge without a hidden coupling. */
|
|
17
|
+
export declare function normalizeElementInstanceState(raw: unknown): ElementInstanceState | undefined;
|
|
18
|
+
/** Map the engine's wait-state `waitStateType` onto the transport-agnostic
|
|
19
|
+
* {@link WaitStateType} union, or `undefined` for an unrecognized value. Case-insensitive
|
|
20
|
+
* so a casing tweak doesn't silently drop parks. */
|
|
21
|
+
export declare function normalizeWaitStateType(raw: unknown): WaitStateType | undefined;
|
|
22
|
+
/** Map one engine element-instance row onto an {@link ElementInstanceSummary}, or `undefined`
|
|
23
|
+
* when the row is malformed (missing key/elementId/process instance, or an unrecognized
|
|
24
|
+
* state). The single source of truth both `searchElementInstances` and `getElementInstance`
|
|
25
|
+
* extract through, so the two cannot drift. */
|
|
26
|
+
export declare function mapElementInstanceRow(row: Record<string, unknown>, log: Log): ElementInstanceSummary | undefined;
|
|
27
|
+
/** Map one engine wait-state row onto an {@link ElementInstanceWaitState}, or `undefined`
|
|
28
|
+
* when the row is malformed (missing identity, or an unrecognized `waitStateType`). The
|
|
29
|
+
* Camunda SDK nests the park-specific fields under `details`; a plain REST body may inline
|
|
30
|
+
* them — read `details` first, then fall back to the row itself, so both wire shapes map. */
|
|
31
|
+
export declare function mapElementInstanceWaitStateRow(row: Record<string, unknown>, log: Log): ElementInstanceWaitState | undefined;
|
|
11
32
|
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
12
33
|
* acknowledgement actions the handler must call. */
|
|
13
34
|
export interface NanoSdkActivatedJob {
|
|
@@ -88,6 +109,17 @@ export interface NanoSdkClient {
|
|
|
88
109
|
filter?: Record<string, unknown>;
|
|
89
110
|
page?: Record<string, unknown>;
|
|
90
111
|
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
112
|
+
searchElementInstances(input: {
|
|
113
|
+
filter?: Record<string, unknown>;
|
|
114
|
+
page?: Record<string, unknown>;
|
|
115
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
116
|
+
searchElementInstanceWaitStates(input: {
|
|
117
|
+
filter?: Record<string, unknown>;
|
|
118
|
+
page?: Record<string, unknown>;
|
|
119
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
120
|
+
getElementInstance(input: {
|
|
121
|
+
elementInstanceKey: string;
|
|
122
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
91
123
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
92
124
|
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
93
125
|
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
@@ -152,6 +184,9 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
152
184
|
processInstanceKeys?: string[];
|
|
153
185
|
state?: ProcessInstanceState;
|
|
154
186
|
}): Promise<ProcessInstanceSnapshot[]>;
|
|
187
|
+
searchElementInstances(filter?: ElementInstanceFilter): Promise<ElementInstanceSummary[]>;
|
|
188
|
+
searchElementInstanceWaitStates(filter?: ElementInstanceWaitStateFilter): Promise<ElementInstanceWaitState[]>;
|
|
189
|
+
getElementInstance(elementInstanceKey: string): Promise<ElementInstanceSummary | null>;
|
|
155
190
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
156
191
|
workerName?: string;
|
|
157
192
|
maxParallelJobs?: number;
|
|
@@ -48,6 +48,211 @@ export function normalizeProcessInstanceState(raw) {
|
|
|
48
48
|
return undefined;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/** Map the engine's element-instance `state` onto the transport-agnostic
|
|
52
|
+
* {@link ElementInstanceState} union, or `undefined` for an unrecognized value (which the
|
|
53
|
+
* caller skips rather than surfacing a malformed row). Case-insensitive so a future casing
|
|
54
|
+
* tweak on the engine side doesn't silently drop element instances. Kept separate from
|
|
55
|
+
* {@link normalizeProcessInstanceState} — the two model distinct engine enums that only
|
|
56
|
+
* coincide today — so element and process state can diverge without a hidden coupling. */
|
|
57
|
+
export function normalizeElementInstanceState(raw) {
|
|
58
|
+
if (typeof raw !== "string")
|
|
59
|
+
return undefined;
|
|
60
|
+
switch (raw.toUpperCase()) {
|
|
61
|
+
case "ACTIVE":
|
|
62
|
+
return "ACTIVE";
|
|
63
|
+
case "COMPLETED":
|
|
64
|
+
return "COMPLETED";
|
|
65
|
+
case "TERMINATED":
|
|
66
|
+
return "TERMINATED";
|
|
67
|
+
default:
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Map the engine's wait-state `waitStateType` onto the transport-agnostic
|
|
72
|
+
* {@link WaitStateType} union, or `undefined` for an unrecognized value. Case-insensitive
|
|
73
|
+
* so a casing tweak doesn't silently drop parks. */
|
|
74
|
+
export function normalizeWaitStateType(raw) {
|
|
75
|
+
if (typeof raw !== "string")
|
|
76
|
+
return undefined;
|
|
77
|
+
switch (raw.toUpperCase()) {
|
|
78
|
+
case "JOB":
|
|
79
|
+
return "JOB";
|
|
80
|
+
case "MESSAGE":
|
|
81
|
+
return "MESSAGE";
|
|
82
|
+
case "USER_TASK":
|
|
83
|
+
return "USER_TASK";
|
|
84
|
+
case "TIMER":
|
|
85
|
+
return "TIMER";
|
|
86
|
+
case "SIGNAL":
|
|
87
|
+
return "SIGNAL";
|
|
88
|
+
case "CONDITION":
|
|
89
|
+
return "CONDITION";
|
|
90
|
+
default:
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A non-empty string form of an engine key/id, or `undefined` when absent/blank (including a
|
|
95
|
+
* whitespace-only string). Coerces a numeric key to a string (the engine may serialize a key
|
|
96
|
+
* either way) but never `String(...)`-coerces an arbitrary object into a garbage
|
|
97
|
+
* `"[object Object]"` id. The blank check trims, matching `getElementInstance`'s blank-key
|
|
98
|
+
* guard and the form-key presence helpers, so a `" "` key can never leak into a result. */
|
|
99
|
+
function presentEngineKey(value) {
|
|
100
|
+
if (typeof value === "number") {
|
|
101
|
+
return Number.isFinite(value) ? String(value) : undefined;
|
|
102
|
+
}
|
|
103
|
+
if (typeof value !== "string")
|
|
104
|
+
return undefined;
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
return trimmed === "" ? undefined : trimmed;
|
|
107
|
+
}
|
|
108
|
+
/** A required text field (a wait-state discriminator's `jobType`/`messageName`/`signalName`),
|
|
109
|
+
* or `undefined` when absent or blank — the caller skips the row rather than emitting a typed
|
|
110
|
+
* wait state carrying an empty required string. Returns the trimmed value, matching the
|
|
111
|
+
* presence rule used for keys, so surrounding whitespace can never leak into a result. */
|
|
112
|
+
function presentText(value) {
|
|
113
|
+
if (typeof value !== "string")
|
|
114
|
+
return undefined;
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
return trimmed === "" ? undefined : trimmed;
|
|
117
|
+
}
|
|
118
|
+
/** The element type carried on an element-instance/wait-state row. The Camunda SDK DTO calls
|
|
119
|
+
* it `type`; a plain REST/Rust-engine body may call it `elementType`. Accept either. */
|
|
120
|
+
function pickElementType(row) {
|
|
121
|
+
return presentText(row.elementType) ?? presentText(row.type);
|
|
122
|
+
}
|
|
123
|
+
/** Map one engine element-instance row onto an {@link ElementInstanceSummary}, or `undefined`
|
|
124
|
+
* when the row is malformed (missing key/elementId/process instance, or an unrecognized
|
|
125
|
+
* state). The single source of truth both `searchElementInstances` and `getElementInstance`
|
|
126
|
+
* extract through, so the two cannot drift. */
|
|
127
|
+
export function mapElementInstanceRow(row, log) {
|
|
128
|
+
const elementInstanceKey = presentEngineKey(row.elementInstanceKey);
|
|
129
|
+
if (elementInstanceKey === undefined) {
|
|
130
|
+
log("warn", "skipping element instance with no key in engine response");
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const processInstanceKey = presentEngineKey(row.processInstanceKey);
|
|
134
|
+
if (processInstanceKey === undefined) {
|
|
135
|
+
log("warn", "skipping element instance with no processInstanceKey in engine response", {
|
|
136
|
+
elementInstanceKey,
|
|
137
|
+
});
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const elementId = typeof row.elementId === "string" && row.elementId.trim() !== ""
|
|
141
|
+
? row.elementId.trim()
|
|
142
|
+
: undefined;
|
|
143
|
+
if (elementId === undefined) {
|
|
144
|
+
log("warn", "skipping element instance with no elementId in engine response", {
|
|
145
|
+
elementInstanceKey,
|
|
146
|
+
});
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const state = normalizeElementInstanceState(row.state);
|
|
150
|
+
if (state === undefined) {
|
|
151
|
+
log("warn", "skipping element instance with unrecognized state", {
|
|
152
|
+
elementInstanceKey,
|
|
153
|
+
state: String(row.state),
|
|
154
|
+
});
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const elementType = pickElementType(row);
|
|
158
|
+
return {
|
|
159
|
+
elementInstanceKey,
|
|
160
|
+
processInstanceKey,
|
|
161
|
+
elementId,
|
|
162
|
+
...(elementType ? { elementType } : {}),
|
|
163
|
+
state,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Map one engine wait-state row onto an {@link ElementInstanceWaitState}, or `undefined`
|
|
167
|
+
* when the row is malformed (missing identity, or an unrecognized `waitStateType`). The
|
|
168
|
+
* Camunda SDK nests the park-specific fields under `details`; a plain REST body may inline
|
|
169
|
+
* them — read `details` first, then fall back to the row itself, so both wire shapes map. */
|
|
170
|
+
export function mapElementInstanceWaitStateRow(row, log) {
|
|
171
|
+
const elementInstanceKey = presentEngineKey(row.elementInstanceKey);
|
|
172
|
+
const processInstanceKey = presentEngineKey(row.processInstanceKey);
|
|
173
|
+
const elementId = typeof row.elementId === "string" && row.elementId.trim() !== ""
|
|
174
|
+
? row.elementId.trim()
|
|
175
|
+
: undefined;
|
|
176
|
+
if (elementInstanceKey === undefined || processInstanceKey === undefined || elementId === undefined) {
|
|
177
|
+
log("warn", "skipping element-instance wait state with incomplete identity in engine response");
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const details = isRecord(row.details) ? row.details : row;
|
|
181
|
+
const waitStateType = normalizeWaitStateType(details.waitStateType ?? row.waitStateType);
|
|
182
|
+
if (waitStateType === undefined) {
|
|
183
|
+
log("warn", "skipping element-instance wait state with unrecognized waitStateType", {
|
|
184
|
+
elementInstanceKey,
|
|
185
|
+
waitStateType: String(details.waitStateType ?? row.waitStateType),
|
|
186
|
+
});
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const elementType = pickElementType(row);
|
|
190
|
+
const base = {
|
|
191
|
+
elementInstanceKey,
|
|
192
|
+
processInstanceKey,
|
|
193
|
+
elementId,
|
|
194
|
+
...(elementType ? { elementType } : {}),
|
|
195
|
+
};
|
|
196
|
+
switch (waitStateType) {
|
|
197
|
+
case "JOB": {
|
|
198
|
+
// `jobType` is the required discriminator of a JOB park; without it the row is malformed.
|
|
199
|
+
const jobType = presentText(details.jobType);
|
|
200
|
+
if (jobType === undefined) {
|
|
201
|
+
log("warn", "skipping JOB wait state with no jobType in engine response", {
|
|
202
|
+
elementInstanceKey,
|
|
203
|
+
});
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const jobKey = presentEngineKey(details.jobKey);
|
|
207
|
+
return { ...base, waitStateType, jobType, ...(jobKey ? { jobKey } : {}) };
|
|
208
|
+
}
|
|
209
|
+
case "MESSAGE": {
|
|
210
|
+
// `messageName` is the required discriminator of a MESSAGE park.
|
|
211
|
+
const messageName = presentText(details.messageName);
|
|
212
|
+
if (messageName === undefined) {
|
|
213
|
+
log("warn", "skipping MESSAGE wait state with no messageName in engine response", {
|
|
214
|
+
elementInstanceKey,
|
|
215
|
+
});
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
const correlationKey = presentText(details.correlationKey);
|
|
219
|
+
return {
|
|
220
|
+
...base,
|
|
221
|
+
waitStateType,
|
|
222
|
+
messageName,
|
|
223
|
+
...(correlationKey ? { correlationKey } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
case "USER_TASK": {
|
|
227
|
+
// `userTaskKey` is the required identity of a USER_TASK park; without it the row is
|
|
228
|
+
// malformed. Skip it (like the missing-identity guards above) rather than emit a typed
|
|
229
|
+
// wait state carrying an empty key that would break a downstream join.
|
|
230
|
+
const userTaskKey = presentEngineKey(details.taskKey ?? details.userTaskKey);
|
|
231
|
+
if (userTaskKey === undefined) {
|
|
232
|
+
log("warn", "skipping USER_TASK wait state with no userTaskKey in engine response", {
|
|
233
|
+
elementInstanceKey,
|
|
234
|
+
});
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
return { ...base, waitStateType, userTaskKey };
|
|
238
|
+
}
|
|
239
|
+
case "SIGNAL": {
|
|
240
|
+
// `signalName` is the required discriminator of a SIGNAL park.
|
|
241
|
+
const signalName = presentText(details.signalName);
|
|
242
|
+
if (signalName === undefined) {
|
|
243
|
+
log("warn", "skipping SIGNAL wait state with no signalName in engine response", {
|
|
244
|
+
elementInstanceKey,
|
|
245
|
+
});
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
return { ...base, waitStateType, signalName };
|
|
249
|
+
}
|
|
250
|
+
case "TIMER":
|
|
251
|
+
return { ...base, waitStateType };
|
|
252
|
+
case "CONDITION":
|
|
253
|
+
return { ...base, waitStateType };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
51
256
|
/**
|
|
52
257
|
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
53
258
|
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
|
@@ -215,6 +420,70 @@ export class SdkEngineClient {
|
|
|
215
420
|
return [{ processInstanceKey: String(key), state }];
|
|
216
421
|
});
|
|
217
422
|
}
|
|
423
|
+
async searchElementInstances(filter) {
|
|
424
|
+
// Mirror `searchProcessInstances`: build the engine filter from the transport-agnostic
|
|
425
|
+
// selectors, read at zero-wait consistency (an element-instance search is eventually
|
|
426
|
+
// consistent), and map each row through the single `mapElementInstanceRow` gate — which
|
|
427
|
+
// also `searchElementInstances`/`getElementInstance` share — dropping malformed rows.
|
|
428
|
+
const f = {};
|
|
429
|
+
if (filter?.processInstanceKey)
|
|
430
|
+
f.processInstanceKey = filter.processInstanceKey;
|
|
431
|
+
if (filter?.elementId)
|
|
432
|
+
f.elementId = filter.elementId;
|
|
433
|
+
if (filter?.state)
|
|
434
|
+
f.state = filter.state;
|
|
435
|
+
const body = await this.client.searchElementInstances({ filter: f }, { consistency: { waitUpToMs: 0 } });
|
|
436
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
437
|
+
return items.flatMap((it) => {
|
|
438
|
+
const mapped = mapElementInstanceRow(it, this.log);
|
|
439
|
+
return mapped ? [mapped] : [];
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async searchElementInstanceWaitStates(filter) {
|
|
443
|
+
// The wait-states search surfaces *every* park (job/message/timer/signal/condition/user
|
|
444
|
+
// task), not just user tasks. Same zero-wait read + per-row mapping-gate shape as
|
|
445
|
+
// `searchElementInstances`; the SDK nests park-specific fields under `details`, which
|
|
446
|
+
// `mapElementInstanceWaitStateRow` unwraps.
|
|
447
|
+
const f = {};
|
|
448
|
+
if (filter?.processInstanceKey)
|
|
449
|
+
f.processInstanceKey = filter.processInstanceKey;
|
|
450
|
+
if (filter?.elementId)
|
|
451
|
+
f.elementId = filter.elementId;
|
|
452
|
+
if (filter?.waitStateType)
|
|
453
|
+
f.waitStateType = filter.waitStateType;
|
|
454
|
+
const body = await this.client.searchElementInstanceWaitStates({ filter: f }, { consistency: { waitUpToMs: 0 } });
|
|
455
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
456
|
+
return items.flatMap((it) => {
|
|
457
|
+
const mapped = mapElementInstanceWaitStateRow(it, this.log);
|
|
458
|
+
return mapped ? [mapped] : [];
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
async getElementInstance(elementInstanceKey) {
|
|
462
|
+
// A blank key can never address an element instance — short-circuit to null rather than
|
|
463
|
+
// issue a `GET /element-instances/` with an empty segment. Normalize the key up front (as
|
|
464
|
+
// `getForm` resolves a normalized identifier before fetching) so a padded-but-valid key
|
|
465
|
+
// like `" 5 "` addresses the same element instance rather than 404ing on the raw segment.
|
|
466
|
+
if (typeof elementInstanceKey !== "string" || elementInstanceKey.trim() === "") {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const key = elementInstanceKey.trim();
|
|
470
|
+
let body;
|
|
471
|
+
try {
|
|
472
|
+
body = await this.client.getElementInstance({ elementInstanceKey: key }, { consistency: { waitUpToMs: 0 } });
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
// A 404 (no such element instance) is an expected "not found", not a fault — mirror
|
|
476
|
+
// `getForm`, which treats a failed fetch as absence (null) rather than propagating.
|
|
477
|
+
this.log("warn", "getElementInstance: engine fetch failed", {
|
|
478
|
+
elementInstanceKey: key,
|
|
479
|
+
err: err instanceof Error ? err.message : String(err),
|
|
480
|
+
});
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
if (!isRecord(body))
|
|
484
|
+
return null;
|
|
485
|
+
return mapElementInstanceRow(body, this.log) ?? null;
|
|
486
|
+
}
|
|
218
487
|
async registerWorker(jobType, handler, options) {
|
|
219
488
|
const worker = this.client.createJobWorker({
|
|
220
489
|
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, 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, 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.83.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",
|
|
@@ -2769,7 +2769,11 @@ function renderAppView(node) {
|
|
|
2769
2769
|
});
|
|
2770
2770
|
|
|
2771
2771
|
const section = el("section", { class: "pc-appview" + (fill ? " pc-appview-fill" : "") });
|
|
2772
|
-
|
|
2772
|
+
// The title is an <h2> (the tag EVERY card renderer uses and makeCollapsible lifts into the
|
|
2773
|
+
// collapse header), not a <div>: a collapsible appView's title must be lifted out of the body,
|
|
2774
|
+
// otherwise it double-renders (header label + leftover in-body title). The .pc-appview-title CSS
|
|
2775
|
+
// is class-based, so a non-collapsible appView renders identically.
|
|
2776
|
+
if (title) section.append(el("h2", { class: "pc-appview-title" }, title));
|
|
2773
2777
|
section.append(frame);
|
|
2774
2778
|
return section;
|
|
2775
2779
|
}
|
|
@@ -157,14 +157,29 @@ test("#416: renderAppView mounts a base-relative iframe honoring embed/title/fil
|
|
|
157
157
|
assert.equal(frame.getAttribute("title"), "Agent networks");
|
|
158
158
|
assert.equal(frame.className, "pc-appview-frame");
|
|
159
159
|
|
|
160
|
-
// The title also renders as a visible label above the frame.
|
|
160
|
+
// The title also renders as a visible label above the frame — as an <h2> (see below).
|
|
161
161
|
const label = section.children.find(
|
|
162
162
|
(c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
|
|
163
163
|
);
|
|
164
164
|
assert.ok(label, "the title renders as a label");
|
|
165
|
+
assert.equal(label.tagName, "H2", "the appView title must be an <h2> so makeCollapsible lifts it (no duplicate title when collapsible)");
|
|
165
166
|
assert.equal(label.textContent, "Agent networks");
|
|
166
167
|
});
|
|
167
168
|
|
|
169
|
+
test("#471: the appView title is an <h2> so a collapsible appView doesn't double-render it", (t) => {
|
|
170
|
+
// makeCollapsible lifts a card's title out of the body into the collapse header by finding the
|
|
171
|
+
// renderer's <h2> (card.querySelector('h2')). Every card renderer emits an <h2>; renderAppView
|
|
172
|
+
// used to emit a <div class=pc-appview-title>, so the lift missed it and a collapsible appView
|
|
173
|
+
// showed its title twice (header label + leftover in-body div). Pin the tag so that can't regress.
|
|
174
|
+
t.after(installFakeDom());
|
|
175
|
+
const section = asFake(renderAppView({ type: "appView", props: { title: "Staged proposals", embed: "./e.html" } }));
|
|
176
|
+
const label = section.children.find(
|
|
177
|
+
(c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
|
|
178
|
+
);
|
|
179
|
+
assert.ok(label, "the appView renders a title label");
|
|
180
|
+
assert.equal(label.tagName, "H2", "renderAppView must emit its title as the <h2> makeCollapsible lifts");
|
|
181
|
+
});
|
|
182
|
+
|
|
168
183
|
test("#416: renderAppView rebases a root-absolute embed onto the mount root", (t) => {
|
|
169
184
|
t.after(installFakeDom());
|
|
170
185
|
// A root-absolute "/embed.html" would escape the console proxy prefix and hit the
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -261,6 +261,104 @@ export interface UserTaskFilter {
|
|
|
261
261
|
candidateGroup?: string;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* An element instance's lifecycle state, as the engine reports it on a
|
|
266
|
+
* `/v2/element-instances/search` result. Mirrors the engine's `ElementInstanceStateEnum`:
|
|
267
|
+
* `ACTIVE` (a token is currently *at* this element), `COMPLETED` (the token has left it), or
|
|
268
|
+
* `TERMINATED` (the element was interrupted). Structurally the same state set as
|
|
269
|
+
* {@link ProcessInstanceState} (one active state plus the `COMPLETED`/`TERMINATED` terminals),
|
|
270
|
+
* but a distinct concept — element instances are the tokens *within* a process instance — so it
|
|
271
|
+
* is kept as its own type rather than aliased.
|
|
272
|
+
*/
|
|
273
|
+
export type ElementInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A single element instance ("token") as {@link EngineClient.searchElementInstances} and
|
|
277
|
+
* {@link EngineClient.getElementInstance} report it — the finest-grained "how far has this
|
|
278
|
+
* process instance progressed" signal, below the process-instance lifecycle
|
|
279
|
+
* ({@link ProcessInstanceSnapshot}) and independent of whether the element is a user task
|
|
280
|
+
* ({@link UserTaskSummary}). It surfaces active *non-user-task* elements (a service task, a
|
|
281
|
+
* gateway, a catch event) a user-task search cannot see — "the furthest element reached".
|
|
282
|
+
* Carries the element instance's own key, its identity (`elementId`, its BPMN `elementType`
|
|
283
|
+
* when the engine reports it), the owning process instance, and its lifecycle `state`.
|
|
284
|
+
*/
|
|
285
|
+
export interface ElementInstanceSummary {
|
|
286
|
+
readonly elementInstanceKey: string;
|
|
287
|
+
readonly processInstanceKey: string;
|
|
288
|
+
readonly elementId: string;
|
|
289
|
+
/** The BPMN element type (e.g. `"SERVICE_TASK"`, `"USER_TASK"`,
|
|
290
|
+
* `"INTERMEDIATE_CATCH_EVENT"`), when the engine reports it. Kept transport-agnostic (a
|
|
291
|
+
* bare string) so a new BPMN element type on the engine flows through without having to be
|
|
292
|
+
* enumerated here. Absent when the adapter's read model does not carry it. */
|
|
293
|
+
readonly elementType?: string;
|
|
294
|
+
readonly state: ElementInstanceState;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
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.
|
|
303
|
+
*/
|
|
304
|
+
export type WaitStateType =
|
|
305
|
+
| "JOB"
|
|
306
|
+
| "MESSAGE"
|
|
307
|
+
| "USER_TASK"
|
|
308
|
+
| "TIMER"
|
|
309
|
+
| "SIGNAL"
|
|
310
|
+
| "CONDITION";
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A single parked (waiting) element instance as
|
|
314
|
+
* {@link EngineClient.searchElementInstanceWaitStates} reports it. Every variant carries the
|
|
315
|
+
* element instance's identity; the `waitStateType` discriminates the park-specific fields — a
|
|
316
|
+
* `JOB` carries the `jobType`/`jobKey` it is parked on, a `MESSAGE` the awaited
|
|
317
|
+
* `messageName`/`correlationKey`, a `USER_TASK` the `userTaskKey`, a `SIGNAL` the
|
|
318
|
+
* `signalName` — so a caller narrows on `waitStateType` to reach them type-safely rather than
|
|
319
|
+
* probing a flat optional-field bag.
|
|
320
|
+
*/
|
|
321
|
+
export type ElementInstanceWaitState =
|
|
322
|
+
& {
|
|
323
|
+
readonly elementInstanceKey: string;
|
|
324
|
+
readonly processInstanceKey: string;
|
|
325
|
+
readonly elementId: string;
|
|
326
|
+
readonly elementType?: string;
|
|
327
|
+
}
|
|
328
|
+
& (
|
|
329
|
+
| { readonly waitStateType: "JOB"; readonly jobType: string; readonly jobKey?: string }
|
|
330
|
+
| {
|
|
331
|
+
readonly waitStateType: "MESSAGE";
|
|
332
|
+
readonly messageName: string;
|
|
333
|
+
readonly correlationKey?: string;
|
|
334
|
+
}
|
|
335
|
+
| { readonly waitStateType: "USER_TASK"; readonly userTaskKey: string }
|
|
336
|
+
| { readonly waitStateType: "TIMER" }
|
|
337
|
+
| { readonly waitStateType: "SIGNAL"; readonly signalName: string }
|
|
338
|
+
| { readonly waitStateType: "CONDITION" }
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Selectors for an element-instance search: which process instance, element, and/or lifecycle
|
|
343
|
+
* state to match. Used by {@link EngineClient.searchElementInstances}.
|
|
344
|
+
*/
|
|
345
|
+
export interface ElementInstanceFilter {
|
|
346
|
+
processInstanceKey?: string;
|
|
347
|
+
elementId?: string;
|
|
348
|
+
state?: ElementInstanceState;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Selectors for an element-instance *wait-state* search: which process instance, element,
|
|
353
|
+
* and/or kind of wait to match. Used by {@link EngineClient.searchElementInstanceWaitStates}.
|
|
354
|
+
* There is deliberately no lifecycle-`state` selector — a wait state is always an active park.
|
|
355
|
+
*/
|
|
356
|
+
export interface ElementInstanceWaitStateFilter {
|
|
357
|
+
processInstanceKey?: string;
|
|
358
|
+
elementId?: string;
|
|
359
|
+
waitStateType?: WaitStateType;
|
|
360
|
+
}
|
|
361
|
+
|
|
264
362
|
/**
|
|
265
363
|
* A deployed form's form-js schema plus its identifying metadata, as
|
|
266
364
|
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
@@ -350,6 +448,41 @@ export interface EngineClient {
|
|
|
350
448
|
processInstanceKeys?: string[];
|
|
351
449
|
state?: ProcessInstanceState;
|
|
352
450
|
}): Promise<ProcessInstanceSnapshot[]>;
|
|
451
|
+
/**
|
|
452
|
+
* Search element instances ("tokens") by process instance, element, and/or lifecycle state
|
|
453
|
+
* (`POST /v2/element-instances/search`). Where {@link searchProcessInstances} answers "is
|
|
454
|
+
* this instance still running" and {@link searchUserTasks} answers "what user tasks are
|
|
455
|
+
* open", this answers "what element has a token reached" — including the active
|
|
456
|
+
* *non-user-task* elements (a service task, a gateway, a catch event) that a user-task
|
|
457
|
+
* search cannot see, i.e. "the furthest element reached". An eventually consistent
|
|
458
|
+
* (zero-wait) read; each {@link ElementInstanceSummary} carries the element instance's key,
|
|
459
|
+
* its `elementId`/`elementType`, the owning process instance, and its `state`.
|
|
460
|
+
*/
|
|
461
|
+
searchElementInstances(
|
|
462
|
+
filter?: ElementInstanceFilter,
|
|
463
|
+
): Promise<ElementInstanceSummary[]>;
|
|
464
|
+
/**
|
|
465
|
+
* Search the *wait states* of element instances
|
|
466
|
+
* (`POST /v2/element-instances/wait-states/search`) — every park, not only user tasks. A
|
|
467
|
+
* `JOB` park (a service task awaiting a worker), a `MESSAGE` park (an event awaiting
|
|
468
|
+
* correlation), a `TIMER`/`SIGNAL`/`CONDITION`, plus `USER_TASK`. A zero-wait read; each
|
|
469
|
+
* result is discriminated by `waitStateType` (see {@link ElementInstanceWaitState}), so a
|
|
470
|
+
* consumer can read the job/message parks a {@link searchUserTasks} cannot surface.
|
|
471
|
+
*/
|
|
472
|
+
searchElementInstanceWaitStates(
|
|
473
|
+
filter?: ElementInstanceWaitStateFilter,
|
|
474
|
+
): Promise<ElementInstanceWaitState[]>;
|
|
475
|
+
/**
|
|
476
|
+
* Fetch a single element instance by its key (`GET /v2/element-instances/{elementInstanceKey}`),
|
|
477
|
+
* or `null` when it cannot be resolved — the key is blank, no such element instance exists
|
|
478
|
+
* (a 404), or the fetch otherwise fails. A read is treated as absence rather than propagating
|
|
479
|
+
* (mirroring {@link getForm}), so a caller distinguishes "have it" from "don't" without a
|
|
480
|
+
* try/catch. A zero-wait read returning the same {@link ElementInstanceSummary} shape as
|
|
481
|
+
* {@link searchElementInstances}.
|
|
482
|
+
*/
|
|
483
|
+
getElementInstance(
|
|
484
|
+
elementInstanceKey: string,
|
|
485
|
+
): Promise<ElementInstanceSummary | null>;
|
|
353
486
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
354
487
|
registerWorker(
|
|
355
488
|
jobType: string,
|
|
@@ -384,6 +517,9 @@ export const ENGINE_CLIENT_METHODS = [
|
|
|
384
517
|
"getForm",
|
|
385
518
|
"completeUserTask",
|
|
386
519
|
"searchProcessInstances",
|
|
520
|
+
"searchElementInstances",
|
|
521
|
+
"searchElementInstanceWaitStates",
|
|
522
|
+
"getElementInstance",
|
|
387
523
|
"registerWorker",
|
|
388
524
|
"close",
|
|
389
525
|
] as const satisfies readonly (keyof EngineClient)[];
|
|
@@ -33,6 +33,9 @@ const engine: EngineClient = {
|
|
|
33
33
|
getForm: async () => null,
|
|
34
34
|
completeUserTask: async () => {},
|
|
35
35
|
searchProcessInstances: async () => [],
|
|
36
|
+
searchElementInstances: async () => [],
|
|
37
|
+
searchElementInstanceWaitStates: async () => [],
|
|
38
|
+
getElementInstance: async () => null,
|
|
36
39
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
37
40
|
close: async () => {},
|
|
38
41
|
};
|
|
@@ -18,6 +18,9 @@ const fakeEngine: EngineClient = {
|
|
|
18
18
|
getForm: async () => null,
|
|
19
19
|
completeUserTask: async () => {},
|
|
20
20
|
searchProcessInstances: async () => [],
|
|
21
|
+
searchElementInstances: async () => [],
|
|
22
|
+
searchElementInstanceWaitStates: async () => [],
|
|
23
|
+
getElementInstance: async () => null,
|
|
21
24
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
22
25
|
close: async () => {},
|
|
23
26
|
};
|
|
@@ -31,6 +31,9 @@ const engine: EngineClient = {
|
|
|
31
31
|
openUserTasks: async () => [],
|
|
32
32
|
getForm: async () => null,
|
|
33
33
|
searchProcessInstances: async () => [],
|
|
34
|
+
searchElementInstances: async () => [],
|
|
35
|
+
searchElementInstanceWaitStates: async () => [],
|
|
36
|
+
getElementInstance: async () => null,
|
|
34
37
|
completeUserTask: async () => {},
|
|
35
38
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
36
39
|
close: async () => {},
|
|
@@ -85,6 +85,9 @@ function cancelEngine(opts: {
|
|
|
85
85
|
openUserTasks: notUsed("openUserTasks"),
|
|
86
86
|
getForm: notUsed("getForm"),
|
|
87
87
|
completeUserTask: notUsed("completeUserTask"),
|
|
88
|
+
searchElementInstances: notUsed("searchElementInstances"),
|
|
89
|
+
searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
|
|
90
|
+
getElementInstance: notUsed("getElementInstance"),
|
|
88
91
|
registerWorker: notUsed("registerWorker"),
|
|
89
92
|
close: notUsed("close"),
|
|
90
93
|
};
|
|
@@ -21,6 +21,9 @@ class MiniEngine implements EngineClient {
|
|
|
21
21
|
async getForm(): Promise<null> { return null; }
|
|
22
22
|
async completeUserTask(): Promise<void> {}
|
|
23
23
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
24
|
+
async searchElementInstances(): Promise<[]> { return []; }
|
|
25
|
+
async searchElementInstanceWaitStates(): Promise<[]> { return []; }
|
|
26
|
+
async getElementInstance(): Promise<null> { return null; }
|
|
24
27
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
25
28
|
this.workers.set(jobType, handler);
|
|
26
29
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -20,6 +20,9 @@ const noEngine: EngineClient = {
|
|
|
20
20
|
getForm: async () => null,
|
|
21
21
|
completeUserTask: async () => {},
|
|
22
22
|
searchProcessInstances: async () => [],
|
|
23
|
+
searchElementInstances: async () => [],
|
|
24
|
+
searchElementInstanceWaitStates: async () => [],
|
|
25
|
+
getElementInstance: async () => null,
|
|
23
26
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
24
27
|
close: async () => {},
|
|
25
28
|
};
|
|
@@ -90,6 +90,9 @@ function fakeEngine(
|
|
|
90
90
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
91
91
|
getForm: notUsed("getForm"),
|
|
92
92
|
completeUserTask: notUsed("completeUserTask"),
|
|
93
|
+
searchElementInstances: notUsed("searchElementInstances"),
|
|
94
|
+
searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
|
|
95
|
+
getElementInstance: notUsed("getElementInstance"),
|
|
93
96
|
registerWorker: notUsed("registerWorker"),
|
|
94
97
|
close: notUsed("close"),
|
|
95
98
|
};
|