c8ctl-plugin-nano 1.36.1 → 1.37.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 +39 -13
- package/c8ctl-plugin.js +276 -34
- package/package.json +8 -8
- package/work-channel.mjs +7 -0
package/README.md
CHANGED
|
@@ -292,23 +292,28 @@ c8ctl nano work reviewer
|
|
|
292
292
|
|
|
293
293
|
**Zero-config hub auto-discovery.** You usually don't even set `NANO_AGENTIC_URL`.
|
|
294
294
|
When nwf runs **embedded**, the engine (`:8080`) serves the console but the
|
|
295
|
-
`/agentic` channel is served by the **embedded app on its own
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
295
|
+
`/agentic` channel is served by the **embedded app on its own port** (e.g.
|
|
296
|
+
`:3000`); the engine's console proxy deliberately refuses WebSocket upgrades
|
|
297
|
+
(nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the engine URL.
|
|
298
|
+
With **no** agentic target configured, `work` therefore **auto-discovers** it: it
|
|
299
|
+
reads `GET <engine>/console/api/projects` and, for each running app that
|
|
300
|
+
advertises an agentic UI port (`appUi.enabled === true` and `appUi.port`), probes
|
|
301
|
+
that app's direct `ws://<engine-host>:<port>/agentic`. Discovery runs **against
|
|
302
|
+
the engine's own host** — a local engine keeps probing `127.0.0.1`, while a
|
|
303
|
+
remote/LAN engine (e.g. `merlin.local:8080`) steers the probe back at *itself*
|
|
304
|
+
(`merlin.local:<port>`), never at the worker's own loopback services. It is
|
|
305
|
+
**time-bounded** (≤2s) and never meaningfully delays job polling. The discovered
|
|
306
|
+
host and port are printed for debugging. (An IPv6 literal engine host is bracketed
|
|
307
|
+
in the URL authority, e.g. `ws://[2001:db8::1]:3000/agentic`.)
|
|
304
308
|
|
|
305
309
|
- **Exactly one app →** the worker connects directly to
|
|
306
|
-
`ws
|
|
307
|
-
and appears live with **zero configuration
|
|
310
|
+
`ws://<engine-host>:<appUi.port>/agentic` (bypassing the WS-incapable console
|
|
311
|
+
proxy) and appears live with **zero configuration** — including cross-machine on
|
|
312
|
+
a trusted LAN.
|
|
308
313
|
- **Two or more apps →** the worker **does not guess**: it prints an `ambiguous`
|
|
309
314
|
error naming each discovered `project → :port` and **stops**. Pin the one you
|
|
310
|
-
want and re-run: `export NANO_AGENTIC_URL=http
|
|
311
|
-
`agenticUrl`).
|
|
315
|
+
want and re-run: `export NANO_AGENTIC_URL=http://<engine-host>:<port>` (or
|
|
316
|
+
persist `agenticUrl`).
|
|
312
317
|
- **Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) →** the
|
|
313
318
|
worker prints a one-line advisory naming `NANO_AGENTIC_URL` and **continues
|
|
314
319
|
doing real work** with the channel simply absent — discovery never fails the
|
|
@@ -319,6 +324,27 @@ used **verbatim** — so an explicit target always wins, and it's also how you
|
|
|
319
324
|
disambiguate when several apps are running. `NANO_AGENTIC=off` disables the
|
|
320
325
|
channel entirely and attempts **no** discovery.
|
|
321
326
|
|
|
327
|
+
**Seeing it work — `supervisor status`.** Supervised workers report both the
|
|
328
|
+
engine they poll and their agentic-channel state to `c8ctl nano supervisor
|
|
329
|
+
status` (and the interactive console), so you don't have to read raw worker logs
|
|
330
|
+
to tell whether presence actually reached the Workforce hub. The table gains an
|
|
331
|
+
`ENGINE` column (the engine's `host:port`) and an `AGENTIC` column whose value is
|
|
332
|
+
one of:
|
|
333
|
+
|
|
334
|
+
| AGENTIC | meaning |
|
|
335
|
+
| --- | --- |
|
|
336
|
+
| `starting` | transient: the worker just spawned and hasn't resolved its channel target yet (pre-`connecting`) |
|
|
337
|
+
| `connected` | presence is live on the hub — you should see this worker in the Cockpit |
|
|
338
|
+
| `connecting` | resolved a hub, socket not open yet (or the hub is unreachable) |
|
|
339
|
+
| `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects; also set if the channel failed to start (bad URL/refused socket), in which case it stays disconnected until the worker restarts |
|
|
340
|
+
| `advisory` | nothing discoverable at the engine — **not** in the Cockpit; set `NANO_AGENTIC_URL` |
|
|
341
|
+
| `off` | visibility disabled (`NANO_AGENTIC=off`) |
|
|
342
|
+
| `?` | a live worker not yet reporting, or an older build predating these fields |
|
|
343
|
+
|
|
344
|
+
If workers show `advisory` (or stay `connecting`) while jobs still run, that's the
|
|
345
|
+
"connected to the engine but empty Cockpit" case: point them at the app with
|
|
346
|
+
`export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
|
|
347
|
+
|
|
322
348
|
**Secure mode (opt-in).** For a deployment where you want the visibility channel
|
|
323
349
|
authenticated (rather than open on the LAN), start the server **and** every worker
|
|
324
350
|
box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
|
package/c8ctl-plugin.js
CHANGED
|
@@ -4170,6 +4170,21 @@ function isLoopbackHost(hostname) {
|
|
|
4170
4170
|
return /^127(?:\.\d{1,3}){3}$/.test(h);
|
|
4171
4171
|
}
|
|
4172
4172
|
|
|
4173
|
+
/**
|
|
4174
|
+
* Format a hostname for the authority component of a `ws://`/`http://` URL:
|
|
4175
|
+
* a bare IPv6 literal (contains `:`, not already bracketed) is wrapped in `[…]`,
|
|
4176
|
+
* everything else is used verbatim. Idempotent — an already-bracketed host is
|
|
4177
|
+
* left as-is. Guards against building an invalid `ws://::1:3000/…` when a raw or
|
|
4178
|
+
* normalized IPv6 host (e.g. the `::1` constant) has not been bracketed.
|
|
4179
|
+
*
|
|
4180
|
+
* @param {string} host a hostname from `URL.hostname` or a normalized loopback
|
|
4181
|
+
* @returns {string}
|
|
4182
|
+
*/
|
|
4183
|
+
function wsHostPart(host) {
|
|
4184
|
+
const h = String(host || '');
|
|
4185
|
+
return h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4173
4188
|
/**
|
|
4174
4189
|
* Normalise the engine's `GET /console/api/projects` payload into the running
|
|
4175
4190
|
* embedded apps that advertise an agentic UI port. Accepts the shapes the
|
|
@@ -4207,24 +4222,26 @@ function normalizeProjectApps(projects) {
|
|
|
4207
4222
|
}
|
|
4208
4223
|
|
|
4209
4224
|
/**
|
|
4210
|
-
* Probe whether an embedded app's
|
|
4211
|
-
*
|
|
4225
|
+
* Probe whether an embedded app's `/agentic` endpoint answers a WebSocket
|
|
4226
|
+
* upgrade. Connects to `ws://<host>:<port>/agentic?token=…` (host defaults to
|
|
4227
|
+
* `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
|
|
4212
4228
|
* resolves `true` only if the socket opens within `timeoutMs`; a refused
|
|
4213
4229
|
* connection, the console proxy's deliberate `501`, a `404`, or a timeout all
|
|
4214
|
-
* resolve `false`.
|
|
4215
|
-
*
|
|
4230
|
+
* resolve `false`. Self-cleaning — the probe socket is closed as soon as the
|
|
4231
|
+
* outcome is known. Never throws.
|
|
4216
4232
|
*
|
|
4217
|
-
* @param {number} port the app's direct
|
|
4218
|
-
* @param {{ token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
|
|
4233
|
+
* @param {number} port the app's direct agentic port (`appUi.port`)
|
|
4234
|
+
* @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
|
|
4219
4235
|
* @returns {Promise<boolean>}
|
|
4220
4236
|
*/
|
|
4221
4237
|
function probeAgenticChannel(port, {
|
|
4238
|
+
host = '127.0.0.1',
|
|
4222
4239
|
token = LOCAL_AGENTIC_TOKEN,
|
|
4223
4240
|
WebSocketImpl = globalThis.WebSocket,
|
|
4224
4241
|
timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
|
|
4225
4242
|
} = {}) {
|
|
4226
4243
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
|
|
4227
|
-
const url = `ws
|
|
4244
|
+
const url = `ws://${wsHostPart(host)}:${port}/agentic?token=${encodeURIComponent(token)}`;
|
|
4228
4245
|
return new Promise((resolve) => {
|
|
4229
4246
|
let done = false;
|
|
4230
4247
|
let ws;
|
|
@@ -4249,19 +4266,21 @@ function probeAgenticChannel(port, {
|
|
|
4249
4266
|
|
|
4250
4267
|
/**
|
|
4251
4268
|
* Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
|
|
4252
|
-
* URL (#75). Reads `GET <engine>/console/api/projects`, keeps the apps that
|
|
4253
|
-
* advertise an agentic UI port, and WS-probes each app's
|
|
4254
|
-
*
|
|
4255
|
-
* WS-incapable console proxy).
|
|
4256
|
-
* loopback
|
|
4257
|
-
*
|
|
4258
|
-
*
|
|
4259
|
-
*
|
|
4260
|
-
*
|
|
4269
|
+
* URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
|
|
4270
|
+
* advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
|
|
4271
|
+
* engine's own host** to confirm the channel is actually served there (bypassing
|
|
4272
|
+
* the WS-incapable console proxy). Works cross-machine on a trusted LAN: a
|
|
4273
|
+
* loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
|
|
4274
|
+
* probes that same host — the port is taken from the projects API but the host is
|
|
4275
|
+
* always the engine's, so a rogue projects API can never steer a probe at the
|
|
4276
|
+
* worker's own loopback (#76). Enforces a single shared time budget across the
|
|
4277
|
+
* fetch + probes, and is fail-open: any error — not a nano engine (Camunda),
|
|
4278
|
+
* network failure, malformed body, or an overall timeout — degrades to `[]` so
|
|
4279
|
+
* the worker's real job is never blocked.
|
|
4261
4280
|
*
|
|
4262
|
-
* @param {string} engineBaseUrl the engine base URL (e.g. `http://
|
|
4281
|
+
* @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
|
|
4263
4282
|
* @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
|
|
4264
|
-
* @returns {Promise<Array<{ project: string, port: number, label?: string }>>}
|
|
4283
|
+
* @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
|
|
4265
4284
|
*/
|
|
4266
4285
|
async function discoverAgenticHubs(engineBaseUrl, {
|
|
4267
4286
|
token = LOCAL_AGENTIC_TOKEN,
|
|
@@ -4273,16 +4292,21 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
4273
4292
|
return [];
|
|
4274
4293
|
}
|
|
4275
4294
|
const base = engineBaseUrl.replace(/\/+$/, '');
|
|
4276
|
-
//
|
|
4277
|
-
//
|
|
4278
|
-
//
|
|
4295
|
+
// Discover against the ENGINE's own host — the app is embedded in the engine,
|
|
4296
|
+
// so its /agentic port lives on the same host the worker already trusts as its
|
|
4297
|
+
// engine (that's where it pulls jobs from). A loopback engine keeps probing
|
|
4298
|
+
// 127.0.0.1 (unchanged local behaviour); a remote/LAN engine (e.g.
|
|
4299
|
+
// merlin.local) steers the probe back to ITSELF, never at the worker's own
|
|
4300
|
+
// loopback services — which was the actual #76 concern (a rogue projects API
|
|
4301
|
+
// making the worker probe its own localhost). So the port comes from the
|
|
4302
|
+
// engine's projects API, but the HOST is always the engine's, never guessed.
|
|
4279
4303
|
let host;
|
|
4280
4304
|
try {
|
|
4281
4305
|
host = new URL(base).hostname;
|
|
4282
4306
|
} catch {
|
|
4283
4307
|
return [];
|
|
4284
4308
|
}
|
|
4285
|
-
|
|
4309
|
+
const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
|
|
4286
4310
|
// Single discovery budget: the projects fetch and the WS probes share ONE
|
|
4287
4311
|
// deadline, so total discovery can't approach 2× timeoutMs (the fetch could
|
|
4288
4312
|
// consume ~timeoutMs and then each probe was previously given a fresh full
|
|
@@ -4304,10 +4328,13 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
4304
4328
|
if (apps.length === 0) return [];
|
|
4305
4329
|
const remainingMs = deadline - Date.now();
|
|
4306
4330
|
if (remainingMs <= 0) return [];
|
|
4307
|
-
// Probe candidate ports concurrently within the remaining shared budget.
|
|
4331
|
+
// Probe candidate ports concurrently within the remaining shared budget. Each
|
|
4332
|
+
// surviving hub carries the engine host so the caller builds the right URL.
|
|
4308
4333
|
const settled = await Promise.all(apps.map(async (app) => {
|
|
4309
4334
|
try {
|
|
4310
|
-
return (await wsProbe(app.port, { token, timeoutMs: remainingMs }))
|
|
4335
|
+
return (await wsProbe(app.port, { host: probeHost, token, timeoutMs: remainingMs }))
|
|
4336
|
+
? { ...app, host: probeHost }
|
|
4337
|
+
: null;
|
|
4311
4338
|
} catch {
|
|
4312
4339
|
return null;
|
|
4313
4340
|
}
|
|
@@ -4324,7 +4351,8 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
4324
4351
|
* half-configured. No discovery attempted.
|
|
4325
4352
|
* - `{ status: 'connect', config }` — a target to connect to. Either the
|
|
4326
4353
|
* explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
|
|
4327
|
-
* single discovered app's
|
|
4354
|
+
* single discovered app's `ws://<engineHost>:<port>/agentic` (loopback for a
|
|
4355
|
+
* local engine, the engine's LAN host for a remote one).
|
|
4328
4356
|
* - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
|
|
4329
4357
|
* channel. Hard stop for the worker: it must not silently pick one.
|
|
4330
4358
|
* - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
|
|
@@ -4342,11 +4370,24 @@ async function resolveAgenticTarget(opts = {}) {
|
|
|
4342
4370
|
|
|
4343
4371
|
const hubs = await discoverAgenticHubs(base.url, { token: base.token, ...opts });
|
|
4344
4372
|
|
|
4373
|
+
// The host to suggest in operator-facing messages: the engine's own host
|
|
4374
|
+
// (bracketed if an IPv6 literal, so the suggested URL authority is valid), so a
|
|
4375
|
+
// remote-engine advisory names the reachable LAN host rather than 127.0.0.1.
|
|
4376
|
+
let suggestHost = '127.0.0.1';
|
|
4377
|
+
try {
|
|
4378
|
+
const h = new URL(base.url).hostname;
|
|
4379
|
+
suggestHost = wsHostPart(isLoopbackHost(h) ? '127.0.0.1' : h);
|
|
4380
|
+
} catch { /* keep the loopback default */ }
|
|
4381
|
+
|
|
4345
4382
|
if (hubs.length === 1) {
|
|
4346
|
-
const { project, port } = hubs[0];
|
|
4383
|
+
const { project, port, host } = hubs[0];
|
|
4347
4384
|
return {
|
|
4348
4385
|
status: 'connect',
|
|
4349
|
-
config: {
|
|
4386
|
+
config: {
|
|
4387
|
+
...base,
|
|
4388
|
+
url: `http://${wsHostPart(host)}:${port}`,
|
|
4389
|
+
discovered: { project, port, host },
|
|
4390
|
+
},
|
|
4350
4391
|
};
|
|
4351
4392
|
}
|
|
4352
4393
|
if (hubs.length > 1) {
|
|
@@ -4355,14 +4396,101 @@ async function resolveAgenticTarget(opts = {}) {
|
|
|
4355
4396
|
status: 'ambiguous',
|
|
4356
4397
|
candidates: hubs,
|
|
4357
4398
|
message: `multiple embedded apps expose an agentic channel (${list}); refusing to guess. `
|
|
4358
|
-
+
|
|
4399
|
+
+ `Disambiguate by setting NANO_AGENTIC_URL=http://${suggestHost}:<port> (or persisted agenticUrl) to the one you want.`,
|
|
4359
4400
|
};
|
|
4360
4401
|
}
|
|
4361
4402
|
return {
|
|
4362
4403
|
status: 'advisory',
|
|
4363
4404
|
message: `agentic visibility was not discoverable at ${base.url} — the embedded app port could `
|
|
4364
4405
|
+ 'not be found (not a nano engine, or its console projects API is absent). Set '
|
|
4365
|
-
+
|
|
4406
|
+
+ `NANO_AGENTIC_URL=http://${suggestHost}:<appUi.port> to enable the visibility channel. Continuing without it.`,
|
|
4407
|
+
};
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
/**
|
|
4411
|
+
* Collapse an agentic disconnect/failure detail into the single short string the
|
|
4412
|
+
* marker's `agentic.message` field carries (#99 contract). Accepts the close
|
|
4413
|
+
* `info` the work channel's onDisconnect passes (transport-dependent shape, e.g.
|
|
4414
|
+
* `{ code, reason, local }`), a thrown Error, or a bare string.
|
|
4415
|
+
* @param {unknown} x diagnostic input (close info, Error, or string)
|
|
4416
|
+
* @returns {string|null} a human-readable reason, or null when nothing useful
|
|
4417
|
+
*/
|
|
4418
|
+
function normalizeAgenticMessage(x) {
|
|
4419
|
+
// Collapse an agentic disconnect/failure detail into the single short string
|
|
4420
|
+
// the marker's `agentic.message` field carries (#99 contract). Accepts the
|
|
4421
|
+
// close `info` the work channel's onDisconnect passes (transport-dependent
|
|
4422
|
+
// shape, e.g. `{ code, reason, local }`), a thrown Error, or a bare string,
|
|
4423
|
+
// and returns a human-readable reason or null when there is nothing useful.
|
|
4424
|
+
if (x == null) return null;
|
|
4425
|
+
if (typeof x === 'string') return x.trim() || null;
|
|
4426
|
+
if (x instanceof Error) return x.message ? String(x.message) : String(x);
|
|
4427
|
+
if (typeof x === 'object') {
|
|
4428
|
+
const reason = typeof x.reason === 'string' ? x.reason.trim() : '';
|
|
4429
|
+
const code = x.code != null && x.code !== '' ? String(x.code) : '';
|
|
4430
|
+
if (reason && code) return `${reason} (code ${code})`;
|
|
4431
|
+
if (reason) return reason;
|
|
4432
|
+
if (code) return `close code ${code}`;
|
|
4433
|
+
if (x.message) return String(x.message);
|
|
4434
|
+
if (x.local === true) return 'closed locally';
|
|
4435
|
+
if (x.local === false) return 'connection dropped';
|
|
4436
|
+
return null;
|
|
4437
|
+
}
|
|
4438
|
+
return String(x);
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/**
|
|
4442
|
+
* Map a resolved `resolveAgenticTarget` result to the INITIAL agentic-channel
|
|
4443
|
+
* state persisted on the supervisor activity marker (#99). Pure so the marker
|
|
4444
|
+
* producer's state transitions are unit-testable without a live broker/SDK
|
|
4445
|
+
* client — a regression here would leave every supervised worker stuck at
|
|
4446
|
+
* `?`/`starting`, which the reader/renderer tests can't catch. `connected`/
|
|
4447
|
+
* `disconnected` are layered on top of this base by the channel lifecycle
|
|
4448
|
+
* (a `{ ...state, status }` merge). The `ambiguous` status is a hard-stop
|
|
4449
|
+
* handled by the caller (never reaches the marker), so it degrades to `off`
|
|
4450
|
+
* here. `safeUrl` mirrors the caller's defensive display-URL builder.
|
|
4451
|
+
* @param {{ status?: string, config?: any, message?: string }} target
|
|
4452
|
+
* @param {(u: string) => (string|null)} [safeUrl]
|
|
4453
|
+
*/
|
|
4454
|
+
function agenticStateForTarget(target, safeUrl = (u) => u) {
|
|
4455
|
+
switch (target?.status) {
|
|
4456
|
+
case 'connect': {
|
|
4457
|
+
const cfg = target.config || {};
|
|
4458
|
+
return {
|
|
4459
|
+
status: 'connecting',
|
|
4460
|
+
mode: cfg.secure ? 'secure' : 'local',
|
|
4461
|
+
url: safeUrl(cfg.url),
|
|
4462
|
+
discovered: cfg.discovered || null,
|
|
4463
|
+
};
|
|
4464
|
+
}
|
|
4465
|
+
case 'advisory':
|
|
4466
|
+
// Retain the discovery diagnostic so the supervisor can distinguish a
|
|
4467
|
+
// missing projects API, a timeout, or a non-Nano endpoint (#99).
|
|
4468
|
+
return { status: 'advisory', message: target.message || null };
|
|
4469
|
+
case 'off':
|
|
4470
|
+
default:
|
|
4471
|
+
return { status: 'off' };
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
/**
|
|
4476
|
+
* Build the supervisor activity-marker payload the worker atomically writes for
|
|
4477
|
+
* `supervisor status`. Pure so the producer's field set is unit-testable without
|
|
4478
|
+
* spawning a worker: the reader/renderer tests exercise a hand-written marker and
|
|
4479
|
+
* `agenticStateForTarget` in isolation, so a regression that dropped `engine` or
|
|
4480
|
+
* `agentic` from THIS payload — leaving every supervised worker's Engine/Agentic
|
|
4481
|
+
* column stuck at `?` — would otherwise slip through. `jobs` is the live active-job
|
|
4482
|
+
* list; `busy` is derived so callers can't desync it from `jobs`.
|
|
4483
|
+
* @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number}>, engine:(string|null), agentic:object }} fields
|
|
4484
|
+
*/
|
|
4485
|
+
function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
|
|
4486
|
+
const jobList = Array.isArray(jobs) ? jobs : [];
|
|
4487
|
+
return {
|
|
4488
|
+
pid,
|
|
4489
|
+
updatedAt,
|
|
4490
|
+
busy: jobList.length > 0,
|
|
4491
|
+
jobs: jobList,
|
|
4492
|
+
engine: engine ?? null,
|
|
4493
|
+
agentic,
|
|
4366
4494
|
};
|
|
4367
4495
|
}
|
|
4368
4496
|
|
|
@@ -4654,10 +4782,29 @@ async function workAgent(req, flags) {
|
|
|
4654
4782
|
installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
|
|
4655
4783
|
}
|
|
4656
4784
|
const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
|
|
4785
|
+
// Which engine this worker polls jobs from + the live agentic-visibility
|
|
4786
|
+
// channel status, both surfaced to `supervisor status` via the activity
|
|
4787
|
+
// marker (#99). `agenticState` starts 'starting' and is updated once the
|
|
4788
|
+
// channel target is resolved and again on each connect/disconnect below.
|
|
4789
|
+
// The engine must name the ACTUAL polling authority: jobs are activated by
|
|
4790
|
+
// `camunda.createJobWorker()` against the active c8ctl profile engine
|
|
4791
|
+
// (`camunda.getConfig().restAddress`), whereas `restConfig` can honor the
|
|
4792
|
+
// auxiliary NANO_REST_URL/NANO_BASE_URL/nanoUrl overrides (for `--auto`
|
|
4793
|
+
// reads). Derive from the profile engine, using restConfig only as a
|
|
4794
|
+
// fallback, so the column can't advertise an override host jobs aren't
|
|
4795
|
+
// polled from.
|
|
4796
|
+
const workerEngine = (() => {
|
|
4797
|
+
try {
|
|
4798
|
+
const profileBase = normalizeRestBase(camunda?.getConfig?.()?.restAddress);
|
|
4799
|
+
if (profileBase) return profileBase;
|
|
4800
|
+
} catch { /* degrade to the auxiliary REST config below */ }
|
|
4801
|
+
return restConfig?.baseUrl || null;
|
|
4802
|
+
})();
|
|
4803
|
+
let agenticState = { status: 'starting' };
|
|
4657
4804
|
const writeActivity = () => {
|
|
4658
4805
|
if (!activityFile) return;
|
|
4659
4806
|
const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
|
|
4660
|
-
const payload = { pid: process.pid, updatedAt: Date.now(),
|
|
4807
|
+
const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState });
|
|
4661
4808
|
const tmp = `${activityFile}.${process.pid}.tmp`;
|
|
4662
4809
|
try {
|
|
4663
4810
|
mkdirSync(dirname(activityFile), { recursive: true });
|
|
@@ -4706,9 +4853,22 @@ async function workAgent(req, flags) {
|
|
|
4706
4853
|
// NANO_AGENTIC=off disables it (see resolveAgenticConfig).
|
|
4707
4854
|
const agenticTarget = await resolveAgenticTarget({ logger });
|
|
4708
4855
|
let agenticCfg = null;
|
|
4856
|
+
// buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
|
|
4857
|
+
// This is only the display URL for the activity marker, so compute it
|
|
4858
|
+
// defensively: a bad URL must be recorded as a channel failure (via the
|
|
4859
|
+
// createWorkChannel try/catch below), never crash the worker before it — which
|
|
4860
|
+
// would violate the best-effort channel contract and cause a restart loop.
|
|
4861
|
+
const safeAgenticDisplayUrl = (u) => {
|
|
4862
|
+
try { return redactAgenticUrl(buildAgenticUrl(u, {})); }
|
|
4863
|
+
catch { return null; }
|
|
4864
|
+
};
|
|
4709
4865
|
switch (agenticTarget.status) {
|
|
4710
4866
|
case 'connect':
|
|
4711
4867
|
agenticCfg = agenticTarget.config;
|
|
4868
|
+
// 'connecting' until the socket actually opens (wired on the channel
|
|
4869
|
+
// lifecycle below). Carry the resolved mode/target/discovery so the
|
|
4870
|
+
// supervisor can show WHERE presence is being announced (#99).
|
|
4871
|
+
agenticState = agenticStateForTarget(agenticTarget, safeAgenticDisplayUrl);
|
|
4712
4872
|
break;
|
|
4713
4873
|
case 'ambiguous':
|
|
4714
4874
|
// The operator ran with visibility on-by-default but the hub is
|
|
@@ -4717,13 +4877,19 @@ async function workAgent(req, flags) {
|
|
|
4717
4877
|
process.exit(1);
|
|
4718
4878
|
break;
|
|
4719
4879
|
case 'advisory':
|
|
4880
|
+
agenticState = agenticStateForTarget(agenticTarget);
|
|
4720
4881
|
logger.info(` agentic channel: ${agenticTarget.message}`);
|
|
4721
4882
|
break;
|
|
4722
4883
|
case 'off':
|
|
4723
4884
|
default:
|
|
4885
|
+
agenticState = agenticStateForTarget(agenticTarget);
|
|
4724
4886
|
logger.info(' agentic channel: disabled — the off-switch is set (NANO_AGENTIC=off or persisted agentic:false). Clear it to use default LOCAL visibility.');
|
|
4725
4887
|
break;
|
|
4726
4888
|
}
|
|
4889
|
+
// Persist the resolved channel state to the activity marker now, so
|
|
4890
|
+
// `supervisor status` reflects connecting/advisory/off immediately, before
|
|
4891
|
+
// the socket opens (or without a channel at all).
|
|
4892
|
+
writeActivity();
|
|
4727
4893
|
if (agenticCfg) {
|
|
4728
4894
|
try {
|
|
4729
4895
|
workChannel = await createWorkChannel({
|
|
@@ -4744,12 +4910,35 @@ async function workAgent(req, flags) {
|
|
|
4744
4910
|
const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
|
|
4745
4911
|
const mode = agenticCfg.secure ? 'secure' : 'local';
|
|
4746
4912
|
if (agenticCfg.discovered) {
|
|
4747
|
-
|
|
4913
|
+
const d = agenticCfg.discovered;
|
|
4914
|
+
logger.info(` agentic channel: auto-discovered ${d.project} on the app's /agentic port ${wsHostPart(d.host)}:${d.port} (bypassing the WS-incapable console proxy).`);
|
|
4748
4915
|
}
|
|
4749
4916
|
logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
|
|
4917
|
+
// Track the live connection state on the activity marker so the
|
|
4918
|
+
// supervisor shows connected↔disconnected transitions (#99). onConnect
|
|
4919
|
+
// fires only for listeners present at first open, so also reconcile the
|
|
4920
|
+
// already-open case synchronously via connected(). If the socket opened
|
|
4921
|
+
// and then dropped inside the createWorkChannel() await window (before
|
|
4922
|
+
// these listeners existed), connected() is false but everConnected() is
|
|
4923
|
+
// true — record that as `disconnected` rather than leaving it stuck at
|
|
4924
|
+
// `connecting`. A close carries a normalized diagnostic under the contract
|
|
4925
|
+
// `agentic.message` field (not `reason`) so a hub drop explains WHY; a
|
|
4926
|
+
// fresh (re)connect clears any stale message.
|
|
4927
|
+
const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
|
|
4928
|
+
workChannel.onConnect(() => markAgentic('connected'));
|
|
4929
|
+
workChannel.onReconnect(() => markAgentic('connected'));
|
|
4930
|
+
workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
|
|
4931
|
+
if (workChannel.connected()) markAgentic('connected');
|
|
4932
|
+
else if (workChannel.everConnected()) markAgentic('disconnected');
|
|
4750
4933
|
} catch (err) {
|
|
4751
4934
|
// Never let a channel failure stop the worker from doing its actual job.
|
|
4752
4935
|
workChannel = null;
|
|
4936
|
+
// Retain the failure reason on the marker so the supervisor can show WHY
|
|
4937
|
+
// presence dropped (bad URL, refused socket, …), not just `disconnected`.
|
|
4938
|
+
// The contract diagnostic field is `agentic.message` (#99), matching the
|
|
4939
|
+
// live-disconnect path above — keep the key consistent, not `reason`.
|
|
4940
|
+
agenticState = { ...agenticState, status: 'disconnected', message: normalizeAgenticMessage(err) };
|
|
4941
|
+
writeActivity();
|
|
4753
4942
|
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
4754
4943
|
}
|
|
4755
4944
|
// C4 (#43): observe the client's built-in outbound buffer across the
|
|
@@ -5614,6 +5803,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
5614
5803
|
// Per-job activity (supervised workers only). Guard on pid so a stale marker
|
|
5615
5804
|
// left by a previous incarnation can't show a dead job as in-flight.
|
|
5616
5805
|
let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs, sinceEpochMs }] }
|
|
5806
|
+
let engine = null; // job-polling engine base URL this worker reported (#99)
|
|
5807
|
+
let agentic = null; // { status, mode, url, discovered } agentic-channel state (#99)
|
|
5617
5808
|
if (alive) {
|
|
5618
5809
|
const act = readWorkerActivity(w.id);
|
|
5619
5810
|
if (act && act.pid === w.pid) {
|
|
@@ -5628,6 +5819,10 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
5628
5819
|
}))
|
|
5629
5820
|
: [];
|
|
5630
5821
|
activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
|
|
5822
|
+
// Engine + agentic-channel status ride the same pid-guarded marker, so a
|
|
5823
|
+
// stale incarnation can't show a dead worker as connected to a hub.
|
|
5824
|
+
engine = typeof act.engine === 'string' && act.engine ? act.engine : null;
|
|
5825
|
+
agentic = act.agentic && typeof act.agentic === 'object' ? act.agentic : null;
|
|
5631
5826
|
}
|
|
5632
5827
|
// No marker (or a stale-pid one): leave activity null → rendered as unknown.
|
|
5633
5828
|
}
|
|
@@ -5642,6 +5837,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
5642
5837
|
lastExit: w.lastExit ?? null,
|
|
5643
5838
|
args: Array.isArray(w.args) ? w.args : [],
|
|
5644
5839
|
activity,
|
|
5840
|
+
engine,
|
|
5841
|
+
agentic,
|
|
5645
5842
|
};
|
|
5646
5843
|
}
|
|
5647
5844
|
|
|
@@ -5668,6 +5865,10 @@ function supervisorStatusSignature(workers) {
|
|
|
5668
5865
|
w.activity
|
|
5669
5866
|
? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}`).sort()
|
|
5670
5867
|
: null,
|
|
5868
|
+
// Engine + agentic-channel status: a connect/disconnect or an engine
|
|
5869
|
+
// change is a real transition that must repaint attached consoles (#99).
|
|
5870
|
+
w.engine ?? '',
|
|
5871
|
+
w.agentic ? (w.agentic.status ?? '') : null,
|
|
5671
5872
|
]),
|
|
5672
5873
|
);
|
|
5673
5874
|
}
|
|
@@ -5684,6 +5885,37 @@ function supervisorJobCell(w) {
|
|
|
5684
5885
|
return `${first.key}${more}${dur}`;
|
|
5685
5886
|
}
|
|
5686
5887
|
|
|
5888
|
+
/**
|
|
5889
|
+
* ENGINE cell: the authority (host:port) of the engine this worker polls jobs
|
|
5890
|
+
* from, so an operator can see cross-machine fleets at a glance. `-` for a
|
|
5891
|
+
* down/stopping worker, `?` for a live worker not (yet) reporting or on an
|
|
5892
|
+
* older build whose marker predates this field. A non-URL engine string falls
|
|
5893
|
+
* back to the raw value.
|
|
5894
|
+
*/
|
|
5895
|
+
function supervisorEngineCell(w) {
|
|
5896
|
+
if (w.state !== 'running') return '-';
|
|
5897
|
+
if (!w.activity) return '?'; // alive but not reporting
|
|
5898
|
+
if (!w.engine) return '?'; // reporting, but marker predates the engine field
|
|
5899
|
+
try {
|
|
5900
|
+
const host = new URL(w.engine).host;
|
|
5901
|
+
return host || String(w.engine); // a scheme-less string parses host-empty
|
|
5902
|
+
} catch { return String(w.engine); }
|
|
5903
|
+
}
|
|
5904
|
+
|
|
5905
|
+
/**
|
|
5906
|
+
* AGENTIC cell: the visibility-channel status word
|
|
5907
|
+
* (`connected`/`connecting`/`disconnected`/`advisory`/`off`/`starting`), so an
|
|
5908
|
+
* operator can tell whether presence actually reached the Workforce hub. `-`
|
|
5909
|
+
* for a down/stopping worker, `?` for a live worker not (yet) reporting or on an
|
|
5910
|
+
* older build whose marker predates this field.
|
|
5911
|
+
*/
|
|
5912
|
+
function supervisorAgenticCell(w) {
|
|
5913
|
+
if (w.state !== 'running') return '-';
|
|
5914
|
+
if (!w.activity) return '?'; // alive but not reporting
|
|
5915
|
+
if (!w.agentic || !w.agentic.status) return '?'; // marker predates the agentic field
|
|
5916
|
+
return String(w.agentic.status);
|
|
5917
|
+
}
|
|
5918
|
+
|
|
5687
5919
|
/**
|
|
5688
5920
|
* Re-age a supervisor status snapshot to `now`, recomputing the ticking
|
|
5689
5921
|
* durations (`uptimeMs`, per-job `sinceMs`) from the absolute base epochs the
|
|
@@ -5829,14 +6061,19 @@ function formatSupervisorStatus(status) {
|
|
|
5829
6061
|
id: String(w.id),
|
|
5830
6062
|
profile: String(w.profile),
|
|
5831
6063
|
state: String(w.state),
|
|
6064
|
+
engine: supervisorEngineCell(w),
|
|
6065
|
+
agentic: supervisorAgenticCell(w),
|
|
5832
6066
|
job: supervisorJobCell(w),
|
|
5833
6067
|
pid: w.pid ? String(w.pid) : '-',
|
|
5834
6068
|
restarts: String(w.restarts),
|
|
5835
6069
|
uptime: w.state === 'running' ? formatDuration(w.uptimeMs) : '-',
|
|
5836
6070
|
last: w.lastExit ? String(w.lastExit) : '-',
|
|
5837
6071
|
}));
|
|
5838
|
-
|
|
5839
|
-
|
|
6072
|
+
// ENGINE + AGENTIC sit early (just after STATE) so the pinned live view's
|
|
6073
|
+
// width clamp (which trims from the right) drops the least-critical columns
|
|
6074
|
+
// (LAST EXIT, UPTIME) first and keeps the visibility diagnostics visible.
|
|
6075
|
+
const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', engine: 'ENGINE', agentic: 'AGENTIC', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
|
|
6076
|
+
const cols = ['id', 'profile', 'state', 'engine', 'agentic', 'job', 'pid', 'restarts', 'uptime', 'last'];
|
|
5840
6077
|
const width = {};
|
|
5841
6078
|
for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
|
|
5842
6079
|
const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
|
|
@@ -8681,6 +8918,11 @@ export {
|
|
|
8681
8918
|
printSupervisorStatus,
|
|
8682
8919
|
supervisorStatusSignature,
|
|
8683
8920
|
supervisorJobCell,
|
|
8921
|
+
supervisorEngineCell,
|
|
8922
|
+
supervisorAgenticCell,
|
|
8923
|
+
agenticStateForTarget,
|
|
8924
|
+
normalizeAgenticMessage,
|
|
8925
|
+
buildActivityPayload,
|
|
8684
8926
|
supervisorWorkerActivityFile,
|
|
8685
8927
|
WORK_FORWARD_FLAGS,
|
|
8686
8928
|
installParentDeathWatchdog,
|
|
@@ -8741,7 +8983,7 @@ export const metadata = {
|
|
|
8741
8983
|
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
8742
8984
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
8743
8985
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
8744
|
-
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (
|
|
8986
|
+
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (state, ENGINE + AGENTIC visibility diagnostics, serviced job / idle, pid, restarts, uptime) without the console' },
|
|
8745
8987
|
{ command: 'c8ctl nano supervisor add decider --max-parallel 2', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
|
|
8746
8988
|
{ command: 'c8ctl nano supervisor add reviewer --instances 3', description: 'Add 3 distinct auto-named instances of a profile in one call' },
|
|
8747
8989
|
{ command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.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",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.37.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.37.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.37.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.37.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.37.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.37.0",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.37.0"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/work-channel.mjs
CHANGED
|
@@ -118,6 +118,7 @@ function presenceCapability(capability, jobs) {
|
|
|
118
118
|
* @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
|
|
119
119
|
* @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
|
|
120
120
|
* @property {() => boolean} connected whether the channel is currently open
|
|
121
|
+
* @property {() => boolean} everConnected whether the channel has ever opened (stays true after a later close)
|
|
121
122
|
* @property {() => number} buffered outbound frames currently buffered awaiting the channel
|
|
122
123
|
* @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
|
|
123
124
|
*/
|
|
@@ -272,6 +273,12 @@ export async function createWorkChannel(opts) {
|
|
|
272
273
|
onReconnect: subscribe(reconnectListeners),
|
|
273
274
|
onDisconnect: subscribe(disconnectListeners),
|
|
274
275
|
connected: () => client.connected,
|
|
276
|
+
// Whether the channel has ever opened (even if it has since closed). Lets a
|
|
277
|
+
// late subscriber tell "still connecting, never opened" (false) apart from
|
|
278
|
+
// "opened then dropped before I subscribed" (true), so an initial close that
|
|
279
|
+
// fires inside the createWorkChannel() await window is reconciled to
|
|
280
|
+
// `disconnected` rather than left stuck at `connecting`.
|
|
281
|
+
everConnected: () => hasConnected,
|
|
275
282
|
buffered: () => client.buffered,
|
|
276
283
|
async stop(reason = 'worker stopped') {
|
|
277
284
|
try {
|