c8ctl-plugin-nano 1.38.1 → 1.39.1

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 (2) hide show
  1. package/c8ctl-plugin.js +227 -135
  2. package/package.json +9 -9
package/c8ctl-plugin.js CHANGED
@@ -2458,18 +2458,12 @@ function resolveBrokerRestConfig(env = process.env, opts = {}) {
2458
2458
  // exposes no usable restAddress. The token same-origin gate lives in
2459
2459
  // resolveBrokerRestConfig (re-run against the profile base), never duplicated.
2460
2460
  function resolveAutoRestConfig(camunda, env = process.env) {
2461
- const cfg = readConfig() || {};
2462
- const hasExplicitBase = Boolean(env.NANO_REST_URL || env.NANO_BASE_URL || cfg.nanoUrl);
2463
- if (!hasExplicitBase && camunda && typeof camunda.getConfig === 'function') {
2464
- let profileBase = '';
2465
- try {
2466
- profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2467
- } catch {
2468
- // ignore — degrade to the resolveBrokerRestConfig (localhost) default below
2469
- }
2470
- if (profileBase) return resolveBrokerRestConfig(env, { baseUrl: profileBase });
2471
- }
2472
- return resolveBrokerRestConfig(env);
2461
+ // Derive the base from the single canonical resolver (explicit override
2462
+ // profile restAddress localhost), then run it through resolveBrokerRestConfig
2463
+ // so the token same-origin gate stays single-sourced. resolveWorkerEngineBase
2464
+ // always returns a base, so opts.baseUrl always pins it here.
2465
+ const baseUrl = resolveWorkerEngineBase(camunda, env);
2466
+ return resolveBrokerRestConfig(env, { baseUrl });
2473
2467
  }
2474
2468
 
2475
2469
  // ---------------------------------------------------------------------------
@@ -2483,82 +2477,43 @@ function resolveAutoRestConfig(camunda, env = process.env) {
2483
2477
  // exist" is answerable from that engine alone.
2484
2478
  //
2485
2479
  // `@nanobpm/agentic/demand` already reads deployed `taskDefinition` leaves over
2486
- // C8 REST (`process-definitions/search` → `/{key}/xml`), but its scanner reads
2487
- // type/element/process only. Not every service task is an agent task — plain
2488
- // connectors and record-keepers (e.g. `pr.record-plan`) are ordinary workers.
2489
- // The demand scanner is therefore extended HERE to read `zeebe:taskHeaders` and
2490
- // keep only leaves whose service task carries an `io.nanobpm.agentTask.` header
2491
- // (e.g. `senior:plan` carries `io.nanobpm.agentTask.task.prompt`; a record-keeper
2492
- // does not). Advertise the raw job-type string the engine matches (`senior:plan`)
2493
- // verbatim colon-named types are NOT forced through the agentic dot-grammar.
2480
+ // C8 REST (`process-definitions/search` → `/{key}/xml`). As of
2481
+ // `@nanobpm/agentic@0.4.0` its `scanTaskDefinitions(xml)` tags every leaf with a
2482
+ // canonical `agentic: boolean` true iff the service task declares a
2483
+ // `<zeebe:linkedResource linkName="prompt">` base-prompt side-car (its internal
2484
+ // `hasPromptLink`). That flag is the SINGLE SOURCE OF TRUTH for agentic-ness (see
2485
+ // the package's `demand/taskdef.d.ts` and nano-workforce SPEC "Agent job
2486
+ // contract"): every external agent task delivers its base prompt through a
2487
+ // `linkName="prompt"` linked resource, and no in-process worker task does. Not
2488
+ // every service task is an agent task — plain connectors and record-keepers
2489
+ // (e.g. `pr.record-plan`) are ordinary workers, and they carry no prompt link.
2490
+ //
2491
+ // Per AGENTS.md "Derivation Over Duplication: No Drift Surfaces", this plugin
2492
+ // CONSUMES that flag rather than re-implementing the scan, so the detector can
2493
+ // never drift out of lock-step with the package again (as it did in #95, when a
2494
+ // local copy keyed on the legacy `io.nanobpm.agentTask` header missed the current
2495
+ // linked-prompt marker). Advertise the raw job-type string the engine matches
2496
+ // (`senior:plan`) verbatim — colon-named types are NOT forced through the agentic
2497
+ // dot-grammar.
2494
2498
  // ---------------------------------------------------------------------------
2495
2499
 
2496
- // True iff a serviceTask body carries a `zeebe:header` (inside its
2497
- // `zeebe:taskHeaders`) under the agent-task
2498
- // namespace the LEGACY marker that distinguishes an agent task from a plain
2499
- // connector / record-keeper. Matches the exact `io.nanobpm.agentTask` key and
2500
- // any flattened `io.nanobpm.agentTask.*` dotpath key (element templates emit the
2501
- // latter, e.g. `io.nanobpm.agentTask.task.prompt`). Older deployments carry this;
2502
- // the current `@nanobpm/workflow` toolchain emits the linked-prompt marker below
2503
- // instead, so BOTH must be recognised (jwulf/c8ctl-plugin-nano#95).
2504
- function serviceTaskHasAgentHeader(body) {
2505
- const headerRe = /<zeebe:header\b[^>]*\bkey\s*=\s*"([^"]*)"/g;
2506
- let m;
2507
- while ((m = headerRe.exec(body)) !== null) {
2508
- const key = m[1];
2509
- if (key === AGENT_TASK_NS || key.startsWith(`${AGENT_TASK_NS}.`)) return true;
2510
- }
2511
- return false;
2512
- }
2513
-
2514
- // True iff a serviceTask body links a prompt resource — the CURRENT canonical
2515
- // agent-task marker emitted by `@nanobpm/workflow` / the Urban toolchain:
2516
- // `<zeebe:linkedResource … resourceType="GenericScript" linkName="prompt" />`.
2517
- // An agent task links the model/prompt the harness runs; a plain connector /
2518
- // record-keeper does not. Matched by the `linkName="prompt"` binding (attribute
2519
- // order-independent) so a compiled model with no `io.nanobpm.agentTask` header
2520
- // is still discovered (jwulf/c8ctl-plugin-nano#95). Kept in lock-step with the
2521
- // authored nano-workforce models (resources/processes/*.bpmn), where every
2522
- // `senior:*` service task carries this binding and none carry the legacy header.
2523
- // NOTE: this mirrors `@nanobpm/agentic@0.4.0`'s released `scanTaskDefinitions`
2524
- // `agentic` flag (its internal `hasPromptLink`); #102 tracks replacing this local
2525
- // copy by consuming that detector once the `^0.1.0 → ^0.4.0` bump is vetted.
2526
- function serviceTaskHasLinkedPrompt(body) {
2527
- const re = new RegExp(`<zeebe:linkedResource\\b[^>]*\\blinkName\\s*=\\s*"${DEFAULT_PROMPT_LINK_NAME}"`);
2528
- return re.test(String(body || ''));
2529
- }
2530
-
2531
- // True iff a serviceTask is an *agent* task — by EITHER the legacy
2532
- // `io.nanobpm.agentTask.*` header OR the current linked-prompt marker. Either
2533
- // alone is sufficient; deployments in the wild carry one or the other.
2534
- function serviceTaskIsAgentTask(body) {
2535
- return serviceTaskHasAgentHeader(body) || serviceTaskHasLinkedPrompt(body);
2536
- }
2537
-
2538
- // Scan one deployed BPMN document for its *agent* task-definition leaves: every
2539
- // `<bpmn:serviceTask>` carrying BOTH a non-empty `<zeebe:taskDefinition type>`
2540
- // AND an agent-task marker (legacy `io.nanobpm.agentTask.` header OR a
2500
+ // Scan one deployed BPMN document for its *agent* task-definition leaves: the
2501
+ // subset of `@nanobpm/agentic` `demand.scanTaskDefinitions(xml)` leaves whose
2502
+ // canonical `agentic` flag is set (i.e. the service task declares a
2541
2503
  // `linkName="prompt"` linked resource). Returns `{ taskType, process }` leaves in
2542
- // first-occurrence order. This is the agent-aware extension of the demand
2543
- // package's `scanTaskDefinitions` (which reads type/element/process only).
2544
- function scanAgentTaskLeaves(xml) {
2545
- const source = String(xml || '');
2546
- const procMatch = source.match(/<bpmn:process\b[^>]*\bid\s*=\s*"([^"]*)"/);
2547
- const proc = procMatch ? procMatch[1] : '';
2548
- const out = [];
2549
- const blockRe = /<bpmn:serviceTask\b[^>]*>([\s\S]*?)<\/bpmn:serviceTask>/g;
2550
- let block;
2551
- while ((block = blockRe.exec(source)) !== null) {
2552
- const body = block[1];
2553
- const tdMatch = body.match(/<zeebe:taskDefinition\b[^>]*>/);
2554
- if (!tdMatch) continue;
2555
- const typeMatch = tdMatch[0].match(/\btype\s*=\s*"([^"]*)"/);
2556
- const taskType = typeMatch ? typeMatch[1] : '';
2557
- if (!taskType) continue;
2558
- if (!serviceTaskIsAgentTask(body)) continue;
2559
- out.push({ taskType, process: proc });
2504
+ // first-occurrence order. The published `scanTaskDefinitions` is INJECTED so this
2505
+ // stays a pure, synchronous function; `readDeployedAgentJobTypes` supplies the
2506
+ // real one from the lazily-imported demand surface (`agentic.mjs`).
2507
+ function scanAgentTaskLeaves(xml, scanTaskDefinitions) {
2508
+ if (typeof scanTaskDefinitions !== 'function') {
2509
+ throw new TypeError(
2510
+ 'scanAgentTaskLeaves: `scanTaskDefinitions` must be an injected function ' +
2511
+ `(got ${typeof scanTaskDefinitions}); pass demand.scanTaskDefinitions from ./agentic.mjs`
2512
+ );
2560
2513
  }
2561
- return out;
2514
+ return scanTaskDefinitions(String(xml || ''))
2515
+ .filter((leaf) => leaf.agentic)
2516
+ .map((leaf) => ({ taskType: leaf.taskType, process: leaf.process }));
2562
2517
  }
2563
2518
 
2564
2519
  // Read the distinct deployed *agent* job types through a demand C8RestReader
@@ -2567,12 +2522,13 @@ function scanAgentTaskLeaves(xml) {
2567
2522
  // optional `scope` narrows to one app/network — kept only when the leaf's
2568
2523
  // `bpmn:process` id equals or is prefixed by the scope string.
2569
2524
  async function readDeployedAgentJobTypes(reader, { scope = '' } = {}) {
2525
+ const { demand } = await import('./agentic.mjs');
2570
2526
  const keys = await reader.searchProcessDefinitionKeys();
2571
2527
  const seen = new Set();
2572
2528
  const out = [];
2573
2529
  for (const key of keys) {
2574
2530
  const xml = await reader.getProcessDefinitionXml(key);
2575
- for (const leaf of scanAgentTaskLeaves(xml)) {
2531
+ for (const leaf of scanAgentTaskLeaves(xml, demand.scanTaskDefinitions)) {
2576
2532
  if (scope && !(leaf.process === scope || leaf.process.startsWith(scope))) continue;
2577
2533
  if (seen.has(leaf.taskType)) continue;
2578
2534
  seen.add(leaf.taskType);
@@ -2685,28 +2641,117 @@ function normalizeRestBase(addr) {
2685
2641
  return String(addr || '').replace(/\/+$/, '').replace(/\/v2$/i, '');
2686
2642
  }
2687
2643
 
2644
+ // The ONE place that answers "what engine base does this worker use?" — the
2645
+ // single source of truth for the worker's engine-base precedence chain:
2646
+ //
2647
+ // explicit override (NANO_REST_URL → NANO_BASE_URL → persisted cfg.nanoUrl)
2648
+ // → active c8ctl profile restAddress (the client that activates jobs)
2649
+ // → localhost default (DEFAULT_NANO_URL).
2650
+ //
2651
+ // Every worker engine reference derives from this (derivation over duplication):
2652
+ // the `--auto` job-type read (resolveAutoRestConfig, injecting it into the
2653
+ // resolveBrokerRestConfig token gate), the linked-prompt fetch
2654
+ // (resolveLinkedPromptSource), the `supervisor status` engine column
2655
+ // (workerEngine), and the agentic visibility channel base (resolveAgenticConfig).
2656
+ // Before this existed the chain was hand-copied at each site and the agentic copy
2657
+ // silently lost the profile fallback, so a profile-only remote worker degraded to
2658
+ // its own localhost and never enrolled (jwulf/c8ctl-plugin-nano#107, sibling of
2659
+ // #93/#99). Sharing this resolver fixes that by construction and removes the
2660
+ // drift surface. The token same-origin gate stays in resolveBrokerRestConfig,
2661
+ // never duplicated here.
2662
+ function resolveWorkerEngineBase(camunda, env = process.env) {
2663
+ // readConfig() swallows parse/IO errors and returns {} — never throws.
2664
+ const cfg = readConfig() || {};
2665
+ const explicit = env.NANO_REST_URL || env.NANO_BASE_URL || cfg.nanoUrl;
2666
+ if (explicit) return normalizeRestBase(explicit);
2667
+ if (camunda && typeof camunda.getConfig === 'function') {
2668
+ try {
2669
+ const profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2670
+ if (profileBase) return profileBase;
2671
+ } catch {
2672
+ // degrade to the localhost default below rather than throw
2673
+ }
2674
+ }
2675
+ return DEFAULT_NANO_URL;
2676
+ }
2677
+
2678
+ // The engine authority reported in the supervisor activity marker's ENGINE
2679
+ // column (#99): the base the worker actually POLLS JOBS from. The SDK job worker
2680
+ // (`camunda.createJobWorker`) activates jobs against the client's OWN profile
2681
+ // restAddress — which is NOT affected by the explicit NANO_REST_URL / NANO_BASE_URL
2682
+ // / cfg.nanoUrl overrides that resolveWorkerEngineBase prefers for auxiliary REST
2683
+ // reads (linked prompts, `--auto` reads, agentic channel). Reporting an override
2684
+ // there would make the ENGINE column claim an engine the worker is not polling,
2685
+ // violating supervisorEngineCell's "engine this worker polls jobs from" contract.
2686
+ // So prefer the profile restAddress (the polling engine) and fall back to the
2687
+ // canonical resolver only when the client exposes no usable base.
2688
+ function resolveWorkerPollEngineBase(camunda, env = process.env) {
2689
+ if (camunda && typeof camunda.getConfig === 'function') {
2690
+ try {
2691
+ const profileBase = normalizeRestBase(camunda.getConfig()?.restAddress);
2692
+ if (profileBase) return profileBase;
2693
+ } catch {
2694
+ // degrade to the canonical resolver below rather than throw
2695
+ }
2696
+ }
2697
+ return resolveWorkerEngineBase(camunda, env);
2698
+ }
2699
+
2700
+ // The base URL for a linked-prompt fetch. A linked-resource `resourceKey` is
2701
+ // BROKER-LOCAL: it lives on the engine the SDK client activated this job against
2702
+ // — the polling engine (its OWN profile restAddress). So the prompt fetch must
2703
+ // target that same broker, NOT the NANO_BASE_URL / cfg.nanoUrl auxiliary-read
2704
+ // overrides that resolveWorkerEngineBase prefers. Those overrides steer reads
2705
+ // that are NOT broker-local; honoring them here makes the base drift from the
2706
+ // activation broker so a broker-local resourceKey 404s ("prompt resource N fetch
2707
+ // failed") even while job activation still succeeds. Precedence:
2708
+ //
2709
+ // explicit NANO_REST_URL (same-broker escape hatch, e.g. a caching proxy in
2710
+ // front of the polling engine)
2711
+ // → polling engine base (resolveWorkerPollEngineBase → profile restAddress)
2712
+ // → localhost default.
2713
+ //
2714
+ // NANO_REST_URL is retained as an explicit escape hatch, but NANO_BASE_URL /
2715
+ // cfg.nanoUrl are deliberately NOT — steering the prompt fetch to a different
2716
+ // engine than the one that issued the broker-local resourceKey is the very bug
2717
+ // this resolves.
2718
+ function resolveLinkedPromptBase(camunda, env = process.env) {
2719
+ const explicit = env.NANO_REST_URL;
2720
+ if (explicit) return normalizeRestBase(explicit);
2721
+ return resolveWorkerPollEngineBase(camunda, env);
2722
+ }
2723
+
2688
2724
  // Derive the linked-resource fetch base URL + auth headers from the SAME SDK
2689
2725
  // client that activated the job. A linked-resource `resourceKey` is broker-local,
2690
- // so prompt content must be fetched from the broker this worker is connected to
2691
- // never a localhost default (the cause of "prompt resource N fetch failed").
2692
- // An explicit NANO_REST_URL / NANO_REST_TOKEN override still wins as an operator
2693
- // escape hatch. Both getConfig()/getAuthHeaders() are guarded so an older or
2694
- // atypical client runtime degrades to the override/legacy path rather than throw.
2726
+ // so prompt content must be fetched from the broker this worker POLLS jobs from
2727
+ // (the polling engine its profile restAddress). The base comes from
2728
+ // resolveLinkedPromptBase (explicit NANO_REST_URL escape hatch polling engine
2729
+ // base localhost); it deliberately does NOT follow the NANO_BASE_URL /
2730
+ // cfg.nanoUrl auxiliary-read overrides, since those would point the fetch at an
2731
+ // engine that never issued this broker-local resourceKey (the cause of "prompt
2732
+ // resource N fetch failed"). getAuthHeaders() is guarded so an older or atypical
2733
+ // client runtime degrades to the legacy path rather than throw.
2734
+ //
2735
+ // The base URL is invariant for a worker's lifetime, so callers on the per-job
2736
+ // hot path pass the once-at-startup `resolveLinkedPromptBase` result via
2737
+ // `baseUrl` to skip the synchronous config.json read (existsSync + readFileSync)
2738
+ // that this resolver would otherwise repeat on every job; only the auth headers
2739
+ // (which the client may rotate) are resolved per call. When `baseUrl` is omitted
2740
+ // it falls back to computing the base itself, so standalone callers and tests
2741
+ // keep the single-argument behaviour.
2695
2742
  //
2696
2743
  // TODO: once c8ctl bumps @camunda8/orchestration-cluster-api to v10 (10.0.0-alpha
2697
2744
  // exposes the typed camunda.getResourceContentBinary({resourceKey}) → Blob), drop
2698
2745
  // this raw /content/binary fetch and call that method directly. The pinned ^9.1.0
2699
2746
  // SDK only exposes the deprecated getResourceContent, which 406s for generic
2700
2747
  // (Markdown) prompt resources — see camunda/orchestration-cluster-api-js.
2701
- async function resolveLinkedPromptSource(camunda, env = process.env) {
2702
- let baseUrl = env.NANO_REST_URL || '';
2703
- if (!baseUrl && camunda && typeof camunda.getConfig === 'function') {
2704
- try {
2705
- baseUrl = normalizeRestBase(camunda.getConfig().restAddress);
2706
- } catch {
2707
- // ignore fall back to the legacy resolveBrokerRestConfig base below
2708
- }
2709
- }
2748
+ async function resolveLinkedPromptSource(camunda, env = process.env, { baseUrl: preResolvedBase } = {}) {
2749
+ // The fetch base follows resolveLinkedPromptBase (explicit NANO_REST_URL escape
2750
+ // hatch polling engine base localhost) so it tracks the broker the job was
2751
+ // activated against — the resourceKey is broker-local. A caller-supplied
2752
+ // pre-resolved base (already normalized at worker startup) is reused verbatim to
2753
+ // avoid a per-job config.json read.
2754
+ const baseUrl = preResolvedBase ?? resolveLinkedPromptBase(camunda, env);
2710
2755
  let authHeaders;
2711
2756
  if (env.NANO_REST_TOKEN) {
2712
2757
  authHeaders = { Authorization: `Bearer ${env.NANO_REST_TOKEN}` };
@@ -4121,13 +4166,19 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
4121
4166
  * secret is ignored (LOCAL mode).
4122
4167
  * - OFF: NANO_AGENTIC=off (or 0/false/no), or persisted `agentic:false`.
4123
4168
  *
4124
- * Env wins over persisted config; the base URL falls back to the configured nano
4125
- * URL (the app's own port) and ultimately DEFAULT_NANO_URL, so it is never empty.
4169
+ * Env wins over persisted config; when no explicit agentic target is set the base
4170
+ * URL defers to the shared resolveWorkerEngineBase (explicit engine override
4171
+ * active c8ctl profile restAddress → localhost default), so a profile-only remote
4172
+ * worker discovers the agentic channel of the engine its jobs actually run on
4173
+ * rather than its own loopback (jwulf/c8ctl-plugin-nano#107). It is never empty.
4126
4174
  * Returns `null` only when disabled (the off-switch).
4127
4175
  *
4176
+ * @param {object} [camunda] the SDK client that activates jobs — its
4177
+ * getConfig().restAddress supplies the profile engine base when no explicit
4178
+ * agentic/engine override is set. Threaded from the `work` call site.
4128
4179
  * @returns {{ url: string, token: string, credential: string, bufferCapacity: number, secure: boolean, explicitUrl: boolean } | null}
4129
4180
  */
4130
- function resolveAgenticConfig() {
4181
+ function resolveAgenticConfig(camunda) {
4131
4182
  const cfg = readConfig();
4132
4183
  // Explicit off-switch (env wins). Lets an operator fully opt out of visibility.
4133
4184
  const offSetting = process.env.NANO_AGENTIC
@@ -4136,14 +4187,14 @@ function resolveAgenticConfig() {
4136
4187
 
4137
4188
  // An explicit agentic target (env NANO_AGENTIC_URL or persisted `agenticUrl`)
4138
4189
  // is used verbatim and short-circuits hub auto-discovery (#75). When neither is
4139
- // set the URL below is the ENGINE base (nanoUrl → NANO_BASE_URL → default), off
4140
- // which `resolveAgenticTarget()` discovers the embedded app's own /agentic port.
4190
+ // set the base defers to the shared worker-engine resolver (explicit engine
4191
+ // override profile restAddress localhost default) NOT a re-inlined
4192
+ // nanoUrl → NANO_BASE_URL → default chain — so agentic discovery targets the
4193
+ // same engine the worker's jobs run on (jwulf/c8ctl-plugin-nano#107).
4141
4194
  const explicitUrl = !!(process.env.NANO_AGENTIC_URL || cfg.agenticUrl);
4142
4195
  const url = process.env.NANO_AGENTIC_URL
4143
4196
  || cfg.agenticUrl
4144
- || cfg.nanoUrl
4145
- || process.env.NANO_BASE_URL
4146
- || DEFAULT_NANO_URL;
4197
+ || resolveWorkerEngineBase(camunda);
4147
4198
  // SECURE-mode shared secret. Named NANO_AGENTIC_SECRET to match the server's env
4148
4199
  // var EXACTLY (Tab A → Slot A): set the same name + value on the server and every
4149
4200
  // worker box. The worker presents it as its identity token; the hub verifies it
@@ -4387,11 +4438,11 @@ async function discoverAgenticHubs(engineBaseUrl, {
4387
4438
  * no projects API / not a nano engine, or discovery error/timeout). The
4388
4439
  * worker continues doing real work with no channel.
4389
4440
  *
4390
- * @param {{ fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4441
+ * @param {{ camunda?: object, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4391
4442
  * @returns {Promise<{ status: string, config?: object, message?: string, candidates?: Array }>}
4392
4443
  */
4393
- async function resolveAgenticTarget(opts = {}) {
4394
- const base = resolveAgenticConfig();
4444
+ async function resolveAgenticTarget({ camunda, ...opts } = {}) {
4445
+ const base = resolveAgenticConfig(camunda);
4395
4446
  if (!base) return { status: 'off' };
4396
4447
  // Explicit target wins verbatim and skips discovery entirely.
4397
4448
  if (base.explicitUrl) return { status: 'connect', config: base };
@@ -4730,7 +4781,9 @@ async function workAgent(req, flags) {
4730
4781
  // no capability, no app enrol endpoint, no channel connection. It is the
4731
4782
  // mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
4732
4783
  // the rank×capability matrix is bypassed entirely (any deployed agent job is
4733
- // served, gated only by the `io.nanobpm.agentTask.` task header), and the
4784
+ // served, gated only by the leaf's canonical `agentic` flag the
4785
+ // `linkName="prompt"` linked-resource marker read by
4786
+ // `@nanobpm/agentic`'s demand scanner), and the
4734
4787
  // desired set is reconciled by polling the engine rather than watching the
4735
4788
  // profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
4736
4789
  // app/network; without it, every agent job type on the engine is served.
@@ -4810,24 +4863,30 @@ async function workAgent(req, flags) {
4810
4863
  installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4811
4864
  }
4812
4865
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4813
- // Which engine this worker polls jobs from + the live agentic-visibility
4814
- // channel status, both surfaced to `supervisor status` via the activity
4815
- // marker (#99). `agenticState` starts 'starting' and is updated once the
4816
- // channel target is resolved and again on each connect/disconnect below.
4817
- // The engine must name the ACTUAL polling authority: jobs are activated by
4818
- // `camunda.createJobWorker()` against the active c8ctl profile engine
4819
- // (`camunda.getConfig().restAddress`), whereas `restConfig` can honor the
4820
- // auxiliary NANO_REST_URL/NANO_BASE_URL/nanoUrl overrides (for `--auto`
4821
- // reads). Derive from the profile engine, using restConfig only as a
4822
- // fallback, so the column can't advertise an override host jobs aren't
4823
- // polled from.
4824
- const workerEngine = (() => {
4825
- try {
4826
- const profileBase = normalizeRestBase(camunda?.getConfig?.()?.restAddress);
4827
- if (profileBase) return profileBase;
4828
- } catch { /* degrade to the auxiliary REST config below */ }
4829
- return restConfig?.baseUrl || null;
4830
- })();
4866
+ // Which engine this worker polls jobs from, surfaced to `supervisor status` via
4867
+ // the activity marker (#99). Derived from resolveWorkerPollEngineBase the SDK
4868
+ // client's OWN profile restAddress (the base createJobWorker actually activates
4869
+ // against), NOT the NANO_* / cfg.nanoUrl override that resolveWorkerEngineBase
4870
+ // prefers for auxiliary REST reads. That keeps the ENGINE column honest: it
4871
+ // reports the engine the worker truly polls, never an override the job worker
4872
+ // ignores. Falls back to the canonical resolver only when the client exposes no
4873
+ // usable profile base.
4874
+ const workerEngine = resolveWorkerPollEngineBase(camunda);
4875
+ // Base URL for the per-job linked-prompt fetch (issue #63). This follows
4876
+ // resolveLinkedPromptBase — the polling engine (profile restAddress the SDK
4877
+ // activates jobs against), since the linked resourceKey is BROKER-LOCAL and must
4878
+ // be fetched from the broker that issued it — with NANO_REST_URL as the only
4879
+ // explicit escape hatch. It deliberately does NOT follow the NANO_BASE_URL /
4880
+ // cfg.nanoUrl auxiliary-read overrides resolveWorkerEngineBase prefers: pointing
4881
+ // the prompt fetch at a different engine than the activation broker would 404 a
4882
+ // broker-local resourceKey while job activation still succeeds. Computed ONCE at
4883
+ // startup so the per-job hot path skips a config.json read. It coincides with
4884
+ // `workerEngine` (the polling engine) unless NANO_REST_URL is set.
4885
+ const linkedPromptBase = resolveLinkedPromptBase(camunda);
4886
+ // The live agentic-visibility channel status, also surfaced to `supervisor
4887
+ // status` via the activity marker (#99). `agenticState` starts 'starting' and
4888
+ // is updated once the channel target is resolved and again on each
4889
+ // connect/disconnect below.
4831
4890
  let agenticState = { status: 'starting' };
4832
4891
  const writeActivity = () => {
4833
4892
  if (!activityFile) return;
@@ -4879,7 +4938,7 @@ async function workAgent(req, flags) {
4879
4938
  // worker joins with the well-known LOCAL token and no credential; SECURE mode
4880
4939
  // (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
4881
4940
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4882
- const agenticTarget = await resolveAgenticTarget({ logger });
4941
+ const agenticTarget = await resolveAgenticTarget({ camunda, logger });
4883
4942
  let agenticCfg = null;
4884
4943
  // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
4885
4944
  // This is only the display URL for the activity marker, so compute it
@@ -5048,8 +5107,10 @@ async function workAgent(req, flags) {
5048
5107
  try {
5049
5108
  // Fetch the prompt from the broker the SDK client is connected to,
5050
5109
  // deriving base URL + auth from that client (not restConfig, whose base
5051
- // defaults to localhost) — the resourceKey is broker-local.
5052
- const promptSource = await resolveLinkedPromptSource(camunda);
5110
+ // defaults to localhost) — the resourceKey is broker-local. The base is
5111
+ // invariant, so reuse the once-at-startup linkedPromptBase and let the
5112
+ // resolver only compute per-job auth headers (no per-job config.json read).
5113
+ const promptSource = await resolveLinkedPromptSource(camunda, process.env, { baseUrl: linkedPromptBase });
5053
5114
  const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
5054
5115
  baseUrl: promptSource.baseUrl || restConfig.baseUrl,
5055
5116
  authHeaders: promptSource.authHeaders,
@@ -5864,6 +5925,10 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5864
5925
  startedAtMs,
5865
5926
  lastExit: w.lastExit ?? null,
5866
5927
  args: Array.isArray(w.args) ? w.args : [],
5928
+ // The per-worker log path (logs/supervisor/worker-<id>.log). Carried through
5929
+ // so `supervisor status` can surface it (see formatSupervisorLogsLines);
5930
+ // null when the source record predates it (e.g. an older persisted state).
5931
+ logFile: w.logFile ?? null,
5867
5932
  activity,
5868
5933
  engine,
5869
5934
  agentic,
@@ -6070,6 +6135,30 @@ function createSupervisorLiveView({
6070
6135
  };
6071
6136
  }
6072
6137
 
6138
+ /**
6139
+ * The `Logs:` block for `supervisor status`, derived purely from the status
6140
+ * payload so it renders identically for a live daemon status and a synthesized
6141
+ * one. Lists the daemon log and each worker's log file, plus a hint at the
6142
+ * existing tailer. Returns an empty array (no section) when no log path is
6143
+ * known — an older persisted state, or a dead daemon whose frame carried none.
6144
+ */
6145
+ function formatSupervisorLogsLines(status) {
6146
+ const daemonLog = status?.daemon?.logFile || null;
6147
+ const workers = Array.isArray(status?.workers) ? status.workers : [];
6148
+ const workerLogs = workers
6149
+ .filter((w) => w && w.logFile)
6150
+ .map((w) => ({ label: String(w.id), path: String(w.logFile) }));
6151
+ const entries = [];
6152
+ if (daemonLog) entries.push({ label: 'daemon', path: String(daemonLog) });
6153
+ for (const w of workerLogs) entries.push(w);
6154
+ if (entries.length === 0) return [];
6155
+ const labelWidth = Math.max(...entries.map((e) => e.label.length));
6156
+ const out = ['', 'Logs:'];
6157
+ for (const e of entries) out.push(` ${e.label.padEnd(labelWidth)} ${e.path}`);
6158
+ out.push(' View: c8ctl nano supervisor logs [<id>] [--follow]');
6159
+ return out;
6160
+ }
6161
+
6073
6162
  /** Render a supervisor status object as an aligned text table. */
6074
6163
  function formatSupervisorStatus(status) {
6075
6164
  const lines = [];
@@ -6083,6 +6172,7 @@ function formatSupervisorStatus(status) {
6083
6172
  lines.push('');
6084
6173
  if (workers.length === 0) {
6085
6174
  lines.push(' No workers. Add one with: c8ctl nano supervisor add <profile>');
6175
+ lines.push(...formatSupervisorLogsLines(status));
6086
6176
  return lines.join('\n');
6087
6177
  }
6088
6178
  const rows = workers.map((w) => ({
@@ -6107,6 +6197,7 @@ function formatSupervisorStatus(status) {
6107
6197
  const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
6108
6198
  lines.push(fmt(head));
6109
6199
  for (const r of rows) lines.push(fmt(r));
6200
+ lines.push(...formatSupervisorLogsLines(status));
6110
6201
  return lines.join('\n');
6111
6202
  }
6112
6203
 
@@ -8858,6 +8949,9 @@ export {
8858
8949
  pickLinkedResource,
8859
8950
  resolveBrokerRestConfig,
8860
8951
  resolveAutoRestConfig,
8952
+ resolveWorkerEngineBase,
8953
+ resolveWorkerPollEngineBase,
8954
+ resolveLinkedPromptBase,
8861
8955
  resourceContentUrl,
8862
8956
  fetchLinkedResourceContent,
8863
8957
  resolveLinkedPrompt,
@@ -8909,9 +9003,6 @@ export {
8909
9003
  jobTypeMatrix,
8910
9004
  diffJobTypes,
8911
9005
  parseJobTypeFlags,
8912
- serviceTaskHasAgentHeader,
8913
- serviceTaskHasLinkedPrompt,
8914
- serviceTaskIsAgentTask,
8915
9006
  scanAgentTaskLeaves,
8916
9007
  readDeployedAgentJobTypes,
8917
9008
  resolveAutoJobTypes,
@@ -8942,6 +9033,7 @@ export {
8942
9033
  formatDuration,
8943
9034
  summarizeSupervisorWorker,
8944
9035
  formatSupervisorStatus,
9036
+ formatSupervisorLogsLines,
8945
9037
  reageSupervisorStatus,
8946
9038
  clampToWidth,
8947
9039
  createSupervisorLiveView,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.38.1",
3
+ "version": "1.39.1",
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",
@@ -52,17 +52,17 @@
52
52
  "semantic-release": "^25.0.3"
53
53
  },
54
54
  "dependencies": {
55
- "@nanobpm/agentic": "^0.1.0",
55
+ "@nanobpm/agentic": "^0.4.0",
56
56
  "@nanobpm/urban-agent-client": "^0.1.4"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.38.1",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.38.1",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.38.1",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.38.1",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.38.1",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.38.1",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.38.1"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.39.1",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.39.1",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.39.1",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.39.1",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.39.1",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.39.1",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.39.1"
67
67
  }
68
68
  }