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.
- package/README.md +23 -0
- package/c8ctl-plugin.js +214 -27
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3167
|
-
|
|
3168
|
-
|
|
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
|
-
|
|
3172
|
-
|
|
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 ${
|
|
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.
|
|
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.
|
|
51
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
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
|
}
|