c8ctl-plugin-nano 1.52.0 → 1.53.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.52.0",
3
+ "version": "1.53.1",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -39,7 +39,7 @@
39
39
  "build:supervisor": "node supervisor/build.mjs",
40
40
  "vendor:effect": "node scripts/vendor-effect.mjs",
41
41
  "test:supervisor": "node --experimental-strip-types --test supervisor/test/*.test.ts",
42
- "test": "node --check c8ctl-plugin.js && npm run typecheck:supervisor && npm run build:supervisor && npm run test:supervisor && node --test",
42
+ "test": "node --check c8ctl-plugin.js && npm run typecheck:supervisor && npm run build:supervisor && npm run test:supervisor && node --test --test-timeout=120000 --test-force-exit",
43
43
  "prepublishOnly": "npm run typecheck:supervisor && npm run build:supervisor"
44
44
  },
45
45
  "license": "MIT",
@@ -60,7 +60,7 @@
60
60
  "@semantic-release/github": "^12.0.6",
61
61
  "@types/node": "^22.20.1",
62
62
  "effect": "4.0.0-rc.112",
63
- "esbuild": "^0.24.2",
63
+ "esbuild": "^0.28.0",
64
64
  "semantic-release": "^25.0.3",
65
65
  "typescript": "^5.9.3"
66
66
  },
@@ -70,12 +70,12 @@
70
70
  },
71
71
  "optionalDependencies": {
72
72
  "node-pty": "^1.0.0",
73
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.52.0",
74
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.52.0",
75
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.52.0",
76
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.52.0",
77
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.52.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.52.0",
79
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.52.0"
73
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.53.1",
74
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.53.1",
75
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.53.1",
76
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.53.1",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.53.1",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.53.1",
79
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.53.1"
80
80
  }
81
81
  }
@@ -4,19 +4,45 @@
4
4
  * (issue #156).
5
5
  *
6
6
  * #154's activation loop deliberately does NOT use the `@camunda8` SDK job
7
- * worker: the SDK models one poller per type with `maxJobsToActivate =
8
- * maxParallel − active` and structurally cannot express "global capacity S
9
- * shared across K types with per-type gating". The supervisor rolls its own race
10
- * over a NARROW two-call engine surface instead:
7
+ * worker (`createJobWorker`): that convenience loop models one self-driving
8
+ * poller per type with `maxJobsToActivate = maxParallel − active` and cannot
9
+ * express "global capacity S shared across K types with per-type gating". The
10
+ * supervisor rolls its own race over a NARROW single-shot engine surface — but
11
+ * every call on that surface still PREFERS the injected `camunda` SDK client's
12
+ * typed method, hand-rolling REST only as a standalone/wire-test fallback:
11
13
  *
12
- * - `activate` → `POST <base>/v2/jobs/activation` for exactly ONE type, one
13
- * long-poll, resolving 0..`maxJobsToActivate` jobs (0 == the long-poll
14
- * expired empty). `timeout` is the SHORT initial lock (the crash-safety net);
15
- * `requestTimeout` is how long the call blocks server-side.
16
- * - `extendLock` `PATCH <base>/v2/jobs/{jobKey}/timeout` with `{ timeout }`.
14
+ * - `activate` → the SDK's typed `activateJobs` (operationId `activateJobs`
15
+ * `POST <base>/jobs/activation`) for exactly ONE type, one long-poll,
16
+ * resolving 0..`maxJobsToActivate` jobs (0 == the long-poll expired empty).
17
+ * `activateJobs` is the SINGLE-SHOT primitive `createJobWorker` is built on;
18
+ * its input body (`JobActivationRequest`) is 1:1 with ours and its returned
19
+ * `CancelablePromise.cancel()` aborts the in-flight long-poll, so a losing
20
+ * `raceAll` fiber interrupts its poll at once. Falls back to the SAME call
21
+ * issued raw via `fetchImpl` when no SDK client is injected. `timeout` is the
22
+ * SHORT initial lock (the crash-safety net); `requestTimeout` is how long the
23
+ * call blocks server-side.
24
+ * - `extendLock` → the SDK's typed `updateJob` (operationId `updateJob`,
25
+ * `PATCH <base>/jobs/{jobKey}` with `{ changeset: { timeout } }`) when a
26
+ * `camunda` SDK client is injected, else the SAME call issued raw via
27
+ * `fetchImpl` (so the module stays wire-testable without a live client).
17
28
  * The C8 contract SETs the lock to `ms` from now (a duration-from-now), which
18
29
  * is exactly the supervisor's "extend the winner to the recovery window, then
19
- * heartbeat" model — set, not accumulate.
30
+ * heartbeat" model — set, not accumulate. NOTE: there is no
31
+ * `/jobs/{jobKey}/timeout` sub-route — that was a drifted URL that 404s on the
32
+ * engine; the timeout is a `changeset` field on the job resource itself.
33
+ * - `complete` / `fail` → the SDK's typed `completeJob` / `failJob` (operationIds
34
+ * `completeJob` → `POST <base>/jobs/{jobKey}/completion`, `failJob` →
35
+ * `POST <base>/jobs/{jobKey}/failure`) when a `camunda` SDK client is injected,
36
+ * else the SAME calls issued raw via `fetchImpl` — the settle surface the
37
+ * supervisor's JobRunner uses once an agent finishes.
38
+ *
39
+ * Every method on this surface (`activate`→`activateJobs`,
40
+ * `extendLock`→`updateJob`, `complete`→`completeJob`, `fail`→`failJob`) PREFERS
41
+ * the injected `camunda` client and only hand-rolls the REST call as a
42
+ * standalone/wire-test fallback; there is no sanctioned raw-only method. This
43
+ * convention is pinned by `supervisor-engine-sdk-preference.test.mjs` — a new
44
+ * engine method that hits a raw route must either prefer an SDK method or be
45
+ * added to that test's raw-only allowlist with a reason.
20
46
  *
21
47
  * This module is the raw-JS analogue of `agentic-endpoint.mjs`: it is Effect-free
22
48
  * (the supervisor's `makeEngineClient` lift wraps each method into the Effect
@@ -136,7 +162,8 @@ async function readErrorBody(res) {
136
162
  * @param {Record<string,string>|(() => (Record<string,string>|Promise<Record<string,string>>))} [opts.authHeaders] Ready-made auth header map, OR a resolver invoked per request (so rotating SDK auth — e.g. an OAuth bearer that refreshes — is re-derived each call rather than frozen). Wins over `token`.
137
163
  * @param {typeof fetch} [opts.fetchImpl] Injected `fetch` (defaults to the global; overridden in tests).
138
164
  * @param {number} [opts.requestTimeoutSlackMs] Extra ms added to a call's abort budget over its server long-poll (default 5000).
139
- * @returns {{ activate(req: ActivateRequest): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number): Promise<void>, complete(jobKey: string, variables?: object): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }): Promise<void> }}
165
+ * @param {{ activateJobs?: (input: { type: string, worker?: string, maxJobsToActivate: number, timeout: number, requestTimeout?: number }) => (Promise<{ jobs?: object[] }> & { cancel?: () => void }), updateJob?: (req: { jobKey: string, changeset: { timeout: number } }) => Promise<unknown>, completeJob?: (req: { jobKey: string, variables?: object }) => Promise<unknown>, failJob?: (req: { jobKey: string, retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }) => Promise<unknown> }} [opts.camunda] Optional Camunda SDK client. When present, each engine method prefers its typed SDK method over the raw fetch fallback: `activate`→`activateJobs` (`POST /v2/jobs/activation`, cancelled via the returned `CancelablePromise.cancel()`), `extendLock`→`updateJob` (`PATCH /v2/jobs/{jobKey}` `{ changeset: { timeout } }`), `complete`→`completeJob` (`POST /v2/jobs/{jobKey}/completion`), `fail`→`failJob` (`POST /v2/jobs/{jobKey}/failure`).
166
+ * @returns {{ activate(req: ActivateRequest, signal?: AbortSignal): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number): Promise<void>, complete(jobKey: string, variables?: object): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }): Promise<void> }}
140
167
  */
141
168
  export function createRawEngineClient(opts = {}) {
142
169
  const {
@@ -146,6 +173,7 @@ export function createRawEngineClient(opts = {}) {
146
173
  authHeaders,
147
174
  fetchImpl = fetch,
148
175
  requestTimeoutSlackMs = 5_000,
176
+ camunda,
149
177
  } = opts;
150
178
  if (typeof fetchImpl !== "function") {
151
179
  throw new TypeError("createRawEngineClient: `fetchImpl` must be a function (global fetch or an injected fake)");
@@ -172,34 +200,78 @@ export function createRawEngineClient(opts = {}) {
172
200
 
173
201
  /**
174
202
  * Issue one call with an abort budget. `abortAfterMs <= 0` means no timer
175
- * (the caller relies purely on the server long-poll / connection).
203
+ * (the caller relies purely on the server long-poll / connection). An optional
204
+ * `extSignal` (e.g. the Effect fiber's interruption signal for the `activate`
205
+ * long-poll) also aborts the in-flight fetch, so a losing `raceAll` fiber stops
206
+ * its poll immediately instead of leaking it until the budget timer fires.
176
207
  */
177
- async function call(url, init, abortAfterMs) {
208
+ async function call(url, init, abortAfterMs, extSignal) {
178
209
  const controller = new AbortController();
179
210
  const timer = abortAfterMs > 0 ? setTimeout(() => controller.abort(), abortAfterMs) : null;
211
+ const onExtAbort = () => controller.abort();
212
+ if (extSignal) {
213
+ if (extSignal.aborted) controller.abort();
214
+ else extSignal.addEventListener("abort", onExtAbort, { once: true });
215
+ }
180
216
  try {
181
217
  const headers = await resolveHeaders();
182
218
  return await fetchImpl(url, { ...init, headers, signal: controller.signal });
183
219
  } finally {
184
220
  if (timer) clearTimeout(timer);
221
+ if (extSignal) extSignal.removeEventListener("abort", onExtAbort);
185
222
  }
186
223
  }
187
224
 
188
225
  return {
189
- async activate(req) {
190
- const body = JSON.stringify({
226
+ async activate(req, signal) {
227
+ // Prefer the SDK's typed `activateJobs` (operationId `activateJobs` →
228
+ // `POST /v2/jobs/activation`) so the ONE-type long-poll tracks the engine
229
+ // contract instead of a hand-rolled body/URL. The SDK's `createJobWorker`
230
+ // convenience loop can't express the supervisor's global-capacity single
231
+ // race — but `activateJobs` is the SAME single-shot primitive that loop is
232
+ // built on, and its input body (`JobActivationRequest`) is 1:1 with ours.
233
+ // Its `CancelablePromise.cancel()` aborts the in-flight long-poll, so a
234
+ // losing `raceAll` fiber (interruption → `signal.aborted`) stops its poll
235
+ // immediately. Fall back to the SAME call issued raw via `fetchImpl` when no
236
+ // SDK client is injected (keeps this module wire-testable and standalone).
237
+ // `timeout` is the SHORT initial lock applied to any returned job;
238
+ // `requestTimeout` is the server-side long-poll window.
239
+ const input = {
191
240
  type: req.type,
192
241
  worker,
193
242
  maxJobsToActivate: req.maxJobsToActivate,
194
- // `timeout` is the SHORT initial lock applied to any returned job.
195
243
  timeout: req.lockMs,
196
- // `requestTimeout` is the server-side long-poll window.
197
244
  requestTimeout: req.requestTimeoutMs,
198
- });
245
+ };
199
246
  // Give the abort budget slack over the server long-poll so we don't cancel
200
247
  // a still-valid long-poll a hair before the server would answer it.
201
248
  const abortAfterMs = req.requestTimeoutMs > 0 ? req.requestTimeoutMs + requestTimeoutSlackMs : 0;
202
- const res = await call(`${base}/jobs/activation`, { method: "POST", body }, abortAfterMs);
249
+ if (camunda && typeof camunda.activateJobs === "function") {
250
+ const p = camunda.activateJobs(input);
251
+ // Wire BOTH cancellation sources onto the CancelablePromise: the external
252
+ // interruption signal (fiber lost the race) and the client-side abort
253
+ // budget (a hung connection past the server window). `cancel()` aborts the
254
+ // SDK's own AbortController, ending the long-poll.
255
+ const cancel = typeof p?.cancel === "function" ? () => p.cancel() : () => {};
256
+ const timer = abortAfterMs > 0 ? setTimeout(cancel, abortAfterMs) : null;
257
+ const onExtAbort = () => cancel();
258
+ if (signal) {
259
+ if (signal.aborted) cancel();
260
+ else signal.addEventListener("abort", onExtAbort, { once: true });
261
+ }
262
+ try {
263
+ const result = await p;
264
+ const jobs = Array.isArray(result?.jobs) ? result.jobs : Array.isArray(result) ? result : [];
265
+ return jobs.map(mapJob);
266
+ } catch (err) {
267
+ throw new Error(`activate ${req.type}: SDK activateJobs failed: ${err?.message ?? err}`, { cause: err });
268
+ } finally {
269
+ if (timer) clearTimeout(timer);
270
+ if (signal) signal.removeEventListener("abort", onExtAbort);
271
+ }
272
+ }
273
+ const body = JSON.stringify(input);
274
+ const res = await call(`${base}/jobs/activation`, { method: "POST", body }, abortAfterMs, signal);
203
275
  if (!res || !res.ok) {
204
276
  const status = res ? res.status : "?";
205
277
  throw new Error(`activate ${req.type}: HTTP ${status} from ${base}/jobs/activation${res ? await readErrorBody(res) : ""}`);
@@ -210,8 +282,21 @@ export function createRawEngineClient(opts = {}) {
210
282
  },
211
283
 
212
284
  async extendLock(jobKey, ms) {
213
- const url = `${base}/jobs/${encodeURIComponent(jobKey)}/timeout`;
214
- const res = await call(url, { method: "PATCH", body: JSON.stringify({ timeout: ms }) }, 15_000);
285
+ // Prefer the SDK's typed `updateJob` (operationId `updateJob` →
286
+ // `PATCH /v2/jobs/{jobKey}` with `{ changeset: { timeout } }`) so the lock
287
+ // extension tracks the engine contract instead of a hand-rolled URL. Fall
288
+ // back to the SAME call issued raw when no SDK client is injected (keeps
289
+ // this module wire-testable and usable standalone).
290
+ if (camunda && typeof camunda.updateJob === "function") {
291
+ try {
292
+ await camunda.updateJob({ changeset: { timeout: ms }, jobKey: String(jobKey) });
293
+ return;
294
+ } catch (err) {
295
+ throw new Error(`extendLock ${jobKey}: SDK updateJob failed: ${err?.message ?? err}`, { cause: err });
296
+ }
297
+ }
298
+ const url = `${base}/jobs/${encodeURIComponent(jobKey)}`;
299
+ const res = await call(url, { method: "PATCH", body: JSON.stringify({ changeset: { timeout: ms } }) }, 15_000);
215
300
  if (!res || !res.ok) {
216
301
  const status = res ? res.status : "?";
217
302
  throw new Error(`extendLock ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
@@ -220,21 +305,33 @@ export function createRawEngineClient(opts = {}) {
220
305
 
221
306
  // ---- Job completion / failure ------------------------------------------
222
307
  // The narrow surface the supervisor's JobRunner needs to SETTLE a job once
223
- // its agent harness finishes — the direct-REST analogue of the SDK job
224
- // object's `job.complete()` / `job.fail()` (which the per-type SDK poller
225
- // path in `workAgent` still uses). A plain `ActivatedJob` (jobKey/type/
226
- // variables) carries no settle methods, so the supervisor path completes via
227
- // these calls instead. Effect-free and wire-testable like `activate` /
228
- // `extendLock`; a non-2xx (e.g. a 409 when the lock already lapsed and the
229
- // job was reclaimed) surfaces as a rejected promise for the port to map.
308
+ // its agent harness finishes — the analogue of the SDK job object's
309
+ // `job.complete()` / `job.fail()` (which the per-type SDK poller path in
310
+ // `workAgent` still uses). A plain `ActivatedJob` (jobKey/type/variables)
311
+ // carries no settle methods, so the supervisor path settles via these calls.
312
+ // Like `extendLock`, each PREFERS the injected `camunda` SDK client's typed
313
+ // method (`completeJob` / `failJob`) and only hand-rolls the REST call as a
314
+ // standalone/wire-test fallback. Effect-free and wire-testable; a non-2xx /
315
+ // SDK rejection (e.g. a 409 when the lock lapsed and the job was reclaimed)
316
+ // surfaces as a rejected promise for the port to map.
230
317
 
231
318
  async complete(jobKey, variables) {
232
- // `POST <base>/v2/jobs/{jobKey}/completion` with `{ variables }` — the
319
+ // Prefer the SDK's typed `completeJob` (operationId `completeJob`
320
+ // `POST /v2/jobs/{jobKey}/completion` with `{ variables }`) when a `camunda`
321
+ // SDK client is injected, else the SAME call issued raw via `fetchImpl`. The
233
322
  // result-variable map the model produced is merged onto the process
234
323
  // instance. C8 v2 answers 204 No Content on success.
324
+ const vars = isPlainObjectMap(variables) ? { variables } : {};
325
+ if (camunda && typeof camunda.completeJob === "function") {
326
+ try {
327
+ await camunda.completeJob({ jobKey: String(jobKey), ...vars });
328
+ return;
329
+ } catch (err) {
330
+ throw new Error(`complete ${jobKey}: SDK completeJob failed: ${err?.message ?? err}`, { cause: err });
331
+ }
332
+ }
235
333
  const url = `${base}/jobs/${encodeURIComponent(jobKey)}/completion`;
236
- const body = JSON.stringify(isPlainObjectMap(variables) ? { variables } : {});
237
- const res = await call(url, { method: "POST", body }, 15_000);
334
+ const res = await call(url, { method: "POST", body: JSON.stringify(vars) }, 15_000);
238
335
  if (!res || !res.ok) {
239
336
  const status = res ? res.status : "?";
240
337
  throw new Error(`complete ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
@@ -246,19 +343,30 @@ export function createRawEngineClient(opts = {}) {
246
343
  // null-safe `variables`), so a caller passing `null` never trips the
247
344
  // signature-destructure TypeError.
248
345
  const { retries = 0, errorMessage, retryBackOff, variables } = opts || {};
249
- // `POST <base>/v2/jobs/{jobKey}/failure` with `{ retries, errorMessage?,
250
- // retryBackOff?, variables? }` — `retries > 0` re-queues for another
251
- // attempt, `retries === 0` raises an incident. Optional fields are omitted
252
- // when absent so the engine applies its own defaults. C8 v2 answers 204.
253
- const url = `${base}/jobs/${encodeURIComponent(jobKey)}/failure`;
254
346
  // Normalize retries to a non-negative integer (mirrors `mapJob`), so a
255
- // string/float/negative never reaches the engine as an invalid count.
347
+ // string/float/negative never reaches the engine (or SDK) as an invalid
348
+ // count. `retries > 0` re-queues for another attempt, `retries === 0`
349
+ // raises an incident. Optional fields are omitted when absent so the engine
350
+ // applies its own defaults.
256
351
  const nRetries = Number(retries);
257
- const payload = { retries: Number.isFinite(nRetries) ? Math.max(0, Math.trunc(nRetries)) : 0 };
258
- if (errorMessage !== undefined && errorMessage !== null) payload.errorMessage = String(errorMessage);
259
- if (Number.isFinite(retryBackOff) && retryBackOff > 0) payload.retryBackOff = retryBackOff;
260
- if (isPlainObjectMap(variables)) payload.variables = variables;
261
- const res = await call(url, { method: "POST", body: JSON.stringify(payload) }, 15_000);
352
+ const normRetries = Number.isFinite(nRetries) ? Math.max(0, Math.trunc(nRetries)) : 0;
353
+ const extra = {};
354
+ if (errorMessage !== undefined && errorMessage !== null) extra.errorMessage = String(errorMessage);
355
+ if (Number.isFinite(retryBackOff) && retryBackOff > 0) extra.retryBackOff = retryBackOff;
356
+ if (isPlainObjectMap(variables)) extra.variables = variables;
357
+ // Prefer the SDK's typed `failJob` (operationId `failJob` →
358
+ // `POST /v2/jobs/{jobKey}/failure`) when a `camunda` SDK client is injected,
359
+ // else the SAME call issued raw via `fetchImpl`. C8 v2 answers 204.
360
+ if (camunda && typeof camunda.failJob === "function") {
361
+ try {
362
+ await camunda.failJob({ jobKey: String(jobKey), retries: normRetries, ...extra });
363
+ return;
364
+ } catch (err) {
365
+ throw new Error(`fail ${jobKey}: SDK failJob failed: ${err?.message ?? err}`, { cause: err });
366
+ }
367
+ }
368
+ const url = `${base}/jobs/${encodeURIComponent(jobKey)}/failure`;
369
+ const res = await call(url, { method: "POST", body: JSON.stringify({ retries: normRetries, ...extra }) }, 15_000);
262
370
  if (!res || !res.ok) {
263
371
  const status = res ? res.status : "?";
264
372
  throw new Error(`fail ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);