c8ctl-plugin-nano 1.54.0 → 1.55.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 +219 -231
- package/agentic.mjs +18 -0
- package/package.json +11 -10
- package/supervisor-log-ring.mjs +162 -0
- package/supervisor.dist.js +9 -9
- package/work-relay.mjs +8 -19
package/agentic-endpoint.mjs
CHANGED
|
@@ -1,178 +1,78 @@
|
|
|
1
|
-
// The concrete host-connection agentic endpoint (ADR 0056;
|
|
1
|
+
// The concrete host-connection agentic endpoint (ADR 0056; issues #160, #186).
|
|
2
2
|
//
|
|
3
3
|
// #158 shipped the host-owned job-ownership protocol as an Effect *interface*
|
|
4
4
|
// (`AgenticEndpoint` → `AgenticHandle` implementing `register`/`heartbeat`/
|
|
5
5
|
// `deregister`/`claim`/`transcript`/`release`, identity explicit on every frame)
|
|
6
|
-
// but no concrete transport
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
6
|
+
// but no concrete transport. #160 wired a bespoke one by hand. Since #546 (in
|
|
7
|
+
// `@nanobpm/agentic` 0.12.0) the package ships a high-level `@nanobpm/agentic/
|
|
8
|
+
// emit` `AgenticEmitClient` that already provides everything this file used to
|
|
9
|
+
// hand-roll: multiplexed multi-instance presence, register/heartbeat/deregister,
|
|
10
|
+
// idempotent claim/release, transcript emit over QoS lanes, additive
|
|
11
|
+
// negotiation, capability shaping, and — critically — reconnect-resync from its
|
|
12
|
+
// own write-through shadow of the presence + in-flight-claim maps. #186 retires
|
|
13
|
+
// the bespoke emit surface in favour of that client and its injective
|
|
14
|
+
// `composeStreamId`/`parseStreamId` stream-id codec.
|
|
14
15
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// This module is now a THIN adapter: it lifts one `AgenticEmitClient` into the
|
|
17
|
+
// plain, Effect-free `RawEmitClient` port (`supervisor/src/emit.ts`) the
|
|
18
|
+
// supervisor bundle lifts into the Effect `AgenticHandle`. Two seams remain the
|
|
19
|
+
// adapter's responsibility because the emit client is a pure emitter that holds
|
|
20
|
+
// no inbound sub-protocol:
|
|
19
21
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// 1. Inbound steer routing — the client ignores inbound frames, so the
|
|
23
|
+
// EmitSocket adapter decodes each inbound relay DELIVERY frame and fans it
|
|
24
|
+
// to the installed steer route, keyed by the package `parseStreamId` codec
|
|
25
|
+
// (a foreign/blackboard stream is dropped, never misrouted).
|
|
26
|
+
// 2. Reconnect ownership — the emit client OWNS reconnect and re-emits presence
|
|
27
|
+
// + active claims from its shadow on every reconnect, so the RawEmitClient
|
|
28
|
+
// surfaces a mid-life drop only as a liveness transition (`onConnectionState`)
|
|
29
|
+
// and reserves its single-shot `onClose` for a permanent teardown. That
|
|
30
|
+
// retires the supervisor's manual re-register/re-claim resync (#186): the
|
|
31
|
+
// `ownership.ts` registry stays the authority and the single write path into
|
|
32
|
+
// the client, while the wire-level replay is the client's job.
|
|
33
|
+
//
|
|
34
|
+
// Everything on the wire is CONSUMED through this plugin's single import surface
|
|
35
|
+
// (`agentic.mjs` → `@nanobpm/agentic/emit` + `@nanobpm/urban-agent-client`);
|
|
36
|
+
// nothing is re-declared here.
|
|
24
37
|
|
|
25
38
|
import {
|
|
26
|
-
|
|
39
|
+
AgenticEmitClient,
|
|
40
|
+
parseStreamId,
|
|
27
41
|
decodeFrame,
|
|
28
|
-
MAX_SEQ,
|
|
29
|
-
validatePayload,
|
|
30
|
-
negotiate,
|
|
31
|
-
LOCAL_ADVERTISEMENT,
|
|
32
42
|
loadAgenticClient,
|
|
33
43
|
} from './agentic.mjs';
|
|
34
44
|
import { buildAgenticUrl } from './work-channel.mjs';
|
|
35
45
|
|
|
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
46
|
const textDecoder = new TextDecoder();
|
|
78
47
|
const textEncoder = new TextEncoder();
|
|
79
48
|
|
|
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
49
|
/**
|
|
101
|
-
*
|
|
50
|
+
* Adapt one `@nanobpm/urban-agent-client` websocket transport (the `{ send,
|
|
51
|
+
* close }` value + `{ onOpen, onFrame, onClose, onError }` hook shape) to the
|
|
52
|
+
* `EmitSocket` the {@link AgenticEmitClient} drives (`send`/`close` +
|
|
53
|
+
* `onMessage`/`onOpen`/`onClose` registrations). Opens a fresh transport per
|
|
54
|
+
* call — the emit client invokes its `connect` factory once per (re)connect.
|
|
102
55
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* @param {
|
|
110
|
-
* @param {{ warn?: Function, debug?: Function }}
|
|
111
|
-
* @
|
|
112
|
-
* optional observer invoked on connection-state transitions: `'connected'`
|
|
113
|
-
* once the transport opens and `'disconnected'` when it drops (fired at most
|
|
114
|
-
* once per drop). A throwing observer is caught and logged, never propagated.
|
|
115
|
-
* @returns {import('./supervisor.dist.js').RawEmitClient}
|
|
56
|
+
* Inbound relay DELIVERY frames are decoded HERE and fanned to the current steer
|
|
57
|
+
* route (the emit client ignores inbound frames), keyed by {@link parseStreamId}
|
|
58
|
+
* so a frame for a foreign stream is dropped rather than misrouted.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} url the resolved `ws(s)://…/agentic?token=…` channel URL
|
|
61
|
+
* @param {import('@nanobpm/urban-agent-client').TransportFactory} transportFactory
|
|
62
|
+
* @param {() => (((instance: string, jobKey: string, chunk: Uint8Array) => void) | null)} getSteerRoute
|
|
63
|
+
* @param {{ warn?: Function, debug?: Function }} log
|
|
64
|
+
* @returns {import('@nanobpm/agentic/emit').EmitSocket}
|
|
116
65
|
*/
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const openCbs = [];
|
|
124
|
-
const closeCbs = [];
|
|
125
|
-
let steerRoute = null;
|
|
126
|
-
let open = false;
|
|
127
|
-
let hasClosed = false;
|
|
128
|
-
let seq = 0;
|
|
129
|
-
|
|
130
|
-
// Monotonic uint32 sequence with wraparound — the relay resume-from-offset
|
|
131
|
-
// counter; mirrors the client lib's own seq handling.
|
|
132
|
-
const nextSeq = () => {
|
|
133
|
-
const s = seq;
|
|
134
|
-
seq = seq >= MAX_SEQ ? 0 : seq + 1;
|
|
135
|
-
return s;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
let transport = null;
|
|
139
|
-
let closedFired = false;
|
|
140
|
-
|
|
141
|
-
// Notify registered onClose subscribers at most once, regardless of whether a
|
|
142
|
-
// caller-initiated close() or the transport's own onClose fires first. Higher
|
|
143
|
-
// layers (AgenticHandle.closed) must observe the drop even if the underlying
|
|
144
|
-
// transport delays or omits its close callback.
|
|
145
|
-
const fireClosed = () => {
|
|
146
|
-
if (closedFired) return;
|
|
147
|
-
closedFired = true;
|
|
148
|
-
open = false;
|
|
149
|
-
hasClosed = true;
|
|
150
|
-
// Guard each subscriber: a throwing onClose callback must not abort the loop
|
|
151
|
-
// or prevent notifyState('disconnected') from firing (which would strand the
|
|
152
|
-
// connection-state observer and could crash the worker/supervisor on a drop).
|
|
153
|
-
for (const cb of closeCbs) {
|
|
154
|
-
try { cb(); } catch (err) { log.debug?.(`agentic onClose subscriber threw — ${err?.message || err}`); }
|
|
155
|
-
}
|
|
156
|
-
notifyState('disconnected');
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const send = (lane, family, payload) => {
|
|
160
|
-
if (!open || transport === null) {
|
|
161
|
-
// Contract: a not-open transport throws synchronously so the adapter can
|
|
162
|
-
// surface a SupervisorError the best-effort caller swallows (the next
|
|
163
|
-
// resync replays it). Guard here too so we never encode into a dead socket.
|
|
164
|
-
throw new Error(`agentic transport not open (cannot send ${family})`);
|
|
165
|
-
}
|
|
166
|
-
const check = validatePayload(family, payload);
|
|
167
|
-
if (!check.ok) {
|
|
168
|
-
const detail = check.errors.map((e) => `${e.code}:${e.message}`).join(', ');
|
|
169
|
-
throw new Error(`invalid ${family} payload — ${detail}`);
|
|
170
|
-
}
|
|
171
|
-
transport.send(encodeFrame({ lane, family, seq: nextSeq(), payload }));
|
|
172
|
-
};
|
|
66
|
+
function makeEmitSocket(url, transportFactory, getSteerRoute, log) {
|
|
67
|
+
let onMessageCb = () => {};
|
|
68
|
+
let onOpenCb = () => {};
|
|
69
|
+
let onCloseCb = () => {};
|
|
70
|
+
let opened = false;
|
|
71
|
+
let closed = false;
|
|
173
72
|
|
|
174
|
-
const
|
|
175
|
-
|
|
73
|
+
const routeSteer = (bytes) => {
|
|
74
|
+
const route = getSteerRoute();
|
|
75
|
+
if (typeof route !== 'function') return;
|
|
176
76
|
let frame;
|
|
177
77
|
try {
|
|
178
78
|
frame = decodeFrame(bytes);
|
|
@@ -182,122 +82,222 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
|
|
|
182
82
|
}
|
|
183
83
|
// Inbound steer rides the relay family as a DELIVERY chunk ({ stream, offset,
|
|
184
84
|
// chunk }, no `op`) whose stream is one of our transcript streams; the stream
|
|
185
|
-
// carries the target instance + jobKey. A frame for a foreign stream is
|
|
85
|
+
// id carries the target instance + jobKey. A frame for a foreign stream is
|
|
186
86
|
// dropped, never misrouted.
|
|
187
87
|
if (frame.family !== 'relay') return;
|
|
188
88
|
const payload = frame.payload;
|
|
189
89
|
if (!payload || typeof payload !== 'object' || 'op' in payload) return;
|
|
190
|
-
const target =
|
|
191
|
-
if (target ===
|
|
90
|
+
const target = parseStreamId(payload.stream);
|
|
91
|
+
if (target === undefined) return;
|
|
192
92
|
const chunk = typeof payload.chunk === 'string' ? textEncoder.encode(payload.chunk) : new Uint8Array(0);
|
|
193
93
|
try {
|
|
194
|
-
|
|
94
|
+
// `target.stream` is `String(jobKey)` by the transcript convention.
|
|
95
|
+
route(target.instance, target.stream, chunk);
|
|
195
96
|
} catch (err) {
|
|
196
97
|
log.debug?.(`agentic: steer route threw — ${err?.message || err}`);
|
|
197
98
|
}
|
|
198
99
|
};
|
|
199
100
|
|
|
200
|
-
transport = transportFactory(url, {
|
|
101
|
+
const transport = transportFactory(url, {
|
|
201
102
|
onOpen() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// or prevent notifyState('connected') from firing.
|
|
205
|
-
for (const cb of openCbs) {
|
|
206
|
-
try { cb(); } catch (err) { log.debug?.(`agentic onOpen subscriber threw — ${err?.message || err}`); }
|
|
207
|
-
}
|
|
208
|
-
notifyState('connected');
|
|
103
|
+
opened = true;
|
|
104
|
+
onOpenCb();
|
|
209
105
|
},
|
|
210
106
|
onFrame(bytes) {
|
|
211
|
-
|
|
107
|
+
routeSteer(bytes);
|
|
108
|
+
onMessageCb(bytes);
|
|
212
109
|
},
|
|
213
110
|
onClose() {
|
|
214
|
-
|
|
111
|
+
closed = true;
|
|
112
|
+
onCloseCb();
|
|
215
113
|
},
|
|
216
114
|
onError(err) {
|
|
217
|
-
// Non-fatal on its own; a close follows and drives the reconnect.
|
|
218
|
-
// it for diagnosis only.
|
|
115
|
+
// Non-fatal on its own; a close follows and drives the client's reconnect.
|
|
219
116
|
log.debug?.(`agentic transport error — ${err?.message || err}`);
|
|
220
117
|
},
|
|
221
118
|
});
|
|
222
119
|
|
|
120
|
+
return {
|
|
121
|
+
send(bytes) {
|
|
122
|
+
// Drop sends outside the open window: a pre-open send (the client can
|
|
123
|
+
// emit before the transport's onOpen fires) or a post-close send would
|
|
124
|
+
// otherwise hit a transport that throws while connecting/torn down,
|
|
125
|
+
// producing noisy onError logs. The emit client replays presence + active
|
|
126
|
+
// claims on the next open anyway, so a dropped pre-open frame is not lost.
|
|
127
|
+
if (!opened || closed) return;
|
|
128
|
+
transport.send(bytes);
|
|
129
|
+
},
|
|
130
|
+
close() {
|
|
131
|
+
try {
|
|
132
|
+
transport.close();
|
|
133
|
+
} catch {
|
|
134
|
+
/* idempotent best-effort teardown — never throw on close */
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
onMessage(listener) {
|
|
138
|
+
if (typeof listener === 'function') onMessageCb = listener;
|
|
139
|
+
},
|
|
140
|
+
onOpen(listener) {
|
|
141
|
+
if (typeof listener !== 'function') return;
|
|
142
|
+
onOpenCb = listener;
|
|
143
|
+
// If the transport already opened before this registered (synchronous
|
|
144
|
+
// injected factories can), fire immediately.
|
|
145
|
+
if (opened) listener();
|
|
146
|
+
},
|
|
147
|
+
onClose(listener) {
|
|
148
|
+
if (typeof listener !== 'function') return;
|
|
149
|
+
onCloseCb = listener;
|
|
150
|
+
// Mirror onOpen: a close-before-registration is observable immediately.
|
|
151
|
+
if (closed) listener();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build a {@link import('./supervisor.dist.js').RawEmitClient} backed by ONE
|
|
158
|
+
* {@link AgenticEmitClient}. The client owns reconnect + resync internally; this
|
|
159
|
+
* adapter maps its emitter surface onto the port and installs the inbound-steer
|
|
160
|
+
* seam the client does not carry.
|
|
161
|
+
*/
|
|
162
|
+
function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, logger, onConnectionState }) {
|
|
163
|
+
const log = logger || {};
|
|
164
|
+
// The inbound-steer route, shared with every socket the client opens across
|
|
165
|
+
// reconnects (each fresh EmitSocket reads it, so steer survives a flap without
|
|
166
|
+
// a per-reconnect re-install).
|
|
167
|
+
const steerHolder = { route: null };
|
|
168
|
+
|
|
169
|
+
let firstOpenFired = false;
|
|
170
|
+
let permanentlyClosed = false;
|
|
171
|
+
const openSubs = [];
|
|
172
|
+
const closeSubs = [];
|
|
173
|
+
|
|
174
|
+
const notifyState = (state) => {
|
|
175
|
+
if (typeof onConnectionState !== 'function') return;
|
|
176
|
+
try {
|
|
177
|
+
onConnectionState(state);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log.debug?.(`agentic onConnectionState threw — ${err?.message || err}`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const client = new AgenticEmitClient({
|
|
184
|
+
connect: () => makeEmitSocket(channelUrl, transportFactory, () => steerHolder.route, log),
|
|
185
|
+
peerAdvertisement,
|
|
186
|
+
onOpen() {
|
|
187
|
+
// Fires AFTER the client's resync on every (re)connect. Track liveness on
|
|
188
|
+
// each, but resolve the single-shot RawEmitClient `onOpen` (which drives
|
|
189
|
+
// the Effect connect) only once, on the first open.
|
|
190
|
+
notifyState('connected');
|
|
191
|
+
if (firstOpenFired) return;
|
|
192
|
+
firstOpenFired = true;
|
|
193
|
+
for (const cb of openSubs.splice(0)) {
|
|
194
|
+
try {
|
|
195
|
+
cb();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
log.debug?.(`agentic onOpen subscriber threw — ${err?.message || err}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
onClose() {
|
|
202
|
+
// A transient mid-life drop: the client reconnects itself and replays
|
|
203
|
+
// presence + claims from its write-through shadow. Surface liveness only —
|
|
204
|
+
// the RawEmitClient's single-shot `onClose` is reserved for a permanent
|
|
205
|
+
// teardown so `superviseAgentic` never double-reconnects underneath it.
|
|
206
|
+
if (permanentlyClosed) return;
|
|
207
|
+
notifyState('disconnected');
|
|
208
|
+
},
|
|
209
|
+
onError(err) {
|
|
210
|
+
log.debug?.(`agentic emit error — ${err?.message || err}`);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Negotiated protocol is fixed at construction (additive): a legacy hub that
|
|
215
|
+
// never learned families 8/9 yields a negotiation without claim/release, so the
|
|
216
|
+
// adapter reports them unsupported and the supervisor degrades to a no-op.
|
|
217
|
+
const negotiated = client.protocol;
|
|
218
|
+
const supportsClaimRelease =
|
|
219
|
+
negotiated.supportsFeature('claim-release') &&
|
|
220
|
+
negotiated.supportsFamily('claim') &&
|
|
221
|
+
negotiated.supportsFamily('release');
|
|
222
|
+
const supportsSteer = negotiated.supportsFamily('relay');
|
|
223
|
+
|
|
224
|
+
// Open the first socket; the client drives every subsequent reconnect.
|
|
225
|
+
client.open();
|
|
226
|
+
|
|
223
227
|
return {
|
|
224
228
|
register(instance, capability) {
|
|
225
|
-
|
|
229
|
+
client.register(instance, capability || {});
|
|
226
230
|
},
|
|
227
231
|
heartbeat(instance) {
|
|
228
|
-
|
|
232
|
+
client.heartbeat(instance);
|
|
229
233
|
},
|
|
230
234
|
deregister(instance, reason) {
|
|
231
|
-
|
|
235
|
+
client.deregister(instance, reason);
|
|
232
236
|
},
|
|
233
237
|
claim(instance, jobKey) {
|
|
234
|
-
|
|
238
|
+
client.claim(instance, jobKey);
|
|
235
239
|
},
|
|
236
240
|
release(instance, jobKey) {
|
|
237
|
-
|
|
241
|
+
client.release(instance, jobKey);
|
|
238
242
|
},
|
|
239
243
|
transcript(instance, jobKey, chunk) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
incarnation,
|
|
244
|
-
chunk: textDecoder.decode(chunk),
|
|
245
|
-
});
|
|
244
|
+
// The transcript convention: stream = String(jobKey); the client composes
|
|
245
|
+
// the injective per-instance stream id via composeStreamId.
|
|
246
|
+
client.transcript({ instance, stream: String(jobKey) }, textDecoder.decode(chunk));
|
|
246
247
|
},
|
|
247
248
|
onSteer(route) {
|
|
248
|
-
|
|
249
|
+
steerHolder.route = route;
|
|
249
250
|
},
|
|
250
251
|
onOpen(cb) {
|
|
251
|
-
// If the
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
if (
|
|
252
|
+
// If already open (the client can open before this registers), fire now —
|
|
253
|
+
// otherwise the queued callback never runs and connect() hangs.
|
|
254
|
+
if (typeof cb !== 'function') return;
|
|
255
|
+
if (firstOpenFired) {
|
|
255
256
|
cb();
|
|
256
257
|
return;
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
+
openSubs.push(cb);
|
|
259
260
|
},
|
|
260
261
|
onClose(cb) {
|
|
261
|
-
// Mirror onOpen: if
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (hasClosed) {
|
|
262
|
+
// Mirror onOpen: if already permanently closed, fire immediately so a
|
|
263
|
+
// post-close subscriber still observes the drop.
|
|
264
|
+
if (typeof cb !== 'function') return;
|
|
265
|
+
if (permanentlyClosed) {
|
|
266
266
|
cb();
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
|
-
|
|
269
|
+
closeSubs.push(cb);
|
|
270
270
|
},
|
|
271
271
|
close() {
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
hasClosed = true;
|
|
277
|
-
const t = transport;
|
|
278
|
-
transport = null;
|
|
272
|
+
if (permanentlyClosed) return;
|
|
273
|
+
// Mark closed BEFORE teardown so the client's transient onClose observer
|
|
274
|
+
// no-ops and only this permanent path notifies subscribers + liveness.
|
|
275
|
+
permanentlyClosed = true;
|
|
279
276
|
try {
|
|
280
|
-
|
|
277
|
+
client.close();
|
|
281
278
|
} catch {
|
|
282
279
|
/* idempotent best-effort teardown — never throw on close */
|
|
283
280
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
for (const cb of closeSubs.splice(0)) {
|
|
282
|
+
try {
|
|
283
|
+
cb();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
log.debug?.(`agentic onClose subscriber threw — ${err?.message || err}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
notifyState('disconnected');
|
|
289
289
|
},
|
|
290
|
-
supportsClaimRelease
|
|
291
|
-
supportsSteer
|
|
290
|
+
supportsClaimRelease,
|
|
291
|
+
supportsSteer,
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
/**
|
|
296
296
|
* Build the `RawEmitConnect` factory the supervisor's `makeAgenticEndpoint`
|
|
297
|
-
* lifts into `deps.agenticEndpoint`. Each returned `connect()`
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
297
|
+
* lifts into `deps.agenticEndpoint`. Each returned `connect()` constructs ONE
|
|
298
|
+
* {@link AgenticEmitClient} over a single multiplexed host connection; the client
|
|
299
|
+
* owns its own reconnect + resync, so — unlike the retired bespoke client —
|
|
300
|
+
* `superviseAgentic` does not re-`connect` or re-emit per drop.
|
|
301
301
|
*
|
|
302
302
|
* The WebSocket transport is loaded once through the plugin's single import
|
|
303
303
|
* surface (`loadAgenticClient()` — which installs the source→dist resolve hook so
|
|
@@ -309,40 +309,28 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
|
|
|
309
309
|
* @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
|
|
310
310
|
* @param {string} [opts.credential] capability credential (carried as `?capability=`)
|
|
311
311
|
* @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
|
|
312
|
-
* the peer's advertised support; omit to assume full support
|
|
313
|
-
* @param {number} [opts.incarnationBase] first transcript incarnation (default `Date.now()`)
|
|
312
|
+
* the peer's advertised support; omit to assume full support
|
|
314
313
|
* @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transportFactory] injectable transport (tests)
|
|
315
314
|
* @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
|
|
316
|
-
* when
|
|
317
|
-
* liveness (e.g. the supervisor activity marker's agentic status)
|
|
315
|
+
* when the single host connection opens/drops, so a caller can track its liveness
|
|
318
316
|
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
319
317
|
* @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
|
|
320
318
|
*/
|
|
321
319
|
export async function createRawEmitConnect(opts) {
|
|
322
|
-
const { url, token, credential, remoteAdvertisement,
|
|
320
|
+
const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState } = opts || {};
|
|
323
321
|
if (typeof url !== 'string' || url.trim() === '') {
|
|
324
322
|
throw new Error('createRawEmitConnect requires an agentic channel base url');
|
|
325
323
|
}
|
|
326
324
|
|
|
327
325
|
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
328
326
|
const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
|
|
329
|
-
const support = negotiatedSupport(remoteAdvertisement);
|
|
330
|
-
|
|
331
|
-
// Strictly-increasing per-connection incarnation so successive reconnects fence
|
|
332
|
-
// their predecessor on the hub's transcript ring (a monotonic takeover counter,
|
|
333
|
-
// seeded from the clock so a later-started process starts ahead).
|
|
334
|
-
let generation = Number.isInteger(incarnationBase) && incarnationBase >= 0 ? incarnationBase : Date.now();
|
|
335
327
|
|
|
336
328
|
return () =>
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
buildRawEmitClient({
|
|
330
|
+
channelUrl,
|
|
339
331
|
transportFactory: factory,
|
|
340
|
-
|
|
341
|
-
support,
|
|
332
|
+
peerAdvertisement: remoteAdvertisement,
|
|
342
333
|
logger,
|
|
343
334
|
onConnectionState,
|
|
344
335
|
});
|
|
345
336
|
}
|
|
346
|
-
|
|
347
|
-
// Exposed for unit tests / reuse.
|
|
348
|
-
export { composeTranscriptStream, parseTranscriptStream, negotiatedSupport, cleanCapability };
|
package/agentic.mjs
CHANGED
|
@@ -102,6 +102,24 @@ export * as sessionAcp from '@nanobpm/agentic/session/acp';
|
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
103
103
|
export * as demand from '@nanobpm/agentic/demand';
|
|
104
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Ownership/presence EMIT client — @nanobpm/agentic/emit (nano-ide#557).
|
|
107
|
+
//
|
|
108
|
+
// The blessed client-side emitter: ONE multiplexed host connection that N
|
|
109
|
+
// instances share, emitting register/heartbeat/deregister/claim/release and the
|
|
110
|
+
// relay transcript sink with an EXPLICIT `instance` per frame, owning its own
|
|
111
|
+
// reconnect resync and additive version negotiation. `agentic-endpoint.mjs`
|
|
112
|
+
// builds the plugin's concrete `RawEmitClient` on this instead of hand-rolling a
|
|
113
|
+
// parallel client-ownership layer. `composeStreamId`/`parseStreamId` are the one
|
|
114
|
+
// injective transcript-stream-id codec both this producer (routing inbound steer
|
|
115
|
+
// back to `{instance, jobKey}`) and the nano-workforce consumer derive from.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
export {
|
|
118
|
+
AgenticEmitClient,
|
|
119
|
+
composeStreamId,
|
|
120
|
+
parseStreamId,
|
|
121
|
+
} from '@nanobpm/agentic/emit';
|
|
122
|
+
|
|
105
123
|
// ---------------------------------------------------------------------------
|
|
106
124
|
// Worker-side channel client — @nanobpm/urban-agent-client.
|
|
107
125
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.55.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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
27
|
"agentic-endpoint.mjs",
|
|
28
28
|
"supervisor-engine.mjs",
|
|
29
|
+
"supervisor-log-ring.mjs",
|
|
29
30
|
"work-channel.mjs",
|
|
30
31
|
"work-relay.mjs",
|
|
31
32
|
"work-buffer.mjs",
|
|
@@ -65,17 +66,17 @@
|
|
|
65
66
|
"typescript": "^5.9.3"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
68
|
-
"@nanobpm/agentic": "^0.
|
|
69
|
-
"@nanobpm/urban-agent-client": "^0.1.
|
|
69
|
+
"@nanobpm/agentic": "^0.13.0",
|
|
70
|
+
"@nanobpm/urban-agent-client": "^0.1.13"
|
|
70
71
|
},
|
|
71
72
|
"optionalDependencies": {
|
|
72
73
|
"node-pty": "^1.0.0",
|
|
73
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
74
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
74
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.55.0",
|
|
75
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.55.0",
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.55.0",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.55.0",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.55.0",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.55.0",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.55.0"
|
|
80
81
|
}
|
|
81
82
|
}
|