c8ctl-plugin-nano 1.48.0 → 1.50.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-endpoint.mjs +325 -0
- package/agentic.mjs +6 -0
- package/c8ctl-plugin.js +168 -0
- package/package.json +10 -8
- package/supervisor-engine.mjs +193 -0
- package/supervisor.dist.js +18 -18
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// The concrete host-connection agentic endpoint (ADR 0056; issue #160).
|
|
2
|
+
//
|
|
3
|
+
// #158 shipped the host-owned job-ownership protocol as an Effect *interface*
|
|
4
|
+
// (`AgenticEndpoint` → `AgenticHandle` implementing `register`/`heartbeat`/
|
|
5
|
+
// `deregister`/`claim`/`transcript`/`release`, identity explicit on every frame)
|
|
6
|
+
// but no concrete transport — the `0.10.0` `@nanobpm/agentic` had no `claim`/
|
|
7
|
+
// `release` presence frames to send. `@nanobpm/agentic` is now `^0.11.0`, which
|
|
8
|
+
// lands families 8/9 (`claim`/`release`), the `claim-release`/`multi-instance`
|
|
9
|
+
// negotiable features, and the additive negotiation module. This module is the
|
|
10
|
+
// concrete wire against that bump: ONE multiplexed WebSocket connection over
|
|
11
|
+
// which N supervised workers' presence + ownership + transcript frames ride,
|
|
12
|
+
// each frame carrying its `instance` EXPLICITLY (never the connection id — the
|
|
13
|
+
// assumption #154 structurally breaks).
|
|
14
|
+
//
|
|
15
|
+
// It produces a plain, Effect-free `RawEmitClient` (see `supervisor/src/emit.ts`)
|
|
16
|
+
// that the supervisor bundle lifts into the Effect `AgenticHandle`. Everything on
|
|
17
|
+
// the wire is CONSUMED through this plugin's single import surface (`agentic.mjs`
|
|
18
|
+
// → `@nanobpm/agentic` + `@nanobpm/urban-agent-client`); nothing is re-declared.
|
|
19
|
+
//
|
|
20
|
+
// The supervisor's `superviseAgentic` owns reconnect (it calls the endpoint's
|
|
21
|
+
// `connect` again on every drop) and the claim registry owns replay (the resync
|
|
22
|
+
// re-`register`s + re-`claim`s before transcript resumes), so this client is thin
|
|
23
|
+
// on purpose: one socket, encode-and-send, no buffering or reconnect of its own.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
encodeFrame,
|
|
27
|
+
decodeFrame,
|
|
28
|
+
MAX_SEQ,
|
|
29
|
+
validatePayload,
|
|
30
|
+
negotiate,
|
|
31
|
+
LOCAL_ADVERTISEMENT,
|
|
32
|
+
loadAgenticClient,
|
|
33
|
+
} from './agentic.mjs';
|
|
34
|
+
import { buildAgenticUrl } from './work-channel.mjs';
|
|
35
|
+
|
|
36
|
+
// Presence + ownership frames are facts — they ride the CONTROL lane so a
|
|
37
|
+
// transcript storm on the bulk lane can never delay a claim/release (the QoS
|
|
38
|
+
// ordering contract). Transcript chunks ride BULK.
|
|
39
|
+
const CONTROL_LANE = 'control';
|
|
40
|
+
const BULK_LANE = 'bulk';
|
|
41
|
+
|
|
42
|
+
// A transcript stream name that encodes BOTH the owning instance and the jobKey,
|
|
43
|
+
// so two workers' (or two jobs') transcript streams over the one connection can
|
|
44
|
+
// never collide. `encodeURIComponent` on each segment makes the join
|
|
45
|
+
// unambiguous regardless of what characters an instance/jobKey contains.
|
|
46
|
+
function composeTranscriptStream(instance, jobKey) {
|
|
47
|
+
return `t/${encodeURIComponent(instance)}/${encodeURIComponent(jobKey)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Reverse of {@link composeTranscriptStream}. Returns null for a stream that is
|
|
51
|
+
// not one of ours (a foreign/blackboard stream), so an inbound steer frame for
|
|
52
|
+
// an unrecognised stream is dropped rather than misrouted.
|
|
53
|
+
function parseTranscriptStream(stream) {
|
|
54
|
+
if (typeof stream !== 'string') return null;
|
|
55
|
+
const parts = stream.split('/');
|
|
56
|
+
if (parts.length !== 3 || parts[0] !== 't') return null;
|
|
57
|
+
try {
|
|
58
|
+
return { instance: decodeURIComponent(parts[1]), jobKey: decodeURIComponent(parts[2]) };
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Drop undefined/empty attributes from a declared capability so the enrolment
|
|
65
|
+
// attribute on `register` stays minimal (the S0 validator only requires
|
|
66
|
+
// `capability` to be an object and tolerates extra fields).
|
|
67
|
+
function cleanCapability(capability) {
|
|
68
|
+
const out = {};
|
|
69
|
+
if (capability && typeof capability === 'object') {
|
|
70
|
+
for (const [k, v] of Object.entries(capability)) {
|
|
71
|
+
if (v !== undefined && v !== null && v !== '') out[k] = v;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const textDecoder = new TextDecoder();
|
|
78
|
+
const textEncoder = new TextEncoder();
|
|
79
|
+
|
|
80
|
+
// Compute the negotiated protocol against the peer's advertisement. There is no
|
|
81
|
+
// wired advertisement-exchange frame in the `0.11.0` protocol yet, so a caller
|
|
82
|
+
// that has not learned the peer's support passes nothing and we assume the peer
|
|
83
|
+
// matches this build (full support) — additive negotiation still degrades
|
|
84
|
+
// correctly the moment a real remote advertisement is supplied (an old hub that
|
|
85
|
+
// never learned `claim`/`release` yields a negotiation without them, so those
|
|
86
|
+
// methods report unsupported and the adapter omits them).
|
|
87
|
+
function negotiatedSupport(remoteAdvertisement) {
|
|
88
|
+
const negotiated = negotiate(LOCAL_ADVERTISEMENT, remoteAdvertisement ?? LOCAL_ADVERTISEMENT);
|
|
89
|
+
return {
|
|
90
|
+
claimRelease:
|
|
91
|
+
negotiated.supportsFeature('claim-release') &&
|
|
92
|
+
negotiated.supportsFamily('claim') &&
|
|
93
|
+
negotiated.supportsFamily('release'),
|
|
94
|
+
// Inbound steer rides the relay lane as a delivery frame keyed by our
|
|
95
|
+
// transcript-stream naming; it is installable whenever relay is negotiated.
|
|
96
|
+
steer: negotiated.supportsFamily('relay'),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Open one multiplexed host connection and return a {@link RawEmitClient}.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} params
|
|
104
|
+
* @param {string} params.url the resolved `ws(s)://…/agentic?token=…` channel URL
|
|
105
|
+
* @param {import('@nanobpm/urban-agent-client').TransportFactory} params.transportFactory
|
|
106
|
+
* @param {number} params.incarnation producer generation stamped on transcript
|
|
107
|
+
* frames (strictly higher on each successive connection so a reconnected
|
|
108
|
+
* producer fences its stale predecessor on the hub's incarnation ring)
|
|
109
|
+
* @param {{ claimRelease: boolean, steer: boolean }} params.support negotiated capabilities
|
|
110
|
+
* @param {{ warn?: Function, debug?: Function }} [params.logger]
|
|
111
|
+
* @returns {import('./supervisor.dist.js').RawEmitClient}
|
|
112
|
+
*/
|
|
113
|
+
function openHostConnection({ url, transportFactory, incarnation, support, logger }) {
|
|
114
|
+
const log = logger || {};
|
|
115
|
+
const openCbs = [];
|
|
116
|
+
const closeCbs = [];
|
|
117
|
+
let steerRoute = null;
|
|
118
|
+
let open = false;
|
|
119
|
+
let hasClosed = false;
|
|
120
|
+
let seq = 0;
|
|
121
|
+
|
|
122
|
+
// Monotonic uint32 sequence with wraparound — the relay resume-from-offset
|
|
123
|
+
// counter; mirrors the client lib's own seq handling.
|
|
124
|
+
const nextSeq = () => {
|
|
125
|
+
const s = seq;
|
|
126
|
+
seq = seq >= MAX_SEQ ? 0 : seq + 1;
|
|
127
|
+
return s;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let transport = null;
|
|
131
|
+
let closedFired = false;
|
|
132
|
+
|
|
133
|
+
// Notify registered onClose subscribers at most once, regardless of whether a
|
|
134
|
+
// caller-initiated close() or the transport's own onClose fires first. Higher
|
|
135
|
+
// layers (AgenticHandle.closed) must observe the drop even if the underlying
|
|
136
|
+
// transport delays or omits its close callback.
|
|
137
|
+
const fireClosed = () => {
|
|
138
|
+
if (closedFired) return;
|
|
139
|
+
closedFired = true;
|
|
140
|
+
open = false;
|
|
141
|
+
hasClosed = true;
|
|
142
|
+
for (const cb of closeCbs) cb();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const send = (lane, family, payload) => {
|
|
146
|
+
if (!open || transport === null) {
|
|
147
|
+
// Contract: a not-open transport throws synchronously so the adapter can
|
|
148
|
+
// surface a SupervisorError the best-effort caller swallows (the next
|
|
149
|
+
// resync replays it). Guard here too so we never encode into a dead socket.
|
|
150
|
+
throw new Error(`agentic transport not open (cannot send ${family})`);
|
|
151
|
+
}
|
|
152
|
+
const check = validatePayload(family, payload);
|
|
153
|
+
if (!check.ok) {
|
|
154
|
+
const detail = check.errors.map((e) => `${e.code}:${e.message}`).join(', ');
|
|
155
|
+
throw new Error(`invalid ${family} payload — ${detail}`);
|
|
156
|
+
}
|
|
157
|
+
transport.send(encodeFrame({ lane, family, seq: nextSeq(), payload }));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const handleInbound = (bytes) => {
|
|
161
|
+
if (steerRoute === null) return;
|
|
162
|
+
let frame;
|
|
163
|
+
try {
|
|
164
|
+
frame = decodeFrame(bytes);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
log.debug?.(`agentic: undecodable inbound frame dropped — ${err?.message || err}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Inbound steer rides the relay family as a DELIVERY chunk ({ stream, offset,
|
|
170
|
+
// chunk }, no `op`) whose stream is one of our transcript streams; the stream
|
|
171
|
+
// carries the target instance + jobKey. A frame for a foreign stream is
|
|
172
|
+
// dropped, never misrouted.
|
|
173
|
+
if (frame.family !== 'relay') return;
|
|
174
|
+
const payload = frame.payload;
|
|
175
|
+
if (!payload || typeof payload !== 'object' || 'op' in payload) return;
|
|
176
|
+
const target = parseTranscriptStream(payload.stream);
|
|
177
|
+
if (target === null) return;
|
|
178
|
+
const chunk = typeof payload.chunk === 'string' ? textEncoder.encode(payload.chunk) : new Uint8Array(0);
|
|
179
|
+
try {
|
|
180
|
+
steerRoute(target.instance, target.jobKey, chunk);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log.debug?.(`agentic: steer route threw — ${err?.message || err}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
transport = transportFactory(url, {
|
|
187
|
+
onOpen() {
|
|
188
|
+
open = true;
|
|
189
|
+
for (const cb of openCbs) cb();
|
|
190
|
+
},
|
|
191
|
+
onFrame(bytes) {
|
|
192
|
+
handleInbound(bytes);
|
|
193
|
+
},
|
|
194
|
+
onClose() {
|
|
195
|
+
fireClosed();
|
|
196
|
+
},
|
|
197
|
+
onError(err) {
|
|
198
|
+
// Non-fatal on its own; a close follows and drives the reconnect. Surface
|
|
199
|
+
// it for diagnosis only.
|
|
200
|
+
log.debug?.(`agentic transport error — ${err?.message || err}`);
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
register(instance, capability) {
|
|
206
|
+
send(CONTROL_LANE, 'register', { instance, capability: cleanCapability(capability) });
|
|
207
|
+
},
|
|
208
|
+
heartbeat(instance) {
|
|
209
|
+
send(CONTROL_LANE, 'heartbeat', { instance });
|
|
210
|
+
},
|
|
211
|
+
deregister(instance, reason) {
|
|
212
|
+
send(CONTROL_LANE, 'deregister', reason ? { instance, reason } : { instance });
|
|
213
|
+
},
|
|
214
|
+
claim(instance, jobKey) {
|
|
215
|
+
send(CONTROL_LANE, 'claim', { instance, jobKey });
|
|
216
|
+
},
|
|
217
|
+
release(instance, jobKey) {
|
|
218
|
+
send(CONTROL_LANE, 'release', { instance, jobKey });
|
|
219
|
+
},
|
|
220
|
+
transcript(instance, jobKey, chunk) {
|
|
221
|
+
send(BULK_LANE, 'relay', {
|
|
222
|
+
op: 'produce',
|
|
223
|
+
stream: composeTranscriptStream(instance, jobKey),
|
|
224
|
+
incarnation,
|
|
225
|
+
chunk: textDecoder.decode(chunk),
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
onSteer(route) {
|
|
229
|
+
steerRoute = route;
|
|
230
|
+
},
|
|
231
|
+
onOpen(cb) {
|
|
232
|
+
// If the transport is already open (injected/synchronous factories can
|
|
233
|
+
// open before this registers), fire immediately — otherwise the queued
|
|
234
|
+
// callback never runs and connect() hangs waiting for `opened`.
|
|
235
|
+
if (open) {
|
|
236
|
+
cb();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
openCbs.push(cb);
|
|
240
|
+
},
|
|
241
|
+
onClose(cb) {
|
|
242
|
+
// Mirror onOpen: if the transport already closed (injected/synchronous
|
|
243
|
+
// factories can close before this registers), fire immediately —
|
|
244
|
+
// otherwise a close-before-open is never observable and connect() hangs
|
|
245
|
+
// waiting for `opened` (neither onOpen nor onClose would ever run).
|
|
246
|
+
if (hasClosed) {
|
|
247
|
+
cb();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
closeCbs.push(cb);
|
|
251
|
+
},
|
|
252
|
+
close() {
|
|
253
|
+
// Mark closed and drop the transport reference locally so post-close
|
|
254
|
+
// send() reliably throws (the not-open contract) even if the underlying
|
|
255
|
+
// transport delays or omits its close callback.
|
|
256
|
+
open = false;
|
|
257
|
+
hasClosed = true;
|
|
258
|
+
const t = transport;
|
|
259
|
+
transport = null;
|
|
260
|
+
try {
|
|
261
|
+
t?.close();
|
|
262
|
+
} catch {
|
|
263
|
+
/* idempotent best-effort teardown — never throw on close */
|
|
264
|
+
}
|
|
265
|
+
// Notify subscribers ourselves — never rely on the transport re-entering
|
|
266
|
+
// onClose after a caller-initiated teardown (it may delay or omit it),
|
|
267
|
+
// which would leave AgenticHandle.closed pending indefinitely. Idempotent:
|
|
268
|
+
// a subsequent transport onClose is a no-op.
|
|
269
|
+
fireClosed();
|
|
270
|
+
},
|
|
271
|
+
supportsClaimRelease: support.claimRelease,
|
|
272
|
+
supportsSteer: support.steer,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build the `RawEmitConnect` factory the supervisor's `makeAgenticEndpoint`
|
|
278
|
+
* lifts into `deps.agenticEndpoint`. Each returned `connect()` opens ONE fresh
|
|
279
|
+
* multiplexed host connection (a reconnect calls it again), stamped with a
|
|
280
|
+
* strictly-increasing incarnation so a reconnected transcript producer fences
|
|
281
|
+
* its stale predecessor.
|
|
282
|
+
*
|
|
283
|
+
* The WebSocket transport is loaded once through the plugin's single import
|
|
284
|
+
* surface (`loadAgenticClient()` — which installs the source→dist resolve hook so
|
|
285
|
+
* the client is importable under stock Node); a test injects its own
|
|
286
|
+
* `transportFactory` and never touches the real client.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} opts
|
|
289
|
+
* @param {string} opts.url the app's HTTP(S) base URL (channel served same-port at `/agentic`)
|
|
290
|
+
* @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
|
|
291
|
+
* @param {string} [opts.credential] capability credential (carried as `?capability=`)
|
|
292
|
+
* @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
|
|
293
|
+
* the peer's advertised support; omit to assume full support (see {@link negotiatedSupport})
|
|
294
|
+
* @param {number} [opts.incarnationBase] first transcript incarnation (default `Date.now()`)
|
|
295
|
+
* @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transportFactory] injectable transport (tests)
|
|
296
|
+
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
297
|
+
* @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
|
|
298
|
+
*/
|
|
299
|
+
export async function createRawEmitConnect(opts) {
|
|
300
|
+
const { url, token, credential, remoteAdvertisement, incarnationBase, transportFactory, logger } = opts || {};
|
|
301
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
302
|
+
throw new Error('createRawEmitConnect requires an agentic channel base url');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
306
|
+
const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
|
|
307
|
+
const support = negotiatedSupport(remoteAdvertisement);
|
|
308
|
+
|
|
309
|
+
// Strictly-increasing per-connection incarnation so successive reconnects fence
|
|
310
|
+
// their predecessor on the hub's transcript ring (a monotonic takeover counter,
|
|
311
|
+
// seeded from the clock so a later-started process starts ahead).
|
|
312
|
+
let generation = Number.isInteger(incarnationBase) && incarnationBase >= 0 ? incarnationBase : Date.now();
|
|
313
|
+
|
|
314
|
+
return () =>
|
|
315
|
+
openHostConnection({
|
|
316
|
+
url: channelUrl,
|
|
317
|
+
transportFactory: factory,
|
|
318
|
+
incarnation: generation++,
|
|
319
|
+
support,
|
|
320
|
+
logger,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Exposed for unit tests / reuse.
|
|
325
|
+
export { composeTranscriptStream, parseTranscriptStream, negotiatedSupport, cleanCapability };
|
package/agentic.mjs
CHANGED
|
@@ -58,6 +58,12 @@ export {
|
|
|
58
58
|
validateVocabDocument,
|
|
59
59
|
// per-family payload contracts
|
|
60
60
|
validatePayload,
|
|
61
|
+
// additive capability/version negotiation (claim/release degrade gracefully)
|
|
62
|
+
PROTOCOL_VERSION,
|
|
63
|
+
PROTOCOL_FEATURES,
|
|
64
|
+
LOCAL_ADVERTISEMENT,
|
|
65
|
+
parseAdvertisement,
|
|
66
|
+
negotiate,
|
|
61
67
|
// language-neutral hex helpers (used to hold the codec to the corpus)
|
|
62
68
|
bytesToHex,
|
|
63
69
|
hexToBytes,
|
package/c8ctl-plugin.js
CHANGED
|
@@ -3239,6 +3239,172 @@ function loadSupervisorRuntime() {
|
|
|
3239
3239
|
return _supervisorRuntime;
|
|
3240
3240
|
}
|
|
3241
3241
|
|
|
3242
|
+
// Build the concrete single host-connection agentic endpoint (issue #160) the
|
|
3243
|
+
// supervisor consumes as `deps.agenticEndpoint`. Composes the raw-JS wire client
|
|
3244
|
+
// (`agentic-endpoint.mjs`, one multiplexed connection over `@nanobpm/agentic`
|
|
3245
|
+
// `^0.11.0`'s `claim`/`release` families, consumed through this plugin's single
|
|
3246
|
+
// import surface) with the supervisor bundle's `makeAgenticEndpoint` adapter,
|
|
3247
|
+
// which lifts the plain client into the Effect `AgenticHandle`. This is the ONE
|
|
3248
|
+
// place the ownership/presence/transcript wire is instantiated for the
|
|
3249
|
+
// single-owner runtime. It is the eventual replacement for the per-worker
|
|
3250
|
+
// `createWorkChannel` fan-out — where every `nano work` process opened its own
|
|
3251
|
+
// socket for a single identity — but that per-worker flip is deferred, so
|
|
3252
|
+
// `createWorkChannel` is still imported and used elsewhere in this file for now.
|
|
3253
|
+
// The ~100 KB Effect surface loads lazily on the first `createAgenticEndpoint()`
|
|
3254
|
+
// call (which awaits `loadSupervisorRuntime()`) — not at module load and not
|
|
3255
|
+
// deferred to `endpoint.connect()` — so a plugin invocation that never runs an
|
|
3256
|
+
// agentic connection never pays for it.
|
|
3257
|
+
//
|
|
3258
|
+
// @param {object} opts see `createRawEmitConnect` in `agentic-endpoint.mjs`
|
|
3259
|
+
// (`url`, `token`, `credential`, optional `remoteAdvertisement`, `logger`, …)
|
|
3260
|
+
// @returns {Promise<import('./supervisor.dist.js').AgenticEndpoint>}
|
|
3261
|
+
async function createAgenticEndpoint(opts) {
|
|
3262
|
+
const { makeAgenticEndpoint } = await loadSupervisorRuntime();
|
|
3263
|
+
const { createRawEmitConnect } = await import('./agentic-endpoint.mjs');
|
|
3264
|
+
const connect = await createRawEmitConnect(opts);
|
|
3265
|
+
return makeAgenticEndpoint(connect);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// Compose the monolith's real edges into the single-owner supervisor runtime's
|
|
3269
|
+
// injected ports (issue #156) — the live-cut-over seam that turns the #154
|
|
3270
|
+
// runtime interface into a runnable host owner. This is the ONE place the
|
|
3271
|
+
// concrete `EngineClient`/`ReconcileReader`/`JobRunner`/`Logger` are assembled
|
|
3272
|
+
// for the runtime, the direct analogue of `createAgenticEndpoint` for the
|
|
3273
|
+
// ownership wire:
|
|
3274
|
+
//
|
|
3275
|
+
// - engine → `supervisor-engine.mjs` (direct `POST /v2/jobs/activation`
|
|
3276
|
+
// + `PATCH /v2/jobs/{key}/timeout`), REPLACING the SDK job
|
|
3277
|
+
// worker per the issue, base/auth derived from the SAME
|
|
3278
|
+
// `resolveWorkerEngineBase` chain the reconcile read uses;
|
|
3279
|
+
// - reconcileReader → the existing `defaultC8RestReader` (a keep-alive,
|
|
3280
|
+
// IPv4-first `httpC8RestReader`), whose shape already IS a
|
|
3281
|
+
// `RawReconcileReader`;
|
|
3282
|
+
// - scan → `demand.scanTaskDefinitions` via `scanAgentTaskLeaves`,
|
|
3283
|
+
// the single agentic-leaf source of truth (no drift);
|
|
3284
|
+
// - logger → `getLogger()`;
|
|
3285
|
+
// - runner → the caller's raw `{ run(job): Promise<void> }` (the
|
|
3286
|
+
// `runAgentJob`-owning-completion harness). REQUIRED to
|
|
3287
|
+
// actually execute; a test injects a fake.
|
|
3288
|
+
//
|
|
3289
|
+
// Returns everything a JS caller needs to run `makeSupervisor` — the assembled
|
|
3290
|
+
// `deps`, the seeded `registry` (so workers can be add/remove'd live), and the
|
|
3291
|
+
// `makeSupervisor` + `Effect` handles from the bundle. NOTE (deferred, issue
|
|
3292
|
+
// #156): the actual hot-path flip — deleting the per-type SDK pollers, the
|
|
3293
|
+
// process-wide `singleFlight`, and the per-process reconcile crawl in `workAgent`
|
|
3294
|
+
// and running `makeSupervisor(deps).run` as the single per-host owner — is NOT
|
|
3295
|
+
// wired here; it deletes battle-tested crash-safety code and can only be
|
|
3296
|
+
// validated against a live engine, so it is intentionally left as the follow-up
|
|
3297
|
+
// this seam unblocks. Constructing deps is side-effect-free (no socket opens, no
|
|
3298
|
+
// activation) until the caller forks `supervisor.run`.
|
|
3299
|
+
//
|
|
3300
|
+
// @param {object} opts
|
|
3301
|
+
// @param {{ run(job): Promise<void> }} opts.runner raw job runner (required to run)
|
|
3302
|
+
// @param {object} [opts.camunda] SDK client, for base/auth derivation
|
|
3303
|
+
// @param {{ baseUrl: string, token?: string }} [opts.restConfig] explicit REST config (else derived)
|
|
3304
|
+
// @param {Record<string,string>} [opts.authHeaders] ready-made auth header map
|
|
3305
|
+
// @param {string} [opts.worker] worker id stamped on activations
|
|
3306
|
+
// @param {Array<{id: string, types?: Iterable<string>, capacity?: number}>} [opts.workers] registry seed
|
|
3307
|
+
// @param {string} [opts.autoWorkerId] worker whose types the reconcile loop rewrites
|
|
3308
|
+
// @param {import('./supervisor.dist.js').AgenticEndpoint} [opts.agenticEndpoint] the ownership wire
|
|
3309
|
+
// @param {object} [opts.agenticConfig] reconnect backoff config
|
|
3310
|
+
// @param {string} [opts.scope] reconcile process-id scope narrowing
|
|
3311
|
+
// @param {object} [opts.config] partial SupervisorConfig overrides
|
|
3312
|
+
// @param {{ searchProcessDefinitionKeys: Function, getProcessDefinitionXml: Function }} [opts.reconcileReader] raw reconcile reader overriding the default keep-alive `defaultC8RestReader`
|
|
3313
|
+
// @param {typeof fetch} [opts.fetchImpl] injected fetch (tests)
|
|
3314
|
+
// @param {NodeJS.ProcessEnv} [opts.env]
|
|
3315
|
+
// @returns {Promise<{ deps: object, registry: object, makeSupervisor: Function, Effect: object, Fiber: object }>}
|
|
3316
|
+
async function createSupervisorDeps(opts = {}) {
|
|
3317
|
+
const {
|
|
3318
|
+
runner,
|
|
3319
|
+
camunda,
|
|
3320
|
+
restConfig,
|
|
3321
|
+
authHeaders,
|
|
3322
|
+
worker,
|
|
3323
|
+
workers = [],
|
|
3324
|
+
autoWorkerId,
|
|
3325
|
+
agenticEndpoint,
|
|
3326
|
+
agenticConfig,
|
|
3327
|
+
scope = '',
|
|
3328
|
+
config,
|
|
3329
|
+
fetchImpl,
|
|
3330
|
+
env = process.env,
|
|
3331
|
+
} = opts;
|
|
3332
|
+
if (!runner || typeof runner.run !== 'function') {
|
|
3333
|
+
throw new TypeError('createSupervisorDeps: `runner` must be a raw job runner `{ run(job): Promise<void> }`');
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
const rt = await loadSupervisorRuntime();
|
|
3337
|
+
const { demand } = await import('./agentic.mjs');
|
|
3338
|
+
const { createRawEngineClient } = await import('./supervisor-engine.mjs');
|
|
3339
|
+
|
|
3340
|
+
// Base/auth: the single canonical worker-engine chain (explicit restConfig →
|
|
3341
|
+
// profile restAddress → localhost), ALWAYS run through the token same-origin
|
|
3342
|
+
// gate — even when a caller pins the base via `restConfig` — so token
|
|
3343
|
+
// derivation (env/config REST token, same-origin agentic fallback) is never
|
|
3344
|
+
// bypassed. An explicit `restConfig.token` still wins over the gate's derived
|
|
3345
|
+
// token; a caller passing only `{ baseUrl }` no longer silently skips auth.
|
|
3346
|
+
const gated = resolveBrokerRestConfig(env, {
|
|
3347
|
+
baseUrl:
|
|
3348
|
+
(restConfig && restConfig.baseUrl) ||
|
|
3349
|
+
(camunda ? resolveWorkerEngineBase(camunda, env) : undefined),
|
|
3350
|
+
});
|
|
3351
|
+
const rc = {
|
|
3352
|
+
baseUrl: gated.baseUrl,
|
|
3353
|
+
token: (restConfig && restConfig.token) || gated.token,
|
|
3354
|
+
};
|
|
3355
|
+
|
|
3356
|
+
// Auth: an explicit ready-made `authHeaders` map wins; else a bare REST token
|
|
3357
|
+
// (`rc.token`) is applied by the engine client itself; else — on OAuth/basic
|
|
3358
|
+
// profiles where no bare token exists — derive the headers from the SDK client's
|
|
3359
|
+
// `getAuthHeaders()` (mirroring resolveLinkedPromptSource). Guarded so an older or
|
|
3360
|
+
// atypical client runtime degrades to the token/none path rather than throw.
|
|
3361
|
+
let resolvedAuthHeaders = authHeaders;
|
|
3362
|
+
if (!resolvedAuthHeaders && !rc.token && camunda && typeof camunda.getAuthHeaders === 'function') {
|
|
3363
|
+
// Resolve PER CALL, not once at startup: SDK auth headers (e.g. an OAuth
|
|
3364
|
+
// bearer) rotate on token refresh, so a long-lived supervisor must re-derive
|
|
3365
|
+
// them on every engine call — a cached startup snapshot would silently expire
|
|
3366
|
+
// and fail activations. Pass a resolver the engine client invokes per request.
|
|
3367
|
+
resolvedAuthHeaders = async () => {
|
|
3368
|
+
try {
|
|
3369
|
+
return await camunda.getAuthHeaders();
|
|
3370
|
+
} catch {
|
|
3371
|
+
return undefined;
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
const engine = rt.makeEngineClient(
|
|
3377
|
+
createRawEngineClient({ baseUrl: rc.baseUrl, token: rc.token, authHeaders: resolvedAuthHeaders, worker, fetchImpl }),
|
|
3378
|
+
);
|
|
3379
|
+
// `reconcileReader` (a raw `{ searchProcessDefinitionKeys, getProcessDefinitionXml }`)
|
|
3380
|
+
// may be injected to override the default keep-alive `httpC8RestReader` — the
|
|
3381
|
+
// caller may already hold one, and a test drives the crawl without a socket.
|
|
3382
|
+
const rawReader = opts.reconcileReader || (await defaultC8RestReader(rc));
|
|
3383
|
+
const reconcileReader = rt.makeReconcileReader(rawReader);
|
|
3384
|
+
const scan = (xml) => scanAgentTaskLeaves(xml, demand.scanTaskDefinitions);
|
|
3385
|
+
const logger = rt.asLogger(getLogger());
|
|
3386
|
+
|
|
3387
|
+
const registry = await rt.Effect.runPromise(rt.makeRegistry());
|
|
3388
|
+
for (const w of workers) {
|
|
3389
|
+
await rt.Effect.runPromise(registry.add(w.id, w.types || [], w.capacity ?? 1));
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
const deps = rt.makeSupervisorDeps({
|
|
3393
|
+
engine,
|
|
3394
|
+
runner: rt.makeJobRunner(runner),
|
|
3395
|
+
registry,
|
|
3396
|
+
reconcileReader,
|
|
3397
|
+
scan,
|
|
3398
|
+
logger,
|
|
3399
|
+
autoWorkerId,
|
|
3400
|
+
agenticEndpoint,
|
|
3401
|
+
agenticConfig,
|
|
3402
|
+
config: scope ? { ...config, scope } : config,
|
|
3403
|
+
});
|
|
3404
|
+
|
|
3405
|
+
return { deps, registry, makeSupervisor: rt.makeSupervisor, Effect: rt.Effect, Fiber: rt.Fiber };
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3242
3408
|
// Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
|
|
3243
3409
|
// `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
|
|
3244
3410
|
// worker always fetches `/content/binary`.
|
|
@@ -12556,6 +12722,8 @@ export {
|
|
|
12556
12722
|
readDeployedAgentJobTypes,
|
|
12557
12723
|
resolveAutoJobTypes,
|
|
12558
12724
|
loadSupervisorRuntime,
|
|
12725
|
+
createAgenticEndpoint,
|
|
12726
|
+
createSupervisorDeps,
|
|
12559
12727
|
enableEngineHappyEyeballs,
|
|
12560
12728
|
preferIpv4Resolution,
|
|
12561
12729
|
ipv4FirstNodeOptions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.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",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"platforms.mjs",
|
|
25
25
|
"agentic.mjs",
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
|
+
"agentic-endpoint.mjs",
|
|
28
|
+
"supervisor-engine.mjs",
|
|
27
29
|
"work-channel.mjs",
|
|
28
30
|
"work-relay.mjs",
|
|
29
31
|
"work-buffer.mjs",
|
|
@@ -67,12 +69,12 @@
|
|
|
67
69
|
},
|
|
68
70
|
"optionalDependencies": {
|
|
69
71
|
"node-pty": "^1.0.0",
|
|
70
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
71
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
72
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
73
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
74
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
72
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.50.0",
|
|
73
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.50.0",
|
|
74
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.50.0",
|
|
75
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.50.0",
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.50.0",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.50.0",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.50.0"
|
|
77
79
|
}
|
|
78
80
|
}
|