c8ctl-plugin-nano 1.26.3 → 1.28.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/agentic-loader-hook.mjs +42 -0
- package/agentic.mjs +133 -0
- package/c8ctl-plugin.js +87 -2
- package/package.json +16 -9
- package/work-channel.mjs +290 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Module-customization RESOLVE hook (Node `module.register`) — the single
|
|
2
|
+
// mechanism that makes `@nanobpm/urban-agent-client` loadable under stock Node.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists (the C0 constraint, jwulf/c8ctl-plugin-nano#39):
|
|
5
|
+
// the published worker client funnels the S0 wire contract through
|
|
6
|
+
// `dist/protocol.js`, which imports `@nanobpm/agentic/source/protocol` — raw
|
|
7
|
+
// TypeScript — on the assumption the consumer runs under a type-stripping
|
|
8
|
+
// loader. This repo runs on stock Node (`node --test`, `nano work`), which
|
|
9
|
+
// REFUSES to type-strip `.ts` under `node_modules`
|
|
10
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a bare `import` of the
|
|
11
|
+
// client throws before any of C2's channel code can run.
|
|
12
|
+
//
|
|
13
|
+
// The redirect below rewrites every `@nanobpm/agentic/source/*` specifier to
|
|
14
|
+
// the package's COMPILED `@nanobpm/agentic/*` `dist` export. Source and dist are
|
|
15
|
+
// the same S0 contract — both are held to the one shared conformance corpus
|
|
16
|
+
// (`agentic-conformance.test.mjs`) — so this is a pure packaging redirect, not a
|
|
17
|
+
// behavioural change: the client ends up bound to the exact codec/grammar this
|
|
18
|
+
// repo already runs green. When the client is republished to import agentic's
|
|
19
|
+
// `dist` directly, this hook becomes a no-op and can be retired.
|
|
20
|
+
//
|
|
21
|
+
// Registered lazily from `loadAgenticClient()` in `agentic.mjs` (the single
|
|
22
|
+
// client swap point) so it is active before the client's module graph loads.
|
|
23
|
+
|
|
24
|
+
const SOURCE_PREFIX = '@nanobpm/agentic/source/';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Node ESM resolve hook. Redirects the client's raw-`.ts` source imports to the
|
|
28
|
+
* compiled dist subpath exports; passes everything else through untouched.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} specifier the requested module specifier
|
|
31
|
+
* @param {import('node:module').ResolveHookContext} context resolution context
|
|
32
|
+
* @param {(s: string, c?: object) => unknown} next the next hook in the chain
|
|
33
|
+
*/
|
|
34
|
+
export async function resolve(specifier, context, next) {
|
|
35
|
+
if (specifier === '@nanobpm/agentic/source') {
|
|
36
|
+
return next('@nanobpm/agentic', context);
|
|
37
|
+
}
|
|
38
|
+
if (specifier.startsWith(SOURCE_PREFIX)) {
|
|
39
|
+
return next(`@nanobpm/agentic/${specifier.slice(SOURCE_PREFIX.length)}`, context);
|
|
40
|
+
}
|
|
41
|
+
return next(specifier, context);
|
|
42
|
+
}
|
package/agentic.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Single import surface for the Nano agentic-visibility plane (ADR 0056 —
|
|
2
|
+
// Magikcraft/nano-bpm#670) inside this plugin.
|
|
3
|
+
//
|
|
4
|
+
// This is slice C0 of the agent-visibility epic (jwulf/c8ctl-plugin-nano#38).
|
|
5
|
+
// The sibling slices — C2 presence (#41), C3 PTY relay (#42) and C4 buffer
|
|
6
|
+
// (#43) — MUST import the wire contract and the worker client through *this*
|
|
7
|
+
// module rather than reaching into `@nanobpm/agentic` subpaths or
|
|
8
|
+
// `@nanobpm/urban-agent-client` directly. One place to import, one place to
|
|
9
|
+
// swap when the upstream packaging changes.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here is re-declared: every frame, lane, family, token, vocab and
|
|
12
|
+
// payload type is CONSUMED from the published `@nanobpm/agentic` package, and
|
|
13
|
+
// the worker-side channel client is CONSUMED from `@nanobpm/urban-agent-client`.
|
|
14
|
+
// The shared conformance corpus (`@nanobpm/agentic/protocol/conformance`) keeps
|
|
15
|
+
// this repo's consumption in lock-step with the hub — see
|
|
16
|
+
// `agentic-conformance.test.mjs`.
|
|
17
|
+
|
|
18
|
+
// Node's module-customization registrar, used lazily by `loadAgenticClient()`
|
|
19
|
+
// to install the source→dist resolve hook before the worker client loads.
|
|
20
|
+
import { register as moduleRegister } from 'node:module';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Wire contract — @nanobpm/agentic/protocol (S0, the single source of truth).
|
|
24
|
+
// The codec, routing-token grammar, vocab schema and per-family payload
|
|
25
|
+
// validators. These resolve to the package's compiled `dist`, so they load
|
|
26
|
+
// under stock Node.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
export {
|
|
29
|
+
// message families
|
|
30
|
+
MESSAGE_FAMILIES,
|
|
31
|
+
FAMILY_CODES,
|
|
32
|
+
familyForCode,
|
|
33
|
+
isMessageFamily,
|
|
34
|
+
// QoS lanes
|
|
35
|
+
QOS_LANES,
|
|
36
|
+
LANE_CODES,
|
|
37
|
+
laneForCode,
|
|
38
|
+
lanePriority,
|
|
39
|
+
isQosLane,
|
|
40
|
+
compareFrameOrder,
|
|
41
|
+
// frame codec
|
|
42
|
+
encodeFrame,
|
|
43
|
+
decodeFrame,
|
|
44
|
+
FrameDecodeError,
|
|
45
|
+
FrameEncodeError,
|
|
46
|
+
FRAME_MAGIC,
|
|
47
|
+
FRAME_VERSION,
|
|
48
|
+
FRAME_HEADER_BYTES,
|
|
49
|
+
MAX_SEQ,
|
|
50
|
+
// routing tokens
|
|
51
|
+
parseToken,
|
|
52
|
+
formatToken,
|
|
53
|
+
isValidToken,
|
|
54
|
+
isSegmentName,
|
|
55
|
+
isSeatLabel,
|
|
56
|
+
TokenParseError,
|
|
57
|
+
// vocab schema
|
|
58
|
+
validateVocabDocument,
|
|
59
|
+
// per-family payload contracts
|
|
60
|
+
validatePayload,
|
|
61
|
+
// language-neutral hex helpers (used to hold the codec to the corpus)
|
|
62
|
+
bytesToHex,
|
|
63
|
+
hexToBytes,
|
|
64
|
+
} from '@nanobpm/agentic/protocol';
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Visibility families. Each is re-exported wholesale so a consumer picks the
|
|
68
|
+
// namespace it needs (channel transport, presence, relay lane, transcript)
|
|
69
|
+
// without re-declaring any of it. Namespaced re-exports keep the surface tidy
|
|
70
|
+
// and avoid symbol collisions between the families.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
export * as channel from '@nanobpm/agentic/channel';
|
|
73
|
+
export * as presence from '@nanobpm/agentic/presence';
|
|
74
|
+
export * as relay from '@nanobpm/agentic/relay';
|
|
75
|
+
export * as transcript from '@nanobpm/agentic/transcript';
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Worker-side channel client — @nanobpm/urban-agent-client.
|
|
79
|
+
//
|
|
80
|
+
// The client's published `dist/protocol.js` imports the contract from
|
|
81
|
+
// `@nanobpm/agentic/source/protocol` (raw TypeScript) on the assumption the
|
|
82
|
+
// consumer runs under a type-stripping loader. Stock Node — which this repo's
|
|
83
|
+
// `node --test` uses — refuses to strip types under `node_modules`
|
|
84
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a *static* re-export of the
|
|
85
|
+
// client would make this whole surface fail to load. To keep C0's surface
|
|
86
|
+
// loadable everywhere while still routing all client consumption through one
|
|
87
|
+
// swap point, the client is exposed behind a lazy async loader.
|
|
88
|
+
//
|
|
89
|
+
// C2 (#41) resolves that constraint HERE, at the single swap point: before the
|
|
90
|
+
// client's module graph loads, `loadAgenticClient()` registers a resolve hook
|
|
91
|
+
// (`agentic-loader-hook.mjs`) that redirects the client's raw-`.ts`
|
|
92
|
+
// `@nanobpm/agentic/source/*` imports to the compiled `@nanobpm/agentic/*`
|
|
93
|
+
// `dist` exports. Source and dist are the same S0 contract (one shared
|
|
94
|
+
// conformance corpus), so the client loads and runs under stock Node with no
|
|
95
|
+
// change in behaviour. When the client is republished to import agentic's dist
|
|
96
|
+
// directly, the redirect self-neutralises.
|
|
97
|
+
//
|
|
98
|
+
// @typedef {import('@nanobpm/urban-agent-client')} AgenticClientModule
|
|
99
|
+
/** @type {Promise<AgenticClientModule> | undefined} */
|
|
100
|
+
let clientModulePromise;
|
|
101
|
+
|
|
102
|
+
// Guards single registration of the source→dist resolve hook (idempotent).
|
|
103
|
+
let sourceRedirectRegistered = false;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Register the `@nanobpm/agentic/source/*` → `dist` resolve hook exactly once,
|
|
107
|
+
* so the worker client is importable under stock Node. Safe to call repeatedly;
|
|
108
|
+
* only the first call registers. Runs before any `import()` of the client so the
|
|
109
|
+
* hook is active for the client's whole module graph.
|
|
110
|
+
*/
|
|
111
|
+
function ensureClientLoadable() {
|
|
112
|
+
if (sourceRedirectRegistered) return;
|
|
113
|
+
sourceRedirectRegistered = true;
|
|
114
|
+
moduleRegister('./agentic-loader-hook.mjs', import.meta.url);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load the published worker-side agentic channel client
|
|
119
|
+
* (`@nanobpm/urban-agent-client`). Memoised so repeated calls share one module
|
|
120
|
+
* instance. Import the client only through this accessor so there is a single
|
|
121
|
+
* place to consume it from across the plugin.
|
|
122
|
+
*
|
|
123
|
+
* @returns {Promise<AgenticClientModule>} the client module namespace, exposing
|
|
124
|
+
* `connectAgenticChannel`, `AgenticClient`, `OutboundRing`, the websocket
|
|
125
|
+
* transport and the re-exported protocol symbols.
|
|
126
|
+
*/
|
|
127
|
+
export function loadAgenticClient() {
|
|
128
|
+
if (clientModulePromise === undefined) {
|
|
129
|
+
ensureClientLoadable();
|
|
130
|
+
clientModulePromise = import('@nanobpm/urban-agent-client');
|
|
131
|
+
}
|
|
132
|
+
return clientModulePromise;
|
|
133
|
+
}
|
package/c8ctl-plugin.js
CHANGED
|
@@ -57,6 +57,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
57
57
|
import { createInterface } from 'node:readline/promises';
|
|
58
58
|
import { createInterface as createReadline } from 'node:readline';
|
|
59
59
|
import { platformForHost } from './platforms.mjs';
|
|
60
|
+
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
60
61
|
|
|
61
62
|
const requireFromHere = createRequire(import.meta.url);
|
|
62
63
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -3236,6 +3237,32 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3236
3237
|
return env;
|
|
3237
3238
|
}
|
|
3238
3239
|
|
|
3240
|
+
/**
|
|
3241
|
+
* Resolve the agentic-visibility channel connection target + credentials for a
|
|
3242
|
+
* worker (ADR 0056 — slice C2). The channel is served same-port on the app's own
|
|
3243
|
+
* HTTP base URL at `/agentic`; the identity token + capability credential follow
|
|
3244
|
+
* the blackboard's `?token=…` pattern.
|
|
3245
|
+
*
|
|
3246
|
+
* Env wins over persisted config; the base URL falls back to the configured nano
|
|
3247
|
+
* URL (the app's own port). A worker only connects when BOTH an identity token
|
|
3248
|
+
* and a capability credential are present (enrolment) — absent either, it runs
|
|
3249
|
+
* exactly as before, off the visibility page. Returns `null` when not enrolled.
|
|
3250
|
+
*
|
|
3251
|
+
* @returns {{ url: string, token: string, credential: string } | null}
|
|
3252
|
+
*/
|
|
3253
|
+
function resolveAgenticConfig() {
|
|
3254
|
+
const cfg = readConfig();
|
|
3255
|
+
const url = process.env.NANO_AGENTIC_URL
|
|
3256
|
+
|| cfg.agenticUrl
|
|
3257
|
+
|| cfg.nanoUrl
|
|
3258
|
+
|| process.env.NANO_BASE_URL
|
|
3259
|
+
|| DEFAULT_NANO_URL;
|
|
3260
|
+
const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
3261
|
+
const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
|
|
3262
|
+
if (!url || !token || !credential) return null;
|
|
3263
|
+
return { url, token, credential };
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3239
3266
|
/**
|
|
3240
3267
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
3241
3268
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -3480,19 +3507,66 @@ async function workAgent(req, flags) {
|
|
|
3480
3507
|
/* best effort — activity is advisory, never fail a job over it */
|
|
3481
3508
|
}
|
|
3482
3509
|
};
|
|
3510
|
+
// The agentic-visibility channel, wired below. Declared here so the job
|
|
3511
|
+
// recorders can refresh presence with the live job set as jobs start/end.
|
|
3512
|
+
/** @type {import('./work-channel.mjs').WorkChannel | null} */
|
|
3513
|
+
let workChannel = null;
|
|
3514
|
+
// Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
|
|
3515
|
+
// file (gated inside writeActivity) AND the agentic presence frame's live
|
|
3516
|
+
// jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
|
|
3517
|
+
// reports its current jobs on the visibility page.
|
|
3483
3518
|
const recordJobStart = (job, jobType) => {
|
|
3484
|
-
if (!activityFile) return;
|
|
3485
3519
|
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
|
|
3486
3520
|
writeActivity();
|
|
3521
|
+
workChannel?.refreshPresence();
|
|
3487
3522
|
};
|
|
3488
3523
|
const recordJobEnd = (job) => {
|
|
3489
|
-
if (!activityFile) return;
|
|
3490
3524
|
activeJobs.delete(String(job.jobKey));
|
|
3491
3525
|
writeActivity();
|
|
3526
|
+
workChannel?.refreshPresence();
|
|
3492
3527
|
};
|
|
3493
3528
|
// Seed an initial idle marker so status reports 'idle' immediately after spawn.
|
|
3494
3529
|
writeActivity();
|
|
3495
3530
|
|
|
3531
|
+
// ---- Agentic visibility channel (ADR 0056 — slice C2, #41) ----------------
|
|
3532
|
+
// Connect this worker to the app's same-port `/agentic` channel and announce
|
|
3533
|
+
// presence (identity, host, live jobs), heartbeat, and deregister on exit, so
|
|
3534
|
+
// it appears live on the Workforce visibility page. This is the SINGLE place
|
|
3535
|
+
// the connected+authenticated channel client is instantiated in `work`: the
|
|
3536
|
+
// sibling slices C3 (PTY relay, #42) and C4 (buffer, #43) attach to the
|
|
3537
|
+
// accessors on `workChannel` (relay-lane sink + connect/disconnect/reconnect
|
|
3538
|
+
// lifecycle events) rather than opening their own connection.
|
|
3539
|
+
//
|
|
3540
|
+
// Enrolment is opt-in: without an identity token + capability credential the
|
|
3541
|
+
// worker runs exactly as before, off the channel (see resolveAgenticConfig).
|
|
3542
|
+
const agenticCfg = resolveAgenticConfig();
|
|
3543
|
+
if (agenticCfg) {
|
|
3544
|
+
try {
|
|
3545
|
+
workChannel = await createWorkChannel({
|
|
3546
|
+
instance: workerName,
|
|
3547
|
+
host: hostname(),
|
|
3548
|
+
capability: {
|
|
3549
|
+
cognition: profile.rank,
|
|
3550
|
+
family: profile.model || undefined,
|
|
3551
|
+
host: hostname(),
|
|
3552
|
+
},
|
|
3553
|
+
listJobKeys: () => [...activeJobs.keys()],
|
|
3554
|
+
url: agenticCfg.url,
|
|
3555
|
+
token: agenticCfg.token,
|
|
3556
|
+
credential: agenticCfg.credential,
|
|
3557
|
+
logger,
|
|
3558
|
+
});
|
|
3559
|
+
const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
|
|
3560
|
+
logger.info(` agentic channel: announcing presence as ${workerName} on ${shown}`);
|
|
3561
|
+
} catch (err) {
|
|
3562
|
+
// Never let a channel failure stop the worker from doing its actual job.
|
|
3563
|
+
workChannel = null;
|
|
3564
|
+
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
3565
|
+
}
|
|
3566
|
+
} else {
|
|
3567
|
+
logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3496
3570
|
// A per-job-type worker factory. Captures all the CLI-local + profile context
|
|
3497
3571
|
// in closure scope so the profile watcher below can (re)spawn a poller for any
|
|
3498
3572
|
// job type on demand without re-reading the flags.
|
|
@@ -3865,6 +3939,17 @@ async function workAgent(req, flags) {
|
|
|
3865
3939
|
} else {
|
|
3866
3940
|
logger.info('All workers stopped.');
|
|
3867
3941
|
}
|
|
3942
|
+
// Deregister from the visibility channel LAST, so the worker disappears
|
|
3943
|
+
// from the page only once its jobs have drained. Best-effort — a channel
|
|
3944
|
+
// teardown must never hang shutdown.
|
|
3945
|
+
if (workChannel) {
|
|
3946
|
+
try {
|
|
3947
|
+
await workChannel.stop(`worker stopped (${signal})`);
|
|
3948
|
+
logger.info('Deregistered from the agentic visibility channel.');
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
logger.warn(`agentic channel deregister failed: ${err?.message || err}`);
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3868
3953
|
resolve();
|
|
3869
3954
|
};
|
|
3870
3955
|
process.once('SIGINT', () => { stop('SIGINT'); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.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",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"c8ctl-plugin.js",
|
|
24
24
|
"platforms.mjs",
|
|
25
|
+
"agentic.mjs",
|
|
26
|
+
"agentic-loader-hook.mjs",
|
|
27
|
+
"work-channel.mjs",
|
|
25
28
|
"nanobpmn-binary.json",
|
|
26
29
|
"README.md"
|
|
27
30
|
],
|
|
@@ -31,7 +34,7 @@
|
|
|
31
34
|
},
|
|
32
35
|
"license": "MIT",
|
|
33
36
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
37
|
+
"node": ">=22.6"
|
|
35
38
|
},
|
|
36
39
|
"c8ctl": {
|
|
37
40
|
"defaults": {
|
|
@@ -46,13 +49,17 @@
|
|
|
46
49
|
"@semantic-release/github": "^12.0.6",
|
|
47
50
|
"semantic-release": "^25.0.3"
|
|
48
51
|
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@nanobpm/agentic": "^0.1.0",
|
|
54
|
+
"@nanobpm/urban-agent-client": "^0.1.0"
|
|
55
|
+
},
|
|
49
56
|
"optionalDependencies": {
|
|
50
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
51
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
57
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.28.0",
|
|
58
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.28.0",
|
|
59
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.28.0",
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.28.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.28.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.28.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.28.0"
|
|
57
64
|
}
|
|
58
65
|
}
|
package/work-channel.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The `work` command's agentic-visibility channel seam (ADR 0056 — slice C2,
|
|
2
|
+
// jwulf/c8ctl-plugin-nano#41).
|
|
3
|
+
//
|
|
4
|
+
// This module owns the SINGLE connected + authenticated channel client a
|
|
5
|
+
// running worker (`c8ctl nano work <profile>`) uses to appear on the Workforce
|
|
6
|
+
// visibility page. It is the wave-1 scaffold the sibling slices build on:
|
|
7
|
+
//
|
|
8
|
+
// - C3 (#42, PTY relay) publishes framed terminal output through the
|
|
9
|
+
// relay-lane sink exposed by {@link WorkChannel.relayLane}.
|
|
10
|
+
// - C4 (#43, buffer) subscribes to the connect / disconnect / reconnect
|
|
11
|
+
// lifecycle events exposed by {@link WorkChannel.onConnect} /
|
|
12
|
+
// {@link WorkChannel.onDisconnect} / {@link WorkChannel.onReconnect} to
|
|
13
|
+
// drive its buffer flush at the transport seam.
|
|
14
|
+
//
|
|
15
|
+
// Both siblings EXTEND this holder; neither opens, authenticates, or
|
|
16
|
+
// re-instantiates the channel. The connected client is created in exactly one
|
|
17
|
+
// place — {@link createWorkChannel} — alongside the worker's existing
|
|
18
|
+
// `camunda.createClient()` wiring.
|
|
19
|
+
//
|
|
20
|
+
// Everything on the wire (frame codec, lanes, presence payloads) is CONSUMED
|
|
21
|
+
// through this plugin's single import surface (`./agentic.mjs`), which in turn
|
|
22
|
+
// consumes `@nanobpm/agentic` + `@nanobpm/urban-agent-client`. Nothing is
|
|
23
|
+
// re-declared here.
|
|
24
|
+
//
|
|
25
|
+
// SCOPE (C2): presence + the shared seam only. Capability→SERVE-token
|
|
26
|
+
// resolution (REGISTER/SERVE enrolment) is the separate epic #58 and is NOT
|
|
27
|
+
// done here — the worker announces presence (identity, host, live jobs),
|
|
28
|
+
// heartbeats, and deregisters, and the SERVE handshake is deliberately left
|
|
29
|
+
// disabled (`serveTimeoutMs: 0`, the announce is fire-and-forget).
|
|
30
|
+
|
|
31
|
+
import { loadAgenticClient } from './agentic.mjs';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
34
|
+
// Outbound ring size (frames) the client buffers while the hub is unreachable.
|
|
35
|
+
// C4 (#43) tunes/uses this seam; a sensible default keeps a worker that starts
|
|
36
|
+
// before the app from losing its early presence/relay frames.
|
|
37
|
+
const DEFAULT_BUFFER_CAPACITY = 1024;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
|
|
41
|
+
* plus the ADR 0028 identity token and capability credential, carried as query
|
|
42
|
+
* params — the same `?token=…` pattern the blackboard hook uses (and exactly
|
|
43
|
+
* what `sharedSecretAuthenticator` reads on the hub side). `http`→`ws`,
|
|
44
|
+
* `https`→`wss`; the channel is served same-port at `/agentic`.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} baseUrl the app's own HTTP(S) base URL, e.g. `http://localhost:8080`
|
|
47
|
+
* @param {{ token: string, credential: string, path?: string }} auth
|
|
48
|
+
* @returns {string} the `ws(s)://…/agentic?token=…&capability=…` URL
|
|
49
|
+
*/
|
|
50
|
+
export function buildAgenticUrl(baseUrl, { token, credential, path = '/agentic' } = {}) {
|
|
51
|
+
if (typeof baseUrl !== 'string' || baseUrl.trim() === '') {
|
|
52
|
+
throw new Error('buildAgenticUrl requires a non-empty base URL');
|
|
53
|
+
}
|
|
54
|
+
const u = new URL(baseUrl);
|
|
55
|
+
if (u.protocol === 'http:') u.protocol = 'ws:';
|
|
56
|
+
else if (u.protocol === 'https:') u.protocol = 'wss:';
|
|
57
|
+
else if (u.protocol !== 'ws:' && u.protocol !== 'wss:') {
|
|
58
|
+
throw new Error(`Unsupported agentic base URL protocol "${u.protocol}" (expected http/https/ws/wss)`);
|
|
59
|
+
}
|
|
60
|
+
// Preserve any base path, then append the same-port channel path.
|
|
61
|
+
const basePath = u.pathname.replace(/\/+$/, '');
|
|
62
|
+
u.pathname = `${basePath}${path}`;
|
|
63
|
+
if (token !== undefined && token !== null && token !== '') u.searchParams.set('token', String(token));
|
|
64
|
+
if (credential !== undefined && credential !== null && credential !== '') {
|
|
65
|
+
u.searchParams.set('capability', String(credential));
|
|
66
|
+
}
|
|
67
|
+
return u.toString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Redact the token/capability query params from a channel URL for logging.
|
|
72
|
+
* @param {string} url
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function redactAgenticUrl(url) {
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(url);
|
|
78
|
+
if (u.searchParams.has('token')) u.searchParams.set('token', '***');
|
|
79
|
+
if (u.searchParams.has('capability')) u.searchParams.set('capability', '***');
|
|
80
|
+
return u.toString();
|
|
81
|
+
} catch {
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalise a capability object for the `register` presence frame. Drops
|
|
88
|
+
* undefined/empty attributes so the enrolment attribute stays minimal, and
|
|
89
|
+
* carries the worker's live `jobs` (jobKeys) as a forward-compatible nested
|
|
90
|
+
* field — the S0 register validator only requires `capability` to be an object
|
|
91
|
+
* and ignores extra fields ("a later slice may enrich a payload without
|
|
92
|
+
* breaking older peers"), so the visibility page can surface `capability.jobs`
|
|
93
|
+
* without any wire-contract change.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ cognition?: string, weight?: number, family?: string, host?: string }} capability
|
|
96
|
+
* @param {readonly string[]} jobs
|
|
97
|
+
* @returns {object}
|
|
98
|
+
*/
|
|
99
|
+
function presenceCapability(capability, jobs) {
|
|
100
|
+
const out = {};
|
|
101
|
+
if (capability) {
|
|
102
|
+
if (typeof capability.cognition === 'string' && capability.cognition !== '') out.cognition = capability.cognition;
|
|
103
|
+
if (typeof capability.weight === 'number' && Number.isFinite(capability.weight)) out.weight = capability.weight;
|
|
104
|
+
if (typeof capability.family === 'string' && capability.family !== '') out.family = capability.family;
|
|
105
|
+
if (typeof capability.host === 'string' && capability.host !== '') out.host = capability.host;
|
|
106
|
+
}
|
|
107
|
+
out.jobs = Array.isArray(jobs) ? jobs.map(String) : [];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {object} WorkChannel
|
|
113
|
+
* @property {import('@nanobpm/urban-agent-client').AgenticClient} client the one connected channel client
|
|
114
|
+
* @property {() => void} refreshPresence re-announce presence (call when the live job set changes)
|
|
115
|
+
* @property {() => { relay: (stream: string, chunk: string) => void }} relayLane C3's relay-lane sink accessor
|
|
116
|
+
* @property {(fn: () => void) => () => void} onConnect subscribe to the first successful connect
|
|
117
|
+
* @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
|
|
118
|
+
* @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
|
|
119
|
+
* @property {() => boolean} connected whether the channel is currently open
|
|
120
|
+
* @property {() => number} buffered outbound frames currently buffered awaiting the channel
|
|
121
|
+
* @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create the worker's single connected + authenticated agentic channel client
|
|
126
|
+
* and announce presence. The connection begins immediately; because the client
|
|
127
|
+
* buffers outbound frames, presence is announced (and relay is usable) even
|
|
128
|
+
* before the socket is open — it drains on connect.
|
|
129
|
+
*
|
|
130
|
+
* This is the ONLY place the channel client is instantiated in `work`. Sibling
|
|
131
|
+
* slices consume the accessors on the returned holder; they do not connect.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} opts
|
|
134
|
+
* @param {string} opts.instance stable worker instance id (the worker name) carried on every presence frame
|
|
135
|
+
* @param {string} opts.host the worker's host label
|
|
136
|
+
* @param {{ cognition?: string, weight?: number, family?: string, host?: string }} [opts.capability] declared enrolment capability
|
|
137
|
+
* @param {() => readonly string[]} [opts.listJobKeys] reads the live jobKey set from the worker's activeJobs map
|
|
138
|
+
* @param {string} opts.url the app's HTTP(S) base URL (the channel is served same-port at `/agentic`)
|
|
139
|
+
* @param {string} opts.token ADR 0028 identity token
|
|
140
|
+
* @param {string} opts.credential capability credential
|
|
141
|
+
* @param {number} [opts.heartbeatIntervalMs] presence heartbeat cadence (ms)
|
|
142
|
+
* @param {number} [opts.bufferCapacity] outbound ring size in frames
|
|
143
|
+
* @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transport] injectable transport (tests)
|
|
144
|
+
* @param {import('@nanobpm/urban-agent-client').ReconnectOptions} [opts.reconnect] reconnect/backoff policy passthrough
|
|
145
|
+
* @param {(fn: () => void, ms: number) => void} [opts.schedule] injectable backoff scheduler (tests)
|
|
146
|
+
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger] optional logger
|
|
147
|
+
* @returns {Promise<WorkChannel>}
|
|
148
|
+
*/
|
|
149
|
+
export async function createWorkChannel(opts) {
|
|
150
|
+
const {
|
|
151
|
+
instance,
|
|
152
|
+
host,
|
|
153
|
+
capability,
|
|
154
|
+
listJobKeys = () => [],
|
|
155
|
+
url,
|
|
156
|
+
token,
|
|
157
|
+
credential,
|
|
158
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
|
|
159
|
+
bufferCapacity = DEFAULT_BUFFER_CAPACITY,
|
|
160
|
+
transport,
|
|
161
|
+
reconnect,
|
|
162
|
+
schedule,
|
|
163
|
+
logger,
|
|
164
|
+
} = opts || {};
|
|
165
|
+
|
|
166
|
+
if (typeof instance !== 'string' || instance.trim() === '') {
|
|
167
|
+
throw new Error('createWorkChannel requires a non-empty instance id');
|
|
168
|
+
}
|
|
169
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
170
|
+
throw new Error('createWorkChannel requires an agentic channel base url');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
174
|
+
const declaredCapability = { ...(capability || {}) };
|
|
175
|
+
if (typeof host === 'string' && host !== '' && !declaredCapability.host) {
|
|
176
|
+
declaredCapability.host = host;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { connectAgenticChannel } = await loadAgenticClient();
|
|
180
|
+
|
|
181
|
+
// The single connected client. serveTimeoutMs:0 disables the SERVE handshake
|
|
182
|
+
// wait — SERVE-token resolution is the enrolment epic (#58), out of scope for
|
|
183
|
+
// C2; we only need presence to land, which the REGISTER frame does on its own.
|
|
184
|
+
const client = connectAgenticChannel({
|
|
185
|
+
url: channelUrl,
|
|
186
|
+
instance,
|
|
187
|
+
heartbeatIntervalMs,
|
|
188
|
+
serveTimeoutMs: 0,
|
|
189
|
+
bufferCapacity,
|
|
190
|
+
...(transport ? { transport } : {}),
|
|
191
|
+
...(reconnect ? { reconnect } : {}),
|
|
192
|
+
...(schedule ? { schedule } : {}),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const log = logger || {};
|
|
196
|
+
|
|
197
|
+
/** (Re)announce presence with the current live job set. Fire-and-forget: the
|
|
198
|
+
* REGISTER frame is what makes the worker appear; the returned promise only
|
|
199
|
+
* resolves on a SERVE (disabled here), so we never await it and swallow its
|
|
200
|
+
* rejection so a missing SERVE is not an unhandled rejection. */
|
|
201
|
+
const refreshPresence = () => {
|
|
202
|
+
let jobs = [];
|
|
203
|
+
try {
|
|
204
|
+
jobs = listJobKeys() || [];
|
|
205
|
+
} catch {
|
|
206
|
+
jobs = [];
|
|
207
|
+
}
|
|
208
|
+
const cap = presenceCapability(declaredCapability, jobs);
|
|
209
|
+
// register() enqueues the frame even while the channel is down (it drains on
|
|
210
|
+
// connect); catch guards the deliberately-never-resolving SERVE promise.
|
|
211
|
+
Promise.resolve(client.register({ capability: cap })).catch(() => {});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Lifecycle fan-out: the client fires onOpen on the first connect AND on every
|
|
215
|
+
// reconnect. Split that into a one-shot "connect" and a repeated "reconnect"
|
|
216
|
+
// so C4 can distinguish the initial attach from a recovery flush.
|
|
217
|
+
let hasConnected = false;
|
|
218
|
+
const connectListeners = new Set();
|
|
219
|
+
const reconnectListeners = new Set();
|
|
220
|
+
const disconnectListeners = new Set();
|
|
221
|
+
const fan = (set, arg) => {
|
|
222
|
+
for (const fn of set) {
|
|
223
|
+
try {
|
|
224
|
+
fn(arg);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
try {
|
|
227
|
+
log.warn?.(`work-channel listener threw: ${err?.message || err}`);
|
|
228
|
+
} catch {
|
|
229
|
+
/* never let a listener failure escape the lifecycle dispatch */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
client.onOpen(() => {
|
|
236
|
+
if (!hasConnected) {
|
|
237
|
+
hasConnected = true;
|
|
238
|
+
fan(connectListeners);
|
|
239
|
+
// The presence announce buffered before connect drains on this first open,
|
|
240
|
+
// so no re-announce is needed here — avoid a redundant duplicate register.
|
|
241
|
+
} else {
|
|
242
|
+
fan(reconnectListeners);
|
|
243
|
+
// Re-announce presence on RECONNECT so the durable presence row reflects
|
|
244
|
+
// this worker's current identity/host/jobs after a hub restart/outage.
|
|
245
|
+
refreshPresence();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
client.onClose((info) => {
|
|
249
|
+
fan(disconnectListeners, info);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Announce presence immediately; the frame buffers and drains on connect.
|
|
253
|
+
refreshPresence();
|
|
254
|
+
|
|
255
|
+
const subscribe = (set) => (fn) => {
|
|
256
|
+
if (typeof fn !== 'function') return () => {};
|
|
257
|
+
set.add(fn);
|
|
258
|
+
return () => set.delete(fn);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** @type {WorkChannel} */
|
|
262
|
+
const channel = {
|
|
263
|
+
client,
|
|
264
|
+
refreshPresence,
|
|
265
|
+
// C3 (#42): the relay-lane sink. Delegates to the one connected client so
|
|
266
|
+
// relay frames ride the shared, buffered, QoS-ordered outbound path.
|
|
267
|
+
relayLane: () => ({
|
|
268
|
+
relay: (stream, chunk) => client.relay(stream, chunk),
|
|
269
|
+
}),
|
|
270
|
+
onConnect: subscribe(connectListeners),
|
|
271
|
+
onReconnect: subscribe(reconnectListeners),
|
|
272
|
+
onDisconnect: subscribe(disconnectListeners),
|
|
273
|
+
connected: () => client.connected,
|
|
274
|
+
buffered: () => client.buffered,
|
|
275
|
+
async stop(reason = 'worker stopped') {
|
|
276
|
+
try {
|
|
277
|
+
client.deregister(reason);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
try {
|
|
280
|
+
log.warn?.(`agentic deregister failed: ${err?.message || err}`);
|
|
281
|
+
client.close();
|
|
282
|
+
} catch {
|
|
283
|
+
/* best effort — never let shutdown hang on the channel */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return channel;
|
|
290
|
+
}
|