c8ctl-plugin-nano 1.21.0 → 1.22.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 +17 -0
  2. package/c8ctl-plugin.js +146 -25
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -205,8 +205,16 @@ c8ctl nano work reviewer --job-type senior:pr-review --job-type senior:triage
205
205
  ```bash
206
206
  c8ctl nano work reviewer # poll for work until Ctrl-C
207
207
  c8ctl nano work reviewer --max-parallel 2 --job-timeout 600000
208
+ c8ctl nano work reviewer --name reviewer-eu # name this worker (else auto ‹host›-‹profile›-‹random›)
208
209
  ```
209
210
 
211
+ The optional `--name` sets **this worker's name** — the `workerName` it
212
+ registers under at the broker (`‹name›:‹jobType›`) and how it shows up in
213
+ supervisor status/logs. Omit it and a distinct `‹host›-‹profile›-‹random›`
214
+ name is generated, so two `work reviewer` processes never collide at the
215
+ broker. (`--name` names the worker; the profile to run is always the
216
+ positional argument.)
217
+
210
218
  ### Live profile reload (no restart on `assign`)
211
219
 
212
220
  A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
@@ -456,12 +464,21 @@ c8ctl nano supervisor
456
464
  # Manage the fleet without the console (any terminal, any time):
457
465
  c8ctl nano supervisor status # id, state, pid, restarts, uptime
458
466
  c8ctl nano supervisor add reviewer --max-parallel 2 # add + spawn a worker (forwards work flags)
467
+ c8ctl nano supervisor add reviewer --name reviewer-2 # a SECOND reviewer, named so it stays distinct
459
468
  c8ctl nano supervisor restart reviewer # by worker id or profile name
460
469
  c8ctl nano supervisor remove coder # stop + drop a worker (also: `all`)
461
470
  c8ctl nano supervisor logs reviewer --follow # tail a worker's log (or the daemon's)
462
471
  c8ctl nano supervisor stop # stop the daemon and every worker
463
472
  ```
464
473
 
474
+ Each worker has a **name** — its supervisor id and the broker `workerName` it
475
+ registers under. Pass `--name` on `supervisor add` (or `work`) to set it;
476
+ omit it and one is auto-generated as `‹host›-‹profile›-‹random›`, so you can
477
+ run **several instances of the same profile** and they stay distinct
478
+ end-to-end (status, logs, and at the broker). `restart`/`remove` accept either
479
+ a worker id **or** a profile name — targeting a profile affects *every*
480
+ instance of it.
481
+
465
482
  Each worker takes the **same flags as `nano work`** (`--max-parallel`,
466
483
  `--job-timeout`, `--lock-grace`, `--poll-timeout`, `--sandbox`/`--image`,
467
484
  `--job-type`, `--env`, `--arg`, …); they are forwarded verbatim to the spawned
package/c8ctl-plugin.js CHANGED
@@ -49,8 +49,8 @@ import {
49
49
  unwatchFile,
50
50
  } from 'node:fs';
51
51
  import { createConnection, createServer } from 'node:net';
52
- import { randomUUID, createHash } from 'node:crypto';
53
- import { homedir, platform as osPlatform, devNull, tmpdir } from 'node:os';
52
+ import { randomUUID, createHash, randomBytes } from 'node:crypto';
53
+ import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
54
54
  import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
55
55
  import { createRequire } from 'node:module';
56
56
  import { fileURLToPath } from 'node:url';
@@ -2879,7 +2879,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
2879
2879
  */
2880
2880
  async function workAgent(req, flags) {
2881
2881
  const logger = getLogger();
2882
- const name = flags?.name ? String(flags.name).trim() : req.positional[0];
2882
+ // The hire to run always comes from the positional profile. `--name` no longer
2883
+ // selects the hire (that was a footgun: `work reviewer --name coder` silently
2884
+ // ran `coder`); it now names THIS worker instance (see `workerName` below).
2885
+ const name = req.positional[0];
2883
2886
 
2884
2887
  if (!name) {
2885
2888
  const hires = readHires();
@@ -2902,6 +2905,19 @@ async function workAgent(req, flags) {
2902
2905
  }
2903
2906
  const profile = normalized.profile;
2904
2907
 
2908
+ // This worker's identity, surfaced to the broker as the `workerName` on every
2909
+ // activateJobs call (`‹workerName›:‹jobType›`). An explicit `--name` wins;
2910
+ // otherwise auto-generate `‹host›-‹profile›-‹random›` so two workers of the
2911
+ // same profile (e.g. launched by the supervisor) stay distinct at the broker
2912
+ // and in logs. A blank/whitespace `--name` falls back to auto (mirrors the
2913
+ // supervisor path); a non-blank one must be a safe worker-name token.
2914
+ const explicitName = flags?.name ? String(flags.name).trim() : '';
2915
+ if (explicitName !== '' && !isValidWorkerName(explicitName)) {
2916
+ logger.error(`Invalid --name "${flags.name}": use only letters, digits, and . _ -`);
2917
+ process.exit(1);
2918
+ }
2919
+ const workerName = explicitName !== '' ? explicitName : autoWorkerName(name);
2920
+
2905
2921
  if (!globalThis.c8ctl || typeof globalThis.c8ctl.createClient !== 'function') {
2906
2922
  logger.error('work requires the c8ctl runtime (createClient). Run it via the c8ctl CLI.');
2907
2923
  process.exit(1);
@@ -3045,6 +3061,7 @@ async function workAgent(req, flags) {
3045
3061
  const camunda = globalThis.c8ctl.createClient();
3046
3062
 
3047
3063
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3064
+ logger.info(` worker: ${workerName}`);
3048
3065
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
3049
3066
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
3050
3067
  const profileEnvKeys = Object.keys(profileEnv);
@@ -3060,7 +3077,7 @@ async function workAgent(req, flags) {
3060
3077
  const makeWorker = (jobType) =>
3061
3078
  camunda.createJobWorker({
3062
3079
  jobType,
3063
- workerName: `${name}:${jobType}`,
3080
+ workerName: `${workerName}:${jobType}`,
3064
3081
  maxParallelJobs,
3065
3082
  jobTimeoutMs: jobLockMs,
3066
3083
  pollTimeoutMs,
@@ -3491,6 +3508,78 @@ function reconstructWorkArgs(flags) {
3491
3508
  return out;
3492
3509
  }
3493
3510
 
3511
+ /**
3512
+ * Sanitize one token for use inside a worker name: keep `[A-Za-z0-9._-]`,
3513
+ * collapse every other run to a single `-`, and trim leading/trailing
3514
+ * separators. Returns `fallback` when nothing survives (e.g. an all-symbol
3515
+ * input). Pure.
3516
+ */
3517
+ function sanitizeNameToken(raw, fallback = 'x') {
3518
+ const s = String(raw ?? '')
3519
+ .trim()
3520
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
3521
+ .replace(/^[-._]+|[-._]+$/g, '');
3522
+ return s || fallback;
3523
+ }
3524
+
3525
+ /**
3526
+ * An explicit worker name (`--name`) is valid iff — after trimming — it is a
3527
+ * non-empty run of `[A-Za-z0-9._-]`. That charset is the intersection of what
3528
+ * is safe in a broker `workerName` (no `:` to corrupt the `‹name›:‹jobType›`
3529
+ * form) and what survives `supervisorWorkerLogFile`'s filename sanitization
3530
+ * unchanged (so distinct ids can never collapse onto the same `worker-‹id›.log`
3531
+ * or escape the log dir). Auto-generated names are already in this shape;
3532
+ * operator-supplied names are validated against it so both invariants hold.
3533
+ * Pure.
3534
+ */
3535
+ function isValidWorkerName(name) {
3536
+ const s = typeof name === 'string' ? name.trim() : '';
3537
+ return s !== '' && /^[A-Za-z0-9._-]+$/.test(s);
3538
+ }
3539
+
3540
+ /** A short, lowercase, collision-resistant suffix for auto worker names. */
3541
+ function randomNameSuffix(bytes = 4) {
3542
+ return randomBytes(bytes).toString('hex');
3543
+ }
3544
+
3545
+ /**
3546
+ * Auto-generated worker name: `‹short-hostname›-‹profile›-‹random›`. The host
3547
+ * defaults to this machine's short hostname (the first dot-label, lowercased);
3548
+ * the random suffix keeps two same-profile workers on the same host distinct.
3549
+ * `host`/`rand` are injectable so tests can assert a deterministic shape. Pure
3550
+ * given its options.
3551
+ */
3552
+ function autoWorkerName(profile, { host = hostname(), rand = randomNameSuffix } = {}) {
3553
+ const shortHost = sanitizeNameToken(String(host || '').split('.')[0].toLowerCase(), 'host');
3554
+ const prof = sanitizeNameToken(profile, 'worker');
3555
+ const suffix = sanitizeNameToken(typeof rand === 'function' ? rand() : rand, '0');
3556
+ return `${shortHost}-${prof}-${suffix}`;
3557
+ }
3558
+
3559
+ /**
3560
+ * Split a supervised-worker name (`--name X`, `--name=X`, `-n X`) out of a raw
3561
+ * token list, returning `{ name, rest }` where `rest` is the remaining work
3562
+ * flags. Used by the interactive console's `add`, whose tokens aren't parsed by
3563
+ * the CLI flag layer. Last occurrence wins; a trailing `--name` with no value
3564
+ * yields `name: undefined`. Pure.
3565
+ */
3566
+ function extractNameFlag(parts) {
3567
+ const rest = [];
3568
+ let name;
3569
+ const list = Array.isArray(parts) ? parts : [];
3570
+ for (let i = 0; i < list.length; i++) {
3571
+ const tok = String(list[i]);
3572
+ const eq = /^(?:--name|-n)=(.*)$/.exec(tok);
3573
+ if (eq) { name = eq[1]; continue; }
3574
+ if (tok === '--name' || tok === '-n') {
3575
+ if (i + 1 < list.length) { name = String(list[i + 1]); i++; }
3576
+ continue;
3577
+ }
3578
+ rest.push(tok);
3579
+ }
3580
+ return { name: name != null && name.trim() !== '' ? name.trim() : undefined, rest };
3581
+ }
3582
+
3494
3583
  /** Assign a unique, stable worker id from a profile name (pure). */
3495
3584
  function supervisorWorkerId(profile, taken) {
3496
3585
  const base = String(profile || '').trim() || 'worker';
@@ -3766,7 +3855,9 @@ async function runSupervisorDaemon() {
3766
3855
  const startWorker = (w) => {
3767
3856
  let fd;
3768
3857
  try { fd = openSync(w.logFile, 'a'); } catch { fd = 'ignore'; }
3769
- const child = spawn(exec, [entry, 'nano', 'work', w.profile, ...w.args], {
3858
+ // `--name w.id` makes the child's broker workerName match this worker's
3859
+ // supervisor id, so the same profile launched twice is distinct end-to-end.
3860
+ const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
3770
3861
  env: process.env,
3771
3862
  stdio: ['ignore', fd, fd],
3772
3863
  });
@@ -3808,8 +3899,19 @@ async function runSupervisorDaemon() {
3808
3899
  persist();
3809
3900
  };
3810
3901
 
3811
- const addWorker = (profile, args) => {
3812
- const id = supervisorWorkerId(profile, new Set(workers.keys()));
3902
+ const addWorker = (profile, args, name) => {
3903
+ const taken = new Set(workers.keys());
3904
+ let id;
3905
+ if (name != null && String(name).trim() !== '') {
3906
+ id = String(name).trim();
3907
+ if (!isValidWorkerName(id)) throw new Error(`invalid worker name "${id}": use only letters, digits, and . _ -`);
3908
+ if (taken.has(id)) throw new Error(`a worker named "${id}" already exists`);
3909
+ } else {
3910
+ // No explicit name → auto ‹host›-‹profile›-‹random›. The random suffix is
3911
+ // collision-resistant, but never hand back a duplicate id.
3912
+ id = autoWorkerName(profile);
3913
+ while (taken.has(id)) id = autoWorkerName(profile);
3914
+ }
3813
3915
  const w = {
3814
3916
  id, profile: String(profile), args: Array.isArray(args) ? args.map(String) : [],
3815
3917
  restarts: 0, stopping: false, lastExit: null, logFile: supervisorWorkerLogFile(id),
@@ -3896,20 +3998,22 @@ async function runSupervisorDaemon() {
3896
3998
  case 'add': {
3897
3999
  if (shuttingDown) { sock.write(encodeFrame({ ok: false, error: 'supervisor is shutting down', final: true })); break; }
3898
4000
  if (!req.profile) { sock.write(encodeFrame({ ok: false, error: 'add requires a profile', final: true })); break; }
3899
- // `--name` selects a DIFFERENT hire inside `nano work`, so a worker
3900
- // added as profile X but carrying `--name Y` would run Y while status
3901
- // and logs report X. Reject it the supervisor id derives from the
3902
- // positional profile and that must be what actually runs.
4001
+ // A supervised worker's name is supplied out-of-band as `req.name`
4002
+ // (which the daemon forwards to the child as `nano work --name`).
4003
+ // A bare `--name` inside the forwarded work args is therefore ambiguous
4004
+ // it would fight the supervisor-assigned id so reject it here and
4005
+ // steer the operator to the dedicated flag.
3903
4006
  // `req.args` comes from untrusted JSON and may be non-array (e.g. a
3904
4007
  // string or object). Coerce to an array of string tokens before
3905
4008
  // scanning/forwarding so a malformed payload yields a clean rejection
3906
4009
  // instead of throwing a generic request error.
3907
4010
  const args = Array.isArray(req.args) ? req.args.filter((a) => typeof a === 'string') : [];
3908
4011
  const badName = args.find((a) => a === '--name' || a === '-n' || /^--name=/.test(a) || /^-n=/.test(a));
3909
- if (badName) { sock.write(encodeFrame({ ok: false, error: `--name is not allowed for a supervised worker (it would run a different hire than the reported profile "${req.profile}")`, final: true })); break; }
4012
+ if (badName) { sock.write(encodeFrame({ ok: false, error: 'name a supervised worker with `--name` on `supervisor add`, not inside its work flags', final: true })); break; }
3910
4013
  const stored = readHires()[String(req.profile)];
3911
4014
  if (!stored) { sock.write(encodeFrame({ ok: false, error: `no hire named "${req.profile}"`, final: true })); break; }
3912
- const w = await serializeOp(() => addWorker(req.profile, args));
4015
+ const name = typeof req.name === 'string' ? req.name.trim() : '';
4016
+ const w = await serializeOp(() => addWorker(req.profile, args, name));
3913
4017
  sock.write(encodeFrame({ ok: true, type: 'added', worker: workerPublic(w), final: true }));
3914
4018
  break;
3915
4019
  }
@@ -4147,8 +4251,17 @@ async function supervisorStartCmd(req, flags) {
4147
4251
 
4148
4252
  const specs = normalizeArgList(flags?.worker);
4149
4253
  const workArgs = reconstructWorkArgs(flags);
4150
- for (const profile of specs) {
4151
- const res = await supervisorRequest({ op: 'add', profile, args: workArgs });
4254
+ // `--name` names a single launched worker. With several `--worker` specs a lone
4255
+ // name can't apply to all of them, so honour it only for a single spec and let
4256
+ // the rest auto-name; warn so the intent isn't silently dropped.
4257
+ const explicitName = flags?.name ? String(flags.name).trim() : undefined;
4258
+ if (explicitName && specs.length > 1) {
4259
+ logger.warn('--name is ignored when starting multiple --worker specs; each is auto-named.');
4260
+ }
4261
+ const nameFor = (i) => (explicitName && specs.length === 1 ? explicitName : undefined);
4262
+ for (let i = 0; i < specs.length; i++) {
4263
+ const profile = specs[i];
4264
+ const res = await supervisorRequest({ op: 'add', profile, name: nameFor(i), args: workArgs });
4152
4265
  if (res.ok) logger.info(` + worker "${res.worker.id}" (profile ${profile})`);
4153
4266
  else logger.error(` ! could not add "${profile}": ${res.error}`);
4154
4267
  }
@@ -4202,13 +4315,14 @@ async function supervisorStatusCmd() {
4202
4315
 
4203
4316
  async function supervisorAddCmd(req, flags) {
4204
4317
  const logger = getLogger();
4205
- // Use only the positional profile. `--name` is a documented hire/work/assign
4206
- // flag, so honouring it here would make `supervisor add reviewer --name foo`
4207
- // surprisingly add `foo` instead of `reviewer`.
4318
+ // The positional profile is what runs; `--name` names this worker instance
4319
+ // (forwarded to the child as `nano work --name`, and used as its supervisor
4320
+ // id). Omit `--name` to auto-generate ‹host›-‹profile›-‹random›.
4208
4321
  const profile = req.positional[1];
4209
- if (!profile) { logger.error('Usage: c8ctl nano supervisor add <profile> [work flags]'); process.exit(1); }
4322
+ if (!profile) { logger.error('Usage: c8ctl nano supervisor add <profile> [--name <worker>] [work flags]'); process.exit(1); }
4323
+ const name = flags?.name ? String(flags.name).trim() : undefined;
4210
4324
  await startSupervisorDaemon();
4211
- const res = await supervisorRequest({ op: 'add', profile, args: reconstructWorkArgs(flags) });
4325
+ const res = await supervisorRequest({ op: 'add', profile, name, args: reconstructWorkArgs(flags) });
4212
4326
  if (res.ok) logger.info(`Added worker "${res.worker.id}" (profile ${profile}); pid ${res.worker.pid ?? 'starting'}.`);
4213
4327
  else { logger.error(`Could not add "${profile}": ${res.error}`); process.exit(1); }
4214
4328
  }
@@ -4360,14 +4474,16 @@ async function attachSupervisorConsole(state) {
4360
4474
  switch (cmd) {
4361
4475
  case '': break;
4362
4476
  case 'help':
4363
- out('Commands: status | add <profile> [work flags] | remove <id|profile|all> |');
4364
- out(' restart <id|profile|all> | logs [id] | detach | stop | help');
4477
+ out('Commands: status | add <profile> [--name <worker>] [work flags] |');
4478
+ out(' remove <id|profile|all> | restart <id|profile|all> |');
4479
+ out(' logs [id] | detach | stop | help');
4365
4480
  break;
4366
4481
  case 'status': sock.write(encodeFrame({ op: 'status' })); break;
4367
4482
  case 'add': {
4368
4483
  const profile = parts.shift();
4369
- if (!profile) { out('usage: add <profile> [work flags]'); break; }
4370
- sock.write(encodeFrame({ op: 'add', profile, args: parts }));
4484
+ if (!profile) { out('usage: add <profile> [--name <worker>] [work flags]'); break; }
4485
+ const { name, rest } = extractNameFlag(parts);
4486
+ sock.write(encodeFrame({ op: 'add', profile, name, args: rest }));
4371
4487
  break;
4372
4488
  }
4373
4489
  case 'remove': case 'rm': {
@@ -5824,6 +5940,11 @@ export {
5824
5940
  export {
5825
5941
  reconstructWorkArgs,
5826
5942
  supervisorWorkerId,
5943
+ autoWorkerName,
5944
+ sanitizeNameToken,
5945
+ isValidWorkerName,
5946
+ randomNameSuffix,
5947
+ extractNameFlag,
5827
5948
  redactWorkArgs,
5828
5949
  supervisorBackoffMs,
5829
5950
  encodeFrame,
@@ -5926,7 +6047,7 @@ export const commands = {
5926
6047
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
5927
6048
  check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
5928
6049
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
5929
- name: { type: 'string', description: 'hire/work/assign: agent profile name (alt to positional arg)' },
6050
+ name: { type: 'string', description: 'work/supervisor add: worker name (auto ‹host›-‹profile›-‹random› if omitted); hire/assign: agent profile name' },
5930
6051
  rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
5931
6052
  command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
5932
6053
  arg: { type: 'string', multiple: true, description: 'hire/work: command-line switch/arg appended to the harness command (repeatable), e.g. --arg --allow-all. Persisted on hire; work appends more.' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.21.0",
3
+ "version": "1.22.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.21.0",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.21.0",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.21.0",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.21.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.21.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.21.0",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.21.0"
50
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.22.0",
51
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.22.0",
52
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.22.0",
53
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.22.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.22.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.22.0",
56
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.22.0"
57
57
  }
58
58
  }