c8ctl-plugin-nano 1.35.0 → 1.35.2
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/c8ctl-plugin.js +137 -6
- package/package.json +8 -8
package/c8ctl-plugin.js
CHANGED
|
@@ -2549,14 +2549,18 @@ function resourceContentUrl(baseUrl, resourceKey) {
|
|
|
2549
2549
|
// is surfaced as a ProvisionError so the caller fails the job with a clear
|
|
2550
2550
|
// provisioning message (never runs an agent with a silently-empty prompt).
|
|
2551
2551
|
async function fetchLinkedResourceContent(resourceKey, opts = {}) {
|
|
2552
|
-
const { baseUrl, token, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
|
|
2552
|
+
const { baseUrl, token, authHeaders, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
|
|
2553
2553
|
const url = resourceContentUrl(baseUrl, resourceKey);
|
|
2554
2554
|
const controller = new AbortController();
|
|
2555
2555
|
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
2556
2556
|
let res;
|
|
2557
2557
|
try {
|
|
2558
2558
|
const headers = { Accept: 'application/octet-stream' };
|
|
2559
|
-
|
|
2559
|
+
// A ready-made auth-headers map (from the activating SDK client's
|
|
2560
|
+
// getAuthHeaders(), covering OAuth/basic/none) wins over a bare token. An
|
|
2561
|
+
// empty map means unauthenticated — deliberately no Authorization header.
|
|
2562
|
+
if (authHeaders && typeof authHeaders === 'object') Object.assign(headers, authHeaders);
|
|
2563
|
+
else if (token) headers.Authorization = `Bearer ${token}`;
|
|
2560
2564
|
res = await fetchImpl(url, { method: 'GET', headers, signal: controller.signal });
|
|
2561
2565
|
} catch (err) {
|
|
2562
2566
|
throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: ${err && err.message ? err.message : String(err)}`);
|
|
@@ -2581,14 +2585,57 @@ async function fetchLinkedResourceContent(resourceKey, opts = {}) {
|
|
|
2581
2585
|
// prompt resource cannot be fetched — a provisioning failure, not a silent
|
|
2582
2586
|
// empty-prompt run.
|
|
2583
2587
|
async function resolveLinkedPrompt(customHeaders, opts = {}) {
|
|
2584
|
-
const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, fetchImpl, timeoutMs } = opts;
|
|
2588
|
+
const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, authHeaders, fetchImpl, timeoutMs } = opts;
|
|
2585
2589
|
const entry = pickLinkedResource(parseLinkedResources(customHeaders), linkName);
|
|
2586
2590
|
if (!entry) return null;
|
|
2587
2591
|
const resourceKey = entry.resourceKey;
|
|
2588
|
-
const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, fetchImpl, timeoutMs });
|
|
2592
|
+
const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, authHeaders, fetchImpl, timeoutMs });
|
|
2589
2593
|
return { basePrompt, resourceKey, resourceType: entry.resourceType ?? null, linkName: String(linkName) };
|
|
2590
2594
|
}
|
|
2591
2595
|
|
|
2596
|
+
// Normalize a client-configured REST address for use as a linked-resource fetch
|
|
2597
|
+
// base. The SDK's restAddress may or may not already include the `/v2` API
|
|
2598
|
+
// prefix (CAMUNDA_REST_ADDRESS accepts either); resourceContentUrl re-adds it,
|
|
2599
|
+
// so strip a trailing `/v2` (and any trailing slashes) to avoid a double `/v2`.
|
|
2600
|
+
function normalizeRestBase(addr) {
|
|
2601
|
+
return String(addr || '').replace(/\/+$/, '').replace(/\/v2$/i, '');
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
// Derive the linked-resource fetch base URL + auth headers from the SAME SDK
|
|
2605
|
+
// client that activated the job. A linked-resource `resourceKey` is broker-local,
|
|
2606
|
+
// so prompt content must be fetched from the broker this worker is connected to
|
|
2607
|
+
// — never a localhost default (the cause of "prompt resource N fetch failed").
|
|
2608
|
+
// An explicit NANO_REST_URL / NANO_REST_TOKEN override still wins as an operator
|
|
2609
|
+
// escape hatch. Both getConfig()/getAuthHeaders() are guarded so an older or
|
|
2610
|
+
// atypical client runtime degrades to the override/legacy path rather than throw.
|
|
2611
|
+
//
|
|
2612
|
+
// TODO: once c8ctl bumps @camunda8/orchestration-cluster-api to v10 (10.0.0-alpha
|
|
2613
|
+
// exposes the typed camunda.getResourceContentBinary({resourceKey}) → Blob), drop
|
|
2614
|
+
// this raw /content/binary fetch and call that method directly. The pinned ^9.1.0
|
|
2615
|
+
// SDK only exposes the deprecated getResourceContent, which 406s for generic
|
|
2616
|
+
// (Markdown) prompt resources — see camunda/orchestration-cluster-api-js.
|
|
2617
|
+
async function resolveLinkedPromptSource(camunda, env = process.env) {
|
|
2618
|
+
let baseUrl = env.NANO_REST_URL || '';
|
|
2619
|
+
if (!baseUrl && camunda && typeof camunda.getConfig === 'function') {
|
|
2620
|
+
try {
|
|
2621
|
+
baseUrl = normalizeRestBase(camunda.getConfig().restAddress);
|
|
2622
|
+
} catch {
|
|
2623
|
+
// ignore — fall back to the legacy resolveBrokerRestConfig base below
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
let authHeaders;
|
|
2627
|
+
if (env.NANO_REST_TOKEN) {
|
|
2628
|
+
authHeaders = { Authorization: `Bearer ${env.NANO_REST_TOKEN}` };
|
|
2629
|
+
} else if (camunda && typeof camunda.getAuthHeaders === 'function') {
|
|
2630
|
+
try {
|
|
2631
|
+
authHeaders = await camunda.getAuthHeaders();
|
|
2632
|
+
} catch {
|
|
2633
|
+
authHeaders = undefined;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
return { baseUrl, authHeaders };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2592
2639
|
// Secrets are referenced by NAME, never by value, in the model. A resolver maps
|
|
2593
2640
|
// a name → value at run time. The wrapper injects them into the child ENV, so
|
|
2594
2641
|
// values never appear in argv or `docker inspect`.
|
|
@@ -4357,6 +4404,16 @@ async function workAgent(req, flags) {
|
|
|
4357
4404
|
// path via NANO_SUPERVISOR_ACTIVITY_FILE; a standalone `nano work` has no such
|
|
4358
4405
|
// env var and writes nothing (this is entirely advisory).
|
|
4359
4406
|
const activityFile = process.env.NANO_SUPERVISOR_ACTIVITY_FILE || null;
|
|
4407
|
+
// Supervised workers (spawned by the daemon, hence NANO_SUPERVISOR_ACTIVITY_FILE)
|
|
4408
|
+
// arm a parent-death watchdog: if the daemon dies *ungracefully* (SIGKILL /
|
|
4409
|
+
// crash / its host force-killed) it can't run its child-reaping shutdown, and
|
|
4410
|
+
// this worker would otherwise idle forever as an orphan reparented to init.
|
|
4411
|
+
// The watchdog makes the child self-exit the moment it is reparented. A
|
|
4412
|
+
// standalone `nano work` (no activity file) keeps classic nohup semantics.
|
|
4413
|
+
if (activityFile) {
|
|
4414
|
+
const daemonPid = Number.parseInt(process.env.NANO_SUPERVISOR_DAEMON_PID ?? '', 10);
|
|
4415
|
+
installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
|
|
4416
|
+
}
|
|
4360
4417
|
const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
|
|
4361
4418
|
const writeActivity = () => {
|
|
4362
4419
|
if (!activityFile) return;
|
|
@@ -4533,8 +4590,13 @@ async function workAgent(req, flags) {
|
|
|
4533
4590
|
let promptResourceKey = null;
|
|
4534
4591
|
let basePromptOverride;
|
|
4535
4592
|
try {
|
|
4593
|
+
// Fetch the prompt from the broker the SDK client is connected to,
|
|
4594
|
+
// deriving base URL + auth from that client (not restConfig, whose base
|
|
4595
|
+
// defaults to localhost) — the resourceKey is broker-local.
|
|
4596
|
+
const promptSource = await resolveLinkedPromptSource(camunda);
|
|
4536
4597
|
const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
|
|
4537
|
-
baseUrl: restConfig.baseUrl,
|
|
4598
|
+
baseUrl: promptSource.baseUrl || restConfig.baseUrl,
|
|
4599
|
+
authHeaders: promptSource.authHeaders,
|
|
4538
4600
|
token: restConfig.token,
|
|
4539
4601
|
});
|
|
4540
4602
|
if (linked) {
|
|
@@ -5497,6 +5559,63 @@ function waitForChildExit(child, timeoutMs) {
|
|
|
5497
5559
|
});
|
|
5498
5560
|
}
|
|
5499
5561
|
|
|
5562
|
+
/**
|
|
5563
|
+
* Self-terminate a supervised worker if its parent daemon dies *ungracefully*.
|
|
5564
|
+
*
|
|
5565
|
+
* The daemon reaps its `nano work` children on a clean stop/SIGTERM/SIGINT (see
|
|
5566
|
+
* `shutdown`). But a `SIGKILL`, a crash, or the daemon's host process being
|
|
5567
|
+
* force-killed cannot run that path, and the children — spawned attached, with
|
|
5568
|
+
* no controlling TTY — would otherwise survive forever as orphans reparented to
|
|
5569
|
+
* init (ppid 1). That is exactly how a test/agent run that force-kills its
|
|
5570
|
+
* supervisor leaks a whole idle worker fleet.
|
|
5571
|
+
*
|
|
5572
|
+
* The watchdog closes that gap from the child's side. The parent to watch is the
|
|
5573
|
+
* daemon pid the spawn recorded (`parentPid`, forwarded via
|
|
5574
|
+
* NANO_SUPERVISOR_DAEMON_PID) rather than a late `process.ppid` sample: a worker
|
|
5575
|
+
* whose ~async startup (an 8k-line dynamic import) is outrun by the daemon's
|
|
5576
|
+
* death is *already* reparented to init by the time it arms, so a bare
|
|
5577
|
+
* `process.ppid` read would miss the very race that leaks. Given the known pid,
|
|
5578
|
+
* the worker self-reaps whenever it is reparented away from it OR that pid is
|
|
5579
|
+
* gone — and does so *immediately* if it was orphaned during its own startup.
|
|
5580
|
+
*
|
|
5581
|
+
* The poll timer is unref'd so it never keeps an otherwise-idle worker alive;
|
|
5582
|
+
* the real work loop (or, in tests, an explicit keep-alive) holds the event loop
|
|
5583
|
+
* open while the worker is meant to run.
|
|
5584
|
+
*
|
|
5585
|
+
* Unix-only: reparent-to-init is a POSIX semantic; Windows job objects are the
|
|
5586
|
+
* equivalent lifecycle tie and are out of scope here (matching the daemon's
|
|
5587
|
+
* other `win32` guards). Returns a canceller so callers/tests can stop it.
|
|
5588
|
+
*/
|
|
5589
|
+
function installParentDeathWatchdog({ intervalMs = 2000, parentPid, onOrphan, readPpid } = {}) {
|
|
5590
|
+
if (osPlatform() === 'win32') return () => {};
|
|
5591
|
+
// `readPpid` is an injectable seam (defaults to the live value) so the pid-1
|
|
5592
|
+
// container case — which a normal test process can't reproduce, since its own
|
|
5593
|
+
// ppid is never 1 — is unit-testable.
|
|
5594
|
+
const ppid = typeof readPpid === 'function' ? readPpid : () => process.ppid;
|
|
5595
|
+
// Prefer the daemon pid the spawn recorded (`explicit`); fall back to the
|
|
5596
|
+
// current parent. An explicit pid is authoritative even if it is 1 — the
|
|
5597
|
+
// daemon may legitimately run as PID 1 (a container entrypoint) — so we must
|
|
5598
|
+
// NOT treat that as "orphaned". A *fallback* ppid of 1, by contrast, means we
|
|
5599
|
+
// were already reparented to init with no daemon left to watch.
|
|
5600
|
+
const explicit = Number.isInteger(parentPid) && parentPid > 0;
|
|
5601
|
+
const watched = explicit ? parentPid : ppid();
|
|
5602
|
+
const orphaned = typeof onOrphan === 'function' ? onOrphan : () => process.exit(0);
|
|
5603
|
+
const isOrphan = () => {
|
|
5604
|
+
// Reparented away from the daemon (typically to init, pid 1) → orphaned.
|
|
5605
|
+
if (ppid() !== watched) return true;
|
|
5606
|
+
// Defensive: the daemon pid is gone even though ppid still names it (a
|
|
5607
|
+
// zombie/racey read). ESRCH ⇒ gone; EPERM ⇒ alive but not ours to signal.
|
|
5608
|
+
try { process.kill(watched, 0); return false; } catch (err) { return err.code !== 'EPERM'; }
|
|
5609
|
+
};
|
|
5610
|
+
// Orphaned already — self-reap now rather than idle forever. Either we fell
|
|
5611
|
+
// back to ppid and it is already init (no known parent), or the recorded
|
|
5612
|
+
// daemon is verifiably gone/reparented (e.g. it died before we armed).
|
|
5613
|
+
if ((!explicit && watched === 1) || isOrphan()) { orphaned(); return () => {}; }
|
|
5614
|
+
const timer = setInterval(() => { if (isOrphan()) { clearInterval(timer); orphaned(); } }, intervalMs);
|
|
5615
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
5616
|
+
return () => { try { clearInterval(timer); } catch { /* ignore */ } };
|
|
5617
|
+
}
|
|
5618
|
+
|
|
5500
5619
|
// --- Daemon ----------------------------------------------------------------
|
|
5501
5620
|
|
|
5502
5621
|
/**
|
|
@@ -5570,8 +5689,12 @@ async function runSupervisorDaemon() {
|
|
|
5570
5689
|
// supervisor id, so the same profile launched twice is distinct end-to-end.
|
|
5571
5690
|
// NANO_SUPERVISOR_ACTIVITY_FILE tells the child where to report per-job
|
|
5572
5691
|
// activity for `supervisor status` (idle vs the job key it is servicing).
|
|
5692
|
+
// NANO_SUPERVISOR_DAEMON_PID hands the child our pid so its parent-death
|
|
5693
|
+
// watchdog can self-reap if we die ungracefully (SIGKILL/crash) and can't
|
|
5694
|
+
// run the child-draining shutdown — even if we die mid-startup, before the
|
|
5695
|
+
// child reparents to init.
|
|
5573
5696
|
const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
|
|
5574
|
-
env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile },
|
|
5697
|
+
env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile, NANO_SUPERVISOR_DAEMON_PID: String(process.pid) },
|
|
5575
5698
|
stdio: ['ignore', fd, fd],
|
|
5576
5699
|
});
|
|
5577
5700
|
if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
|
|
@@ -5837,6 +5960,11 @@ async function runSupervisorDaemon() {
|
|
|
5837
5960
|
|
|
5838
5961
|
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
5839
5962
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
5963
|
+
// SIGHUP (controlling terminal/session gone) must also drain children rather
|
|
5964
|
+
// than let Node's default terminate the daemon and orphan the fleet. SIGKILL
|
|
5965
|
+
// and hard crashes still can't run this — the per-worker parent-death
|
|
5966
|
+
// watchdog is the backstop for those.
|
|
5967
|
+
process.once('SIGHUP', () => shutdown('SIGHUP'));
|
|
5840
5968
|
dlog(`supervisor daemon up (pid ${process.pid}) — control ${socketPath}`);
|
|
5841
5969
|
persist();
|
|
5842
5970
|
|
|
@@ -7865,6 +7993,8 @@ export {
|
|
|
7865
7993
|
resourceContentUrl,
|
|
7866
7994
|
fetchLinkedResourceContent,
|
|
7867
7995
|
resolveLinkedPrompt,
|
|
7996
|
+
resolveLinkedPromptSource,
|
|
7997
|
+
normalizeRestBase,
|
|
7868
7998
|
coerceBool,
|
|
7869
7999
|
coerceInt,
|
|
7870
8000
|
deepMerge,
|
|
@@ -7944,6 +8074,7 @@ export {
|
|
|
7944
8074
|
supervisorJobCell,
|
|
7945
8075
|
supervisorWorkerActivityFile,
|
|
7946
8076
|
WORK_FORWARD_FLAGS,
|
|
8077
|
+
installParentDeathWatchdog,
|
|
7947
8078
|
runSupervisorDaemon,
|
|
7948
8079
|
startSupervisorDaemon,
|
|
7949
8080
|
supervisorRequest,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.2",
|
|
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.35.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.2",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.2",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.2",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.2",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.2",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.2",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.2"
|
|
67
67
|
}
|
|
68
68
|
}
|