@nanobpm/urban 0.10.0 → 0.11.1
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/README.md +32 -1
- package/dist/runtime/core/manifest.d.ts +19 -0
- package/dist/runtime/core/modules/cron.d.ts +27 -0
- package/dist/runtime/core/modules/cron.js +138 -0
- package/dist/runtime/core/modules/triggers.d.ts +43 -9
- package/dist/runtime/core/modules/triggers.js +181 -22
- package/dist/runtime/core/runtime.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +6 -1
- package/dist/runtime/engine/nanosdk.js +13 -4
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +23 -1
- package/src/runtime/core/modules/cron.test.ts +105 -0
- package/src/runtime/core/modules/cron.ts +150 -0
- package/src/runtime/core/modules/triggers.test.ts +269 -0
- package/src/runtime/core/modules/triggers.ts +207 -28
- package/src/runtime/core/runtime.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +32 -2
- package/src/runtime/engine/nanosdk.ts +18 -5
|
@@ -3,35 +3,77 @@
|
|
|
3
3
|
// message to the engine for correlation. HMAC verification uses Web Crypto (global in both
|
|
4
4
|
// Node 18+ and Deno), so this stays runtime-agnostic.
|
|
5
5
|
|
|
6
|
-
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
6
|
+
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
7
7
|
import type { HttpRequest } from "../host.ts";
|
|
8
8
|
import { json, normalizeRoutePath, type Route } from "../router.ts";
|
|
9
9
|
import type { TriggerDecl } from "../manifest.ts";
|
|
10
|
+
import { nextCronFire, parseCron } from "./cron.ts";
|
|
10
11
|
|
|
11
|
-
export interface TriggersHandle {
|
|
12
|
-
readonly name: string;
|
|
12
|
+
export interface TriggersHandle extends Mounted {
|
|
13
13
|
routes: Route[];
|
|
14
|
-
describe(): Record<string, unknown>;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
/** Injectable timer + clock seam so cron scheduling is deterministic under test. */
|
|
17
|
+
export interface SchedulerDeps {
|
|
18
|
+
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
19
|
+
clearTimer: (handle: unknown) => void;
|
|
20
|
+
/** Current wall-clock time in ms since epoch. */
|
|
21
|
+
now: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Scope = { body: unknown; headers: Record<string, string>; query: Record<string, string> };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
|
|
28
|
+
* returns the raw value at that path (or `undefined` if any segment is missing); a literal
|
|
29
|
+
* (non-`=`) string is returned verbatim.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveExpr(expr: string, scope: Scope): unknown {
|
|
23
32
|
const trimmed = expr.trim();
|
|
24
33
|
if (!trimmed.startsWith("=")) return trimmed; // literal
|
|
25
34
|
const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
|
|
26
35
|
let cur: unknown = scope;
|
|
27
36
|
for (const seg of pathExpr.split(".")) {
|
|
28
|
-
if (cur && typeof cur === "object" &&
|
|
37
|
+
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
|
|
29
38
|
cur = (cur as Record<string, unknown>)[seg];
|
|
30
39
|
} else {
|
|
31
40
|
return undefined;
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
|
-
return cur
|
|
43
|
+
return cur;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
|
|
47
|
+
export function evalCorrelation(expr: string | undefined, scope: Scope): string | undefined {
|
|
48
|
+
if (!expr) return undefined;
|
|
49
|
+
const cur = resolveExpr(expr, scope);
|
|
50
|
+
// A correlation key must be scalar; a non-scalar (object/array) is not a usable key —
|
|
51
|
+
// return undefined rather than forcing a "[object Object]" string.
|
|
52
|
+
if (typeof cur === "string" || typeof cur === "number" || typeof cur === "boolean") {
|
|
53
|
+
return String(cur);
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
|
|
60
|
+
* the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
|
|
61
|
+
* resolve to an object; a plain object literal is accepted for convenience; anything else
|
|
62
|
+
* (missing, or a non-object result) falls back to the raw event body when that is an object,
|
|
63
|
+
* else `{}`.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveActionVariables(
|
|
66
|
+
expr: unknown,
|
|
67
|
+
scope: Scope,
|
|
68
|
+
): Record<string, unknown> {
|
|
69
|
+
const asRecord = (v: unknown): Record<string, unknown> | undefined =>
|
|
70
|
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
|
71
|
+
|
|
72
|
+
if (typeof expr === "string") {
|
|
73
|
+
return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
|
|
74
|
+
}
|
|
75
|
+
// Back-compat: an inline object literal used directly as the variables.
|
|
76
|
+
return asRecord(expr) ?? asRecord(scope.body) ?? {};
|
|
35
77
|
}
|
|
36
78
|
|
|
37
79
|
async function verifyHmac(secret: string, body: string, signature: string): Promise<boolean> {
|
|
@@ -64,13 +106,146 @@ function headerRecord(req: HttpRequest): Record<string, string> {
|
|
|
64
106
|
return out;
|
|
65
107
|
}
|
|
66
108
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Fire a trigger's action against the engine: start a process or publish a message
|
|
111
|
+
* (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
|
|
112
|
+
* of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
|
|
113
|
+
* was done.
|
|
114
|
+
*/
|
|
115
|
+
export async function runTriggerAction(
|
|
116
|
+
app: AppApi,
|
|
117
|
+
action: TriggerDecl["action"],
|
|
118
|
+
scope: Scope,
|
|
119
|
+
): Promise<{ kind: "start" | "message" | "none"; target?: string; correlationKey?: string }> {
|
|
120
|
+
if (!action) return { kind: "none" };
|
|
121
|
+
const variables = resolveActionVariables(action.variables, scope);
|
|
122
|
+
|
|
123
|
+
if (action.start) {
|
|
124
|
+
await app.engine.createInstance({ processDefinitionId: action.start, variables });
|
|
125
|
+
return { kind: "start", target: action.start };
|
|
126
|
+
}
|
|
127
|
+
if (action.message) {
|
|
128
|
+
const correlationKey = evalCorrelation(action.correlationKey, scope);
|
|
129
|
+
await app.engine.publishMessage({ name: action.message, correlationKey, variables });
|
|
130
|
+
return { kind: "message", target: action.message, correlationKey };
|
|
131
|
+
}
|
|
132
|
+
return { kind: "none" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const emptyScope = { body: {}, headers: {} as Record<string, string>, query: {} as Record<string, string> };
|
|
136
|
+
|
|
137
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
138
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
139
|
+
|
|
140
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
141
|
+
function defaultScheduler(): SchedulerDeps {
|
|
142
|
+
return {
|
|
143
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
144
|
+
clearTimer: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
145
|
+
now: () => Date.now(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
150
|
+
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
151
|
+
export function mountTriggers(
|
|
152
|
+
ctx: RuntimeContext,
|
|
153
|
+
app: AppApi,
|
|
154
|
+
sched: SchedulerDeps = defaultScheduler(),
|
|
155
|
+
): TriggersHandle {
|
|
69
156
|
const routes: Route[] = [];
|
|
70
157
|
const mounted: string[] = [];
|
|
158
|
+
const scheduled: string[] = [];
|
|
71
159
|
const seenDeliveries = new Set<string>();
|
|
160
|
+
const timers = new Set<unknown>();
|
|
161
|
+
let stopped = false;
|
|
162
|
+
|
|
163
|
+
// Arm a cron trigger: compute the next UTC fire, sleep until it, fire the action, repeat.
|
|
164
|
+
const armCron = (trig: TriggerDecl): void => {
|
|
165
|
+
const spec = trig.spec;
|
|
166
|
+
if (!spec) {
|
|
167
|
+
ctx.host.log("warn", `trigger "${trig.id}": cron trigger has no spec, skipped`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
let schedule;
|
|
171
|
+
try {
|
|
172
|
+
schedule = parseCron(spec);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
app.log("error", `trigger "${trig.id}": invalid cron spec "${spec}": ${String(e)}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!trig.action?.start && !trig.action?.message) {
|
|
178
|
+
// A timer that can never do anything is pure waste — don't arm it (unlike a webhook,
|
|
179
|
+
// which is request-driven and costs nothing while idle).
|
|
180
|
+
app.log("warn", `trigger "${trig.id}": cron trigger has no action.start/message; not scheduled`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (trig.onMissed && trig.onMissed !== "skip") {
|
|
184
|
+
// once/all catch-up needs a durable last-fire timestamp the runtime does not persist;
|
|
185
|
+
// without it, honour the safe default (skip) and say so rather than silently dropping.
|
|
186
|
+
app.log("warn", `trigger "${trig.id}": onMissed="${trig.onMissed}" not supported without ` +
|
|
187
|
+
`durable trigger state; scheduling forward as "skip"`);
|
|
188
|
+
}
|
|
189
|
+
// Probe the first fire before recording the trigger as scheduled, so an impossible spec
|
|
190
|
+
// (e.g. Feb 30) is reported honestly and never shows up in describe()/startup logs.
|
|
191
|
+
if (!nextCronFire(schedule, new Date(sched.now()))) {
|
|
192
|
+
app.log("warn", `trigger "${trig.id}": cron spec "${spec}" never fires; not scheduled`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
scheduled.push(`${trig.id}@${spec}`);
|
|
196
|
+
|
|
197
|
+
const arm = (): void => {
|
|
198
|
+
if (stopped) return;
|
|
199
|
+
// Capture the clock once so `next` and `delay` are derived from the same instant.
|
|
200
|
+
const nowMs = sched.now();
|
|
201
|
+
const next = nextCronFire(schedule, new Date(nowMs));
|
|
202
|
+
if (!next) return; // spec became unsatisfiable (defensive; the probe above already vetted it)
|
|
203
|
+
const remaining = Math.max(0, next.getTime() - nowMs);
|
|
204
|
+
|
|
205
|
+
// setTimeout delays are clamped to a 32-bit signed int (~24.8 days); a larger delay
|
|
206
|
+
// overflows and fires immediately. For far-future fires (monthly/annual specs), sleep in
|
|
207
|
+
// bounded chunks and re-arm — without firing — until the real deadline is within range.
|
|
208
|
+
if (remaining > MAX_TIMER_DELAY_MS) {
|
|
209
|
+
let chunk: unknown;
|
|
210
|
+
chunk = sched.setTimer(() => {
|
|
211
|
+
timers.delete(chunk);
|
|
212
|
+
if (stopped) return;
|
|
213
|
+
arm();
|
|
214
|
+
}, MAX_TIMER_DELAY_MS);
|
|
215
|
+
timers.add(chunk);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Declare `handle` before arming so the callback can reference it even if a scheduler
|
|
220
|
+
// double invokes it synchronously (avoids a TDZ ReferenceError on `timers.delete`).
|
|
221
|
+
let handle: unknown;
|
|
222
|
+
handle = sched.setTimer(() => {
|
|
223
|
+
timers.delete(handle);
|
|
224
|
+
if (stopped) return;
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const res = await runTriggerAction(app, trig.action, {
|
|
228
|
+
...emptyScope,
|
|
229
|
+
body: { firedAt: next.toISOString() },
|
|
230
|
+
});
|
|
231
|
+
app.log("info", `trigger "${trig.id}" fired`, { firedAt: next.toISOString(), ...res });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
app.log("error", `trigger "${trig.id}": action failed`, { error: String(err) });
|
|
234
|
+
} finally {
|
|
235
|
+
arm(); // reschedule regardless of success (at-least-once, idempotent handlers)
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
}, remaining);
|
|
239
|
+
timers.add(handle);
|
|
240
|
+
};
|
|
241
|
+
arm();
|
|
242
|
+
};
|
|
72
243
|
|
|
73
244
|
for (const trig of (ctx.manifest.triggers ?? []) as TriggerDecl[]) {
|
|
245
|
+
if (trig.type === "cron") {
|
|
246
|
+
armCron(trig);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
74
249
|
if (trig.type !== "webhook") {
|
|
75
250
|
ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
|
|
76
251
|
continue;
|
|
@@ -117,27 +292,31 @@ export function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle
|
|
|
117
292
|
return json({ error: "body must be JSON" }, 400);
|
|
118
293
|
}
|
|
119
294
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return json({ ok: true, published: false });
|
|
295
|
+
if (!trig.action?.message && !trig.action?.start) {
|
|
296
|
+
app.log("warn", `trigger "${trig.id}": no action.message/start; nothing to do`);
|
|
297
|
+
return json({ ok: true, fired: false });
|
|
124
298
|
}
|
|
125
|
-
const
|
|
299
|
+
const res = await runTriggerAction(app, trig.action, {
|
|
126
300
|
body,
|
|
127
301
|
headers,
|
|
128
302
|
query: Object.fromEntries(req.query),
|
|
129
303
|
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
variables: (body && typeof body === "object" ? (body as Record<string, unknown>) : {}),
|
|
134
|
-
});
|
|
135
|
-
app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
|
|
136
|
-
return json({ ok: true, published: true, message, correlationKey });
|
|
304
|
+
// `res.kind` is the honest verb (start | message); don't hard-code "published".
|
|
305
|
+
app.log("info", `trigger "${trig.id}" fired`, { ...res });
|
|
306
|
+
return json({ ok: true, fired: true, ...res });
|
|
137
307
|
},
|
|
138
308
|
});
|
|
139
309
|
}
|
|
140
310
|
|
|
141
|
-
ctx.host.log("info", "triggers mounted", { mounted });
|
|
142
|
-
return {
|
|
311
|
+
ctx.host.log("info", "triggers mounted", { mounted, scheduled });
|
|
312
|
+
return {
|
|
313
|
+
name: "triggers",
|
|
314
|
+
routes,
|
|
315
|
+
describe: () => ({ mounted, scheduled }),
|
|
316
|
+
async stop() {
|
|
317
|
+
stopped = true;
|
|
318
|
+
for (const h of timers) sched.clearTimer(h);
|
|
319
|
+
timers.clear();
|
|
320
|
+
},
|
|
321
|
+
};
|
|
143
322
|
}
|
|
@@ -180,7 +180,8 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
180
180
|
if (flags.triggers) {
|
|
181
181
|
const t = mountTriggers(ctx, api);
|
|
182
182
|
routes.push(...t.routes);
|
|
183
|
-
|
|
183
|
+
mounted.push(t);
|
|
184
|
+
describe.triggers = t.describe?.();
|
|
184
185
|
}
|
|
185
186
|
if (security) describe.security = security.describe();
|
|
186
187
|
if (data) describe.data = data.describe();
|
|
@@ -81,6 +81,9 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
|
|
|
81
81
|
rec.stopped++;
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
|
+
// The real nano-sdk starts the worker itself after async transport detection
|
|
85
|
+
// (autoStart defaults on). Model that here so the adapter must NOT call start().
|
|
86
|
+
if (cfg.autoStart !== false) worker.start();
|
|
84
87
|
return worker;
|
|
85
88
|
},
|
|
86
89
|
close() {
|
|
@@ -198,7 +201,7 @@ test("completeUserTask routes through the SDK", async () => {
|
|
|
198
201
|
assert.deepEqual(seen, { userTaskKey: "utk", variables: { done: true } });
|
|
199
202
|
});
|
|
200
203
|
|
|
201
|
-
test("registerWorker creates a worker
|
|
204
|
+
test("registerWorker creates a worker the SDK auto-starts, and dispatches + completes", async () => {
|
|
202
205
|
const client = fakeSdkClient();
|
|
203
206
|
const engine = new SdkEngineClient(client);
|
|
204
207
|
const handled: Record<string, unknown>[] = [];
|
|
@@ -212,7 +215,9 @@ test("registerWorker creates a worker (autoStart false), starts it, and dispatch
|
|
|
212
215
|
);
|
|
213
216
|
assert.equal(sub.jobType, "svc");
|
|
214
217
|
const rec = client.workers[0];
|
|
215
|
-
|
|
218
|
+
// The adapter must not force autoStart off or call start() itself (that races the
|
|
219
|
+
// SDK's async transport detection); the SDK owns the start lifecycle.
|
|
220
|
+
assert.equal(rec.cfg.autoStart, undefined);
|
|
216
221
|
assert.equal(rec.cfg.workerName, "urban:svc");
|
|
217
222
|
assert.equal(rec.cfg.maxParallelJobs, 4);
|
|
218
223
|
assert.equal(rec.started, 1);
|
|
@@ -272,6 +277,31 @@ test("close stops every worker and closes the SDK client", async () => {
|
|
|
272
277
|
assert.equal(client.closed, 1);
|
|
273
278
|
});
|
|
274
279
|
|
|
280
|
+
test("close drains the REST-fallback worker via the SDK's stopAllWorkers", async () => {
|
|
281
|
+
let stopAllCalls = 0;
|
|
282
|
+
const client = fakeSdkClient({
|
|
283
|
+
async stopAllWorkers() {
|
|
284
|
+
stopAllCalls++;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
const engine = new SdkEngineClient(client);
|
|
288
|
+
await engine.registerWorker("a", async () => ({}));
|
|
289
|
+
await engine.close();
|
|
290
|
+
// The SDK may start a REST-fallback worker whose handle we never receive;
|
|
291
|
+
// close() must reach it through the client's stopAllWorkers().
|
|
292
|
+
assert.equal(stopAllCalls, 1, "close awaits client.stopAllWorkers exactly once");
|
|
293
|
+
assert.equal(client.closed, 1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("close tolerates a client without stopAllWorkers", async () => {
|
|
297
|
+
const client = fakeSdkClient();
|
|
298
|
+
delete (client as { stopAllWorkers?: unknown }).stopAllWorkers;
|
|
299
|
+
const engine = new SdkEngineClient(client);
|
|
300
|
+
await engine.registerWorker("a", async () => ({}));
|
|
301
|
+
await engine.close();
|
|
302
|
+
assert.equal(client.closed, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
275
305
|
test("exposes the underlying nano-sdk client as .sdk", () => {
|
|
276
306
|
const client = fakeSdkClient();
|
|
277
307
|
const engine = new SdkEngineClient(client);
|
|
@@ -49,7 +49,8 @@ export interface NanoSdkJobWorkerConfig {
|
|
|
49
49
|
jobTimeoutMs?: number;
|
|
50
50
|
pollTimeoutMs?: number;
|
|
51
51
|
fetchVariables?: string[];
|
|
52
|
-
/** Start polling immediately.
|
|
52
|
+
/** Start polling immediately (the SDK default). The adapter leaves this unset so the
|
|
53
|
+
* SDK owns the start lifecycle after its async Nano/Falcon transport detection. */
|
|
53
54
|
autoStart?: boolean;
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -97,6 +98,10 @@ export interface NanoSdkClient {
|
|
|
97
98
|
options?: unknown,
|
|
98
99
|
): Promise<unknown>;
|
|
99
100
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
101
|
+
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
102
|
+
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
103
|
+
* and does not hand back. Optional: not every injected client implements it. */
|
|
104
|
+
stopAllWorkers?(): void | Promise<void>;
|
|
100
105
|
close?(): void | Promise<void>;
|
|
101
106
|
}
|
|
102
107
|
|
|
@@ -210,9 +215,6 @@ export class SdkEngineClient implements EngineClient {
|
|
|
210
215
|
workerName: options?.workerName ?? `urban:${jobType}`,
|
|
211
216
|
maxParallelJobs: options?.maxParallelJobs ?? 8,
|
|
212
217
|
fetchVariables: options?.fetchVariables,
|
|
213
|
-
// Own the poll lifecycle here rather than leaving it to the SDK's auto-start
|
|
214
|
-
// default, so nothing polls until `start()` is called below.
|
|
215
|
-
autoStart: false,
|
|
216
218
|
jobHandler: async (job) => {
|
|
217
219
|
const rawKey = job.jobKey;
|
|
218
220
|
if (rawKey == null || rawKey === "") {
|
|
@@ -244,7 +246,11 @@ export class SdkEngineClient implements EngineClient {
|
|
|
244
246
|
}
|
|
245
247
|
},
|
|
246
248
|
});
|
|
247
|
-
|
|
249
|
+
// The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
|
|
250
|
+
// async Nano/Falcon detection and, once the transport is bound, starts polling
|
|
251
|
+
// itself (Falcon) or hands off to an auto-started REST worker. Calling `start()`
|
|
252
|
+
// here would race that detection and dereference a still-null transport, so we
|
|
253
|
+
// don't — the worker autostarts.
|
|
248
254
|
this.workers.add(worker);
|
|
249
255
|
|
|
250
256
|
return {
|
|
@@ -269,6 +275,13 @@ export class SdkEngineClient implements EngineClient {
|
|
|
269
275
|
async close(): Promise<void> {
|
|
270
276
|
for (const w of this.workers) await this.stopWorker(w);
|
|
271
277
|
this.workers.clear();
|
|
278
|
+
try {
|
|
279
|
+
// Also drain any REST-fallback worker the SDK started internally (its handle is
|
|
280
|
+
// not the one we hold), which `stopWorker` above cannot reach.
|
|
281
|
+
await this.client.stopAllWorkers?.();
|
|
282
|
+
} catch {
|
|
283
|
+
/* no workers running / already stopped */
|
|
284
|
+
}
|
|
272
285
|
try {
|
|
273
286
|
await this.client.close?.();
|
|
274
287
|
} catch {
|