c8ctl-plugin-nano 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -0
  2. package/c8ctl-plugin.js +214 -27
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -207,6 +207,29 @@ c8ctl nano work reviewer # poll for work until Ctrl-C
207
207
  c8ctl nano work reviewer --max-parallel 2 --job-timeout 600000
208
208
  ```
209
209
 
210
+ ### Live profile reload (no restart on `assign`)
211
+
212
+ A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
213
+ you extend or reduce that profile's capabilities in another terminal —
214
+
215
+ ```bash
216
+ c8ctl nano assign reviewer fix-ci # add a capability to the live profile
217
+ ```
218
+
219
+ — the supervisor reconciles its pollers in place: it **starts** pollers for the
220
+ newly added rank×capability job types and **gracefully drains** the pollers for
221
+ removed types — best-effort: each draining poller is given a bounded grace
222
+ window (`STOP_GRACE_MS`) for its in-flight jobs to finish before
223
+ it is stopped, so long-running work exceeding that window may still be
224
+ interrupted. Unchanged job types keep running undisturbed, so there is no need
225
+ to stop and restart the worker.
226
+
227
+ Only **job types** (rank + capabilities, plus any `--job-type` extras) reconcile
228
+ live. Changes to the profile's `command`, `model`, `sandbox`/`image`, or `env`
229
+ still require a restart to take effect. If the profile is deleted or the config
230
+ file is mid-write when the reload fires, the running workers are **kept** (never
231
+ torn down) and a warning is logged.
232
+
210
233
  Each activated job runs the profile's command **once** (one-shot): the job is
211
234
  serialized to JSON and piped to the CLI's **stdin** —
212
235
 
package/c8ctl-plugin.js CHANGED
@@ -43,6 +43,8 @@ import {
43
43
  statfsSync,
44
44
  lstatSync,
45
45
  mkdtempSync,
46
+ watchFile,
47
+ unwatchFile,
46
48
  } from 'node:fs';
47
49
  import { randomUUID } from 'node:crypto';
48
50
  import { homedir, platform as osPlatform, devNull } from 'node:os';
@@ -200,20 +202,39 @@ function getConfigFile() {
200
202
  return join(getStateHome(), CONFIG_FILE);
201
203
  }
202
204
 
203
- function readConfig() {
205
+ function readConfigStrict() {
204
206
  const file = getConfigFile();
205
207
  if (!existsSync(file)) return {};
208
+ const cfg = JSON.parse(readFileSync(file, 'utf-8'));
209
+ return cfg && typeof cfg === 'object' ? cfg : {};
210
+ }
211
+
212
+ function readConfig() {
206
213
  try {
207
- const cfg = JSON.parse(readFileSync(file, 'utf-8'));
208
- return cfg && typeof cfg === 'object' ? cfg : {};
214
+ return readConfigStrict();
209
215
  } catch {
216
+ // A malformed/torn config.json is swallowed here so ordinary callers get an
217
+ // empty map; callers that must tell "absent" from "unreadable" apart use
218
+ // readConfigStrict() directly and handle the throw.
210
219
  return {};
211
220
  }
212
221
  }
213
222
 
214
223
  function writeConfig(cfg) {
215
224
  mkdirSync(getStateHome(), { recursive: true });
216
- writeFileSync(getConfigFile(), JSON.stringify(cfg, null, 2));
225
+ // Atomic write: serialize to a temp file in the same dir, then rename over the
226
+ // target. A rename is atomic on a POSIX filesystem, so a concurrent reader
227
+ // (e.g. `work`'s profile watcher, or another `assign`) never observes a
228
+ // half-written config.json and JSON.parse never sees a torn file.
229
+ const target = getConfigFile();
230
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
231
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2));
232
+ try {
233
+ renameSync(tmp, target);
234
+ } catch (err) {
235
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
236
+ throw err;
237
+ }
217
238
  }
218
239
 
219
240
  /**
@@ -1565,6 +1586,21 @@ function jobTypeMatrix(rank, capabilities) {
1565
1586
  return [...new Set(tokens)];
1566
1587
  }
1567
1588
 
1589
+ /**
1590
+ * Diff a running set of job-type pollers against a desired set. Pure so the
1591
+ * profile-watch reconcile in `work` (which starts pollers for `added` types and
1592
+ * gracefully drains pollers for `removed` types) is unit-testable. Order in the
1593
+ * returned arrays is stable (desired order for `added`, current order for
1594
+ * `removed`) for deterministic logging.
1595
+ */
1596
+ function diffJobTypes(current, desired) {
1597
+ const cur = new Set(current);
1598
+ const want = new Set(desired);
1599
+ const added = [...want].filter((t) => !cur.has(t));
1600
+ const removed = [...cur].filter((t) => !want.has(t));
1601
+ return { added, removed };
1602
+ }
1603
+
1568
1604
  /** All persisted hire profiles, keyed by name. */
1569
1605
  function readHires() {
1570
1606
  const cfg = readConfig();
@@ -1573,6 +1609,16 @@ function readHires() {
1573
1609
  return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
1574
1610
  }
1575
1611
 
1612
+ /**
1613
+ * Like readHires(), but propagates a malformed-config parse error instead of
1614
+ * swallowing it. Lets a caller distinguish "profile genuinely removed" from
1615
+ * "config temporarily unreadable/torn" so it can report an accurate reason.
1616
+ */
1617
+ function readHiresStrict() {
1618
+ const cfg = readConfigStrict();
1619
+ return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
1620
+ }
1621
+
1576
1622
  /** Persist a single hire profile into config.json under `hires`. */
1577
1623
  function writeHire(profile) {
1578
1624
  const cfg = readConfig();
@@ -2975,7 +3021,10 @@ async function workAgent(req, flags) {
2975
3021
  logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobKillMs}ms; activation lock: ${jobLockMs}ms; poll timeout: ${pollTimeoutMs}ms`);
2976
3022
  logger.info('Polling for work — press Ctrl-C to stop.');
2977
3023
 
2978
- const workers = jobTypes.map((jobType) =>
3024
+ // A per-job-type worker factory. Captures all the CLI-local + profile context
3025
+ // in closure scope so the profile watcher below can (re)spawn a poller for any
3026
+ // job type on demand without re-reading the flags.
3027
+ const makeWorker = (jobType) =>
2979
3028
  camunda.createJobWorker({
2980
3029
  jobType,
2981
3030
  workerName: `${name}:${jobType}`,
@@ -3156,35 +3205,172 @@ async function workAgent(req, flags) {
3156
3205
  variables: { [AGENT_RESULT_KEY]: resultEnvelope },
3157
3206
  });
3158
3207
  },
3159
- }),
3160
- );
3208
+ });
3209
+
3210
+ // Live worker registry keyed by job type, so the profile watcher can add or
3211
+ // drain individual pollers without disturbing the others. `draining` is the
3212
+ // shutdown latch (shared with the watcher so a reconcile can't race a stop).
3213
+ const workers = new Map();
3214
+ let draining = false;
3215
+
3216
+ const drainWorker = async (w) => {
3217
+ try {
3218
+ if (typeof w.stopGracefully === 'function') {
3219
+ await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
3220
+ } else if (typeof w.stop === 'function') {
3221
+ await w.stop();
3222
+ }
3223
+ return true;
3224
+ } catch {
3225
+ return false; // best-effort: never let one worker's stop failure hang us
3226
+ }
3227
+ };
3228
+
3229
+ const spawnJobType = (jobType) => {
3230
+ if (workers.has(jobType)) return false;
3231
+ workers.set(jobType, makeWorker(jobType));
3232
+ return true;
3233
+ };
3234
+
3235
+ for (const jobType of jobTypes) spawnJobType(jobType);
3236
+
3237
+ // ---- Live profile watch: reconcile the poller set when the watched profile's
3238
+ // job types change (e.g. `c8ctl nano assign <name> …`) — start pollers for
3239
+ // added types, gracefully drain pollers for removed types — without a restart
3240
+ // and without disturbing unchanged types' in-flight work. ----
3241
+ const configFile = getConfigFile();
3242
+ const WATCH_INTERVAL_MS = 1500;
3243
+ let reconciling = false;
3244
+ // Set when a profile change arrives while a reconcile is already in flight, so
3245
+ // we run one more pass after the current drain completes instead of dropping
3246
+ // the update until the next change fires.
3247
+ let reconcileRequested = false;
3248
+ // Handle to the in-flight reconcile so shutdown can wait for it to finish
3249
+ // before snapshotting `workers` (avoids double-stops / missed drains).
3250
+ let inFlightReconcile = null;
3251
+
3252
+ // Desired job types from the CURRENT on-disk profile (matrix ∪ --job-type
3253
+ // extras). Returns { skip } for a transient/torn read, a vanished profile, or
3254
+ // an invalid edit — callers must then KEEP the running set, never tear down.
3255
+ const desiredJobTypes = () => {
3256
+ let stored;
3257
+ try {
3258
+ stored = readHiresStrict()[name];
3259
+ } catch {
3260
+ // config.json exists but doesn't parse (e.g. a torn write): the profile is
3261
+ // NOT necessarily gone, so don't claim it was deleted — skip this pass.
3262
+ return { skip: 'config unreadable' };
3263
+ }
3264
+ if (!stored) return { skip: 'deleted' };
3265
+ const norm = normalizeStoredProfile(name, stored);
3266
+ if (norm.error) return { skip: norm.error };
3267
+ const m = jobTypeMatrix(norm.profile.rank, norm.profile.capabilities);
3268
+ return { jobTypes: [...new Set([...m, ...extraJobTypes])] };
3269
+ };
3270
+
3271
+ const reconcile = () => {
3272
+ if (draining) return inFlightReconcile || Promise.resolve();
3273
+ if (reconciling) {
3274
+ // A change landed mid-reconcile — remember it so the current pass loops
3275
+ // once more rather than leaving the worker set stale until the next edit.
3276
+ // Return the ACTUAL in-flight promise (not a fresh short-lived one) so a
3277
+ // caller — including shutdown — waits for the real reconcile to finish.
3278
+ reconcileRequested = true;
3279
+ return inFlightReconcile || Promise.resolve();
3280
+ }
3281
+ reconciling = true;
3282
+ reconcileRequested = false;
3283
+ inFlightReconcile = (async () => {
3284
+ try {
3285
+ do {
3286
+ reconcileRequested = false;
3287
+ await runReconcilePass();
3288
+ } while (reconcileRequested && !draining);
3289
+ } finally {
3290
+ reconciling = false;
3291
+ inFlightReconcile = null;
3292
+ }
3293
+ })();
3294
+ return inFlightReconcile;
3295
+ };
3296
+
3297
+ const runReconcilePass = async () => {
3298
+ const desired = desiredJobTypes();
3299
+ if (desired.skip) {
3300
+ if (desired.skip === 'deleted') {
3301
+ logger.warn(`Profile "${name}" is gone from config — keeping the current ${workers.size} worker(s) running.`);
3302
+ } else {
3303
+ logger.warn(`Profile "${name}" reload skipped — ${desired.skip}; keeping current workers.`);
3304
+ }
3305
+ return;
3306
+ }
3307
+ const { added, removed } = diffJobTypes([...workers.keys()], desired.jobTypes);
3308
+ if (added.length === 0 && removed.length === 0) return;
3309
+ logger.info(`Profile "${name}" changed — reconciling job types (+${added.length} / -${removed.length}).`);
3310
+ for (const jt of added) {
3311
+ spawnJobType(jt);
3312
+ logger.info(` + now listening on ${jt}`);
3313
+ }
3314
+ await Promise.all(
3315
+ removed.map(async (jt) => {
3316
+ const w = workers.get(jt);
3317
+ logger.info(` - draining ${jt} …`);
3318
+ const ok = await drainWorker(w);
3319
+ if (ok) {
3320
+ // Only drop it from the registry once it has actually stopped, so a
3321
+ // failed drain stays tracked and gets retried on the next reconcile
3322
+ // pass (or on shutdown) instead of leaking an untracked poller.
3323
+ workers.delete(jt);
3324
+ logger.info(` - stopped ${jt}`);
3325
+ } else {
3326
+ logger.warn(` - ${jt} did not stop cleanly; keeping it tracked so it is retried on the next reconcile or shutdown.`);
3327
+ }
3328
+ }),
3329
+ );
3330
+ logger.info(` now listening on ${workers.size} job type(s): ${[...workers.keys()].join(' ')}`);
3331
+ };
3332
+
3333
+ // `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
3334
+ // atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
3335
+ // inode and go silent), and it's uniform across platforms. Profile edits are
3336
+ // rare + manual, so a ~1.5s poll latency is fine.
3337
+ watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
3338
+ // Fires each interval; act only on real changes. Compare mtime, ctime and
3339
+ // size, not mtime alone: on filesystems with coarse mtime resolution (or two
3340
+ // edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
3341
+ // differ, and an mtime-only guard would skip a genuine profile update.
3342
+ if (
3343
+ curr.mtimeMs === prev.mtimeMs &&
3344
+ curr.ctimeMs === prev.ctimeMs &&
3345
+ curr.size === prev.size
3346
+ ) return;
3347
+ // `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
3348
+ // a reconcile is already running coalesces into the current pass and returns
3349
+ // that same in-flight promise, so shutdown always waits for the real one.
3350
+ reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
3351
+ });
3161
3352
 
3162
3353
  // Keep the process alive until a stop signal, then drain gracefully.
3163
3354
  await new Promise((resolve) => {
3164
- let stopping = false;
3165
3355
  const stop = async (signal) => {
3166
- if (stopping) return;
3167
- stopping = true;
3168
- logger.info(`Received ${signal} stopping ${workers.length} worker(s)...`);
3356
+ if (draining) return;
3357
+ draining = true;
3358
+ // Stop watching first so no new reconcile can be triggered, then wait for
3359
+ // any in-flight reconcile to finish before snapshotting `workers` — this
3360
+ // prevents double-stops, missed drains, or a wrong worker count on exit.
3361
+ unwatchFile(configFile);
3362
+ if (inFlightReconcile) {
3363
+ logger.info('Waiting for in-flight profile reconcile to finish before shutdown…');
3364
+ await inFlightReconcile;
3365
+ }
3366
+ const list = [...workers.values()];
3367
+ logger.info(`Received ${signal} — stopping ${list.length} worker(s)...`);
3169
3368
  if (reaperTimer) clearInterval(reaperTimer);
3170
3369
  if (runDirTimer) clearInterval(runDirTimer);
3171
- let stopFailures = 0;
3172
- await Promise.all(
3173
- workers.map(async (w) => {
3174
- try {
3175
- if (typeof w.stopGracefully === 'function') {
3176
- await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
3177
- } else if (typeof w.stop === 'function') {
3178
- await w.stop();
3179
- }
3180
- } catch {
3181
- // best-effort: never let one worker's stop failure hang shutdown
3182
- stopFailures += 1;
3183
- }
3184
- }),
3185
- );
3370
+ const results = await Promise.all(list.map(drainWorker));
3371
+ const stopFailures = results.filter((ok) => !ok).length;
3186
3372
  if (stopFailures > 0) {
3187
- logger.warn(`${stopFailures} of ${workers.length} worker(s) did not stop cleanly; some connections may still be open.`);
3373
+ logger.warn(`${stopFailures} of ${list.length} worker(s) did not stop cleanly; some connections may still be open.`);
3188
3374
  } else {
3189
3375
  logger.info('All workers stopped.');
3190
3376
  }
@@ -4547,6 +4733,7 @@ export {
4547
4733
  applyAssign,
4548
4734
  resolveAssignInputs,
4549
4735
  jobTypeMatrix,
4736
+ diffJobTypes,
4550
4737
  parseJobTypeFlags,
4551
4738
  deriveJobLockMs,
4552
4739
  derivePollTimeoutMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
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",
@@ -47,12 +47,12 @@
47
47
  "semantic-release": "^25.0.3"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.19.0",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.19.0",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.19.0",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.19.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.19.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.19.0",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.19.0"
50
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.20.0",
51
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.20.0",
52
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.20.0",
53
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.20.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.20.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.20.0",
56
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.20.0"
57
57
  }
58
58
  }