@holokai/neuron-sdk 0.1.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/README.md +628 -0
- package/dist/index.d.mts +1022 -0
- package/dist/index.d.ts +1022 -0
- package/dist/index.js +1124 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1108 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var zod = require('zod');
|
|
4
|
+
var eventemitter3 = require('eventemitter3');
|
|
5
|
+
var zodToJsonSchema = require('zod-to-json-schema');
|
|
6
|
+
var AjvDefault = require('ajv');
|
|
7
|
+
var addFormatsDefault = require('ajv-formats');
|
|
8
|
+
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
var AjvDefault__default = /*#__PURE__*/_interopDefault(AjvDefault);
|
|
12
|
+
var addFormatsDefault__default = /*#__PURE__*/_interopDefault(addFormatsDefault);
|
|
13
|
+
|
|
14
|
+
// ../neuron-protocol/src/capability.ts
|
|
15
|
+
var CapabilityTypeSchema = zod.z.string().min(1).regex(
|
|
16
|
+
/^[A-Za-z0-9_.\-]+(\/[A-Za-z0-9_.\-]+)+$/,
|
|
17
|
+
"capability type must be namespaced: {owner}/{package}[/{name}][.{variant}]"
|
|
18
|
+
);
|
|
19
|
+
var UserIdSchema = zod.z.string().min(1);
|
|
20
|
+
var CapabilityScopeSchema = zod.z.union([
|
|
21
|
+
zod.z.literal("any"),
|
|
22
|
+
zod.z.object({ onBehalfOf: UserIdSchema }).strict()
|
|
23
|
+
]);
|
|
24
|
+
var CapabilitySchema = zod.z.object({
|
|
25
|
+
type: CapabilityTypeSchema,
|
|
26
|
+
scope: CapabilityScopeSchema,
|
|
27
|
+
concurrency: zod.z.number().int().positive()
|
|
28
|
+
}).strict();
|
|
29
|
+
var JsonSchemaSchema = zod.z.unknown();
|
|
30
|
+
var LeaseIdSchema = zod.z.string().min(1);
|
|
31
|
+
var TaskPayloadSchema = zod.z.object({
|
|
32
|
+
taskId: zod.z.string().min(1),
|
|
33
|
+
workflowId: zod.z.string().min(1),
|
|
34
|
+
capability: CapabilityTypeSchema,
|
|
35
|
+
onBehalfOf: UserIdSchema.optional(),
|
|
36
|
+
input: zod.z.unknown(),
|
|
37
|
+
attempt: zod.z.number().int().nonnegative(),
|
|
38
|
+
metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
39
|
+
}).strict();
|
|
40
|
+
var LeasePayloadSchema = zod.z.object({
|
|
41
|
+
leaseId: LeaseIdSchema,
|
|
42
|
+
expiresAt: zod.z.string().datetime(),
|
|
43
|
+
task: TaskPayloadSchema,
|
|
44
|
+
inputSchema: JsonSchemaSchema,
|
|
45
|
+
outputSchema: JsonSchemaSchema
|
|
46
|
+
}).strict();
|
|
47
|
+
var NeuronIdSchema = zod.z.string().min(1);
|
|
48
|
+
var RegisterFrameSchema = zod.z.object({
|
|
49
|
+
type: zod.z.literal("register"),
|
|
50
|
+
neuronId: NeuronIdSchema,
|
|
51
|
+
// Free-form by design — the gateway negotiates compatibility against its
|
|
52
|
+
// own PROTOCOL_VERSION (semver), so a v1.1 server can still accept a v1.0
|
|
53
|
+
// neuron. Do not pin this to the exported constant.
|
|
54
|
+
protocolVersion: zod.z.string().min(1),
|
|
55
|
+
capabilities: zod.z.array(CapabilitySchema).min(1).refine(
|
|
56
|
+
(caps) => new Set(caps.map((c) => c.type)).size === caps.length,
|
|
57
|
+
"duplicate capability types are not allowed in a single register frame"
|
|
58
|
+
)
|
|
59
|
+
}).strict();
|
|
60
|
+
var UpdateCapabilityFrameSchema = zod.z.object({
|
|
61
|
+
type: zod.z.literal("update-capability"),
|
|
62
|
+
neuronId: NeuronIdSchema,
|
|
63
|
+
capability: CapabilitySchema
|
|
64
|
+
}).strict();
|
|
65
|
+
var HeartbeatInFlightEntrySchema = zod.z.object({
|
|
66
|
+
leaseId: LeaseIdSchema,
|
|
67
|
+
taskId: zod.z.string().min(1),
|
|
68
|
+
startedAt: zod.z.string().datetime()
|
|
69
|
+
}).strict();
|
|
70
|
+
var HeartbeatFrameSchema = zod.z.object({
|
|
71
|
+
type: zod.z.literal("heartbeat"),
|
|
72
|
+
neuronId: NeuronIdSchema,
|
|
73
|
+
inFlight: zod.z.array(HeartbeatInFlightEntrySchema),
|
|
74
|
+
sentAt: zod.z.string().datetime()
|
|
75
|
+
}).strict();
|
|
76
|
+
var AckFrameSchema = zod.z.object({
|
|
77
|
+
type: zod.z.literal("ack"),
|
|
78
|
+
leaseId: LeaseIdSchema,
|
|
79
|
+
output: zod.z.unknown(),
|
|
80
|
+
completedAt: zod.z.string().datetime()
|
|
81
|
+
}).strict();
|
|
82
|
+
var NackKindSchema = zod.z.enum([
|
|
83
|
+
"retryable",
|
|
84
|
+
"terminal",
|
|
85
|
+
"schema-mismatch",
|
|
86
|
+
"cancelled"
|
|
87
|
+
]);
|
|
88
|
+
var NackFrameSchema = zod.z.object({
|
|
89
|
+
type: zod.z.literal("nack"),
|
|
90
|
+
leaseId: LeaseIdSchema,
|
|
91
|
+
kind: NackKindSchema,
|
|
92
|
+
reason: zod.z.string().min(1),
|
|
93
|
+
code: zod.z.string().optional(),
|
|
94
|
+
details: zod.z.unknown().optional(),
|
|
95
|
+
failedAt: zod.z.string().datetime()
|
|
96
|
+
}).strict();
|
|
97
|
+
var ProgressFrameSchema = zod.z.object({
|
|
98
|
+
type: zod.z.literal("progress"),
|
|
99
|
+
leaseId: LeaseIdSchema,
|
|
100
|
+
percent: zod.z.number().min(0).max(100).optional(),
|
|
101
|
+
message: zod.z.string().optional(),
|
|
102
|
+
data: zod.z.unknown().optional(),
|
|
103
|
+
sentAt: zod.z.string().datetime()
|
|
104
|
+
}).strict();
|
|
105
|
+
zod.z.discriminatedUnion("type", [
|
|
106
|
+
RegisterFrameSchema,
|
|
107
|
+
UpdateCapabilityFrameSchema,
|
|
108
|
+
HeartbeatFrameSchema,
|
|
109
|
+
AckFrameSchema,
|
|
110
|
+
NackFrameSchema,
|
|
111
|
+
ProgressFrameSchema
|
|
112
|
+
]);
|
|
113
|
+
var RegisteredFrameSchema = zod.z.object({
|
|
114
|
+
type: zod.z.literal("registered"),
|
|
115
|
+
sessionId: zod.z.string().min(1),
|
|
116
|
+
// Effective capability bindings after auth + policy filters; may be a
|
|
117
|
+
// subset of what the neuron advertised.
|
|
118
|
+
capabilities: zod.z.array(CapabilitySchema),
|
|
119
|
+
acceptedAt: zod.z.string().datetime()
|
|
120
|
+
}).strict();
|
|
121
|
+
var LeaseFrameSchema = zod.z.object({
|
|
122
|
+
type: zod.z.literal("lease"),
|
|
123
|
+
lease: LeasePayloadSchema,
|
|
124
|
+
sentAt: zod.z.string().datetime()
|
|
125
|
+
}).strict();
|
|
126
|
+
var CancelFrameSchema = zod.z.object({
|
|
127
|
+
type: zod.z.literal("cancel"),
|
|
128
|
+
leaseId: LeaseIdSchema,
|
|
129
|
+
reason: zod.z.string().min(1),
|
|
130
|
+
cancelledAt: zod.z.string().datetime()
|
|
131
|
+
}).strict();
|
|
132
|
+
var CapabilityRevokedFrameSchema = zod.z.object({
|
|
133
|
+
type: zod.z.literal("capability-revoked"),
|
|
134
|
+
capability: CapabilityTypeSchema,
|
|
135
|
+
reason: zod.z.string().min(1),
|
|
136
|
+
revokedAt: zod.z.string().datetime()
|
|
137
|
+
}).strict();
|
|
138
|
+
var DisconnectFrameSchema = zod.z.object({
|
|
139
|
+
type: zod.z.literal("disconnect"),
|
|
140
|
+
reason: zod.z.string().min(1),
|
|
141
|
+
code: zod.z.string().optional(),
|
|
142
|
+
reconnectable: zod.z.boolean(),
|
|
143
|
+
sentAt: zod.z.string().datetime()
|
|
144
|
+
}).strict();
|
|
145
|
+
var ServerToNeuronFrameSchema = zod.z.discriminatedUnion("type", [
|
|
146
|
+
RegisteredFrameSchema,
|
|
147
|
+
LeaseFrameSchema,
|
|
148
|
+
CancelFrameSchema,
|
|
149
|
+
CapabilityRevokedFrameSchema,
|
|
150
|
+
DisconnectFrameSchema
|
|
151
|
+
]);
|
|
152
|
+
zod.z.discriminatedUnion("type", [
|
|
153
|
+
RegisterFrameSchema,
|
|
154
|
+
UpdateCapabilityFrameSchema,
|
|
155
|
+
HeartbeatFrameSchema,
|
|
156
|
+
AckFrameSchema,
|
|
157
|
+
NackFrameSchema,
|
|
158
|
+
ProgressFrameSchema,
|
|
159
|
+
RegisteredFrameSchema,
|
|
160
|
+
LeaseFrameSchema,
|
|
161
|
+
CancelFrameSchema,
|
|
162
|
+
CapabilityRevokedFrameSchema,
|
|
163
|
+
DisconnectFrameSchema
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
// ../neuron-protocol/src/transport.ts
|
|
167
|
+
var PROTOCOL_VERSION = "1.0.0";
|
|
168
|
+
var SSE_PATH = "/neuron/events";
|
|
169
|
+
var SSE_EVENT = {
|
|
170
|
+
registered: "registered",
|
|
171
|
+
lease: "lease",
|
|
172
|
+
cancel: "cancel",
|
|
173
|
+
capabilityRevoked: "capability-revoked",
|
|
174
|
+
disconnect: "disconnect"
|
|
175
|
+
};
|
|
176
|
+
var POST_PATH = {
|
|
177
|
+
register: "/neuron/register",
|
|
178
|
+
updateCapability: "/neuron/update-capability",
|
|
179
|
+
heartbeat: "/neuron/heartbeat",
|
|
180
|
+
ack: "/neuron/ack",
|
|
181
|
+
nack: "/neuron/nack",
|
|
182
|
+
progress: "/neuron/progress"
|
|
183
|
+
};
|
|
184
|
+
function defineCapability(init) {
|
|
185
|
+
return {
|
|
186
|
+
type: init.type,
|
|
187
|
+
description: init.description,
|
|
188
|
+
inputSchema: zodToJsonSchema.zodToJsonSchema(init.input, { $refStrategy: "none" }),
|
|
189
|
+
outputSchema: zodToJsonSchema.zodToJsonSchema(init.output, { $refStrategy: "none" }),
|
|
190
|
+
scope: init.scope,
|
|
191
|
+
concurrency: init.concurrency,
|
|
192
|
+
_zod: { input: init.input, output: init.output }
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function toPlanShape(def) {
|
|
196
|
+
return {
|
|
197
|
+
type: def.type,
|
|
198
|
+
description: def.description,
|
|
199
|
+
input: def.inputSchema,
|
|
200
|
+
output: def.outputSchema
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function toRegistrationCapability(def, opts) {
|
|
204
|
+
const scope = opts?.scope ?? def.scope;
|
|
205
|
+
const concurrency = opts?.concurrency ?? def.concurrency;
|
|
206
|
+
if (!scope) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`capability ${def.type}: scope is required for registration \u2014 set it on defineCapability(...) or at neuron.handle(def, { scope }, fn)`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (typeof concurrency !== "number") {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`capability ${def.type}: concurrency is required for registration \u2014 set it on defineCapability(...) or at neuron.handle(def, { concurrency }, fn)`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return { type: def.type, scope, concurrency };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/heartbeat.ts
|
|
220
|
+
var DEFAULT_INTERVAL_MS = 1e4;
|
|
221
|
+
var HeartbeatLoop = class {
|
|
222
|
+
constructor(opts) {
|
|
223
|
+
this.opts = opts;
|
|
224
|
+
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
225
|
+
}
|
|
226
|
+
timer = null;
|
|
227
|
+
running = false;
|
|
228
|
+
intervalMs;
|
|
229
|
+
start() {
|
|
230
|
+
if (this.timer) return;
|
|
231
|
+
this.timer = setInterval(() => {
|
|
232
|
+
void this.tick();
|
|
233
|
+
}, this.intervalMs);
|
|
234
|
+
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
235
|
+
}
|
|
236
|
+
stop() {
|
|
237
|
+
if (this.timer) {
|
|
238
|
+
clearInterval(this.timer);
|
|
239
|
+
this.timer = null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Public for tests so they don't have to wait for the timer. */
|
|
243
|
+
async tick() {
|
|
244
|
+
if (this.running) return;
|
|
245
|
+
if (!this.opts.transport.isConnected()) return;
|
|
246
|
+
this.running = true;
|
|
247
|
+
try {
|
|
248
|
+
await this.opts.transport.post(POST_PATH.heartbeat, {
|
|
249
|
+
type: "heartbeat",
|
|
250
|
+
neuronId: this.opts.neuronId,
|
|
251
|
+
inFlight: this.opts.inFlight(),
|
|
252
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.opts.logger?.warn({ err }, "heartbeat post failed; will retry next tick");
|
|
256
|
+
} finally {
|
|
257
|
+
this.running = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// src/lease-runner.ts
|
|
263
|
+
var TerminalFailure = class extends Error {
|
|
264
|
+
constructor(reason, code) {
|
|
265
|
+
super(reason);
|
|
266
|
+
this.reason = reason;
|
|
267
|
+
this.code = code;
|
|
268
|
+
this.name = "TerminalFailure";
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
function validateInput(zod, validator, jsonSchema, data) {
|
|
272
|
+
if (zod) {
|
|
273
|
+
const result = zod.safeParse(data);
|
|
274
|
+
return result.success ? { ok: true, value: result.data } : { ok: false, errors: result.error.issues };
|
|
275
|
+
}
|
|
276
|
+
const errors = validator.validate(
|
|
277
|
+
jsonSchema,
|
|
278
|
+
data
|
|
279
|
+
);
|
|
280
|
+
return errors === null ? { ok: true, value: data } : { ok: false, errors };
|
|
281
|
+
}
|
|
282
|
+
async function runLease(opts) {
|
|
283
|
+
const { lease, handler, zodInput, zodOutput, transport, validator, logger, signal } = opts;
|
|
284
|
+
const inputCheck = validateInput(zodInput, validator, lease.inputSchema, lease.task.input);
|
|
285
|
+
if (!inputCheck.ok) {
|
|
286
|
+
await sendNack(
|
|
287
|
+
transport,
|
|
288
|
+
lease.leaseId,
|
|
289
|
+
"schema-mismatch",
|
|
290
|
+
"lease input does not match inputSchema",
|
|
291
|
+
{ code: "INPUT_SCHEMA_MISMATCH", details: { errors: inputCheck.errors } },
|
|
292
|
+
logger
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const parsedInput = inputCheck.value;
|
|
297
|
+
const progressFn = makeProgressFn(transport, lease.leaseId, logger);
|
|
298
|
+
const ctx = {
|
|
299
|
+
task: lease.task,
|
|
300
|
+
leaseId: lease.leaseId,
|
|
301
|
+
signal,
|
|
302
|
+
log: scopedLogger(logger, lease),
|
|
303
|
+
progress: progressFn,
|
|
304
|
+
fail: (reason, opts2) => {
|
|
305
|
+
throw new TerminalFailure(reason, opts2?.code);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
let output;
|
|
309
|
+
try {
|
|
310
|
+
output = await handler(parsedInput, ctx);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (signal.aborted || err.name === "AbortError") {
|
|
313
|
+
await sendNack(
|
|
314
|
+
transport,
|
|
315
|
+
lease.leaseId,
|
|
316
|
+
"cancelled",
|
|
317
|
+
"handler aborted",
|
|
318
|
+
{ code: "CANCELLED" },
|
|
319
|
+
logger
|
|
320
|
+
);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (err instanceof TerminalFailure) {
|
|
324
|
+
await sendNack(
|
|
325
|
+
transport,
|
|
326
|
+
lease.leaseId,
|
|
327
|
+
"terminal",
|
|
328
|
+
err.reason,
|
|
329
|
+
{ code: err.code ?? "HANDLER_TERMINAL" },
|
|
330
|
+
logger
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
+
logger.error({ err, leaseId: lease.leaseId }, "handler threw; nacking retryable");
|
|
336
|
+
await sendNack(
|
|
337
|
+
transport,
|
|
338
|
+
lease.leaseId,
|
|
339
|
+
"retryable",
|
|
340
|
+
message,
|
|
341
|
+
{ code: "HANDLER_THREW" },
|
|
342
|
+
logger
|
|
343
|
+
);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const outputCheck = validateInput(zodOutput, validator, lease.outputSchema, output);
|
|
347
|
+
if (!outputCheck.ok) {
|
|
348
|
+
await sendNack(
|
|
349
|
+
transport,
|
|
350
|
+
lease.leaseId,
|
|
351
|
+
"schema-mismatch",
|
|
352
|
+
"handler output does not match outputSchema",
|
|
353
|
+
{ code: "OUTPUT_SCHEMA_MISMATCH", details: { errors: outputCheck.errors } },
|
|
354
|
+
logger
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const parsedOutput = outputCheck.value;
|
|
359
|
+
await transport.post(POST_PATH.ack, {
|
|
360
|
+
type: "ack",
|
|
361
|
+
leaseId: lease.leaseId,
|
|
362
|
+
output: parsedOutput,
|
|
363
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
364
|
+
}).catch((err) => {
|
|
365
|
+
logger.warn(
|
|
366
|
+
{ err, leaseId: lease.leaseId },
|
|
367
|
+
"ack POST rejected by gateway; lease will be reclaimed by reaper"
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async function sendNack(transport, leaseId, kind, reason, extras = {}, logger) {
|
|
372
|
+
await transport.post(POST_PATH.nack, {
|
|
373
|
+
type: "nack",
|
|
374
|
+
leaseId,
|
|
375
|
+
kind,
|
|
376
|
+
reason,
|
|
377
|
+
code: extras.code,
|
|
378
|
+
details: extras.details,
|
|
379
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
380
|
+
}).catch((err) => {
|
|
381
|
+
logger?.warn(
|
|
382
|
+
{ err, leaseId, kind },
|
|
383
|
+
"nack POST rejected by gateway; lease will be reclaimed by reaper"
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function makeProgressFn(transport, leaseId, logger) {
|
|
388
|
+
return (update) => {
|
|
389
|
+
void transport.post(POST_PATH.progress, {
|
|
390
|
+
type: "progress",
|
|
391
|
+
leaseId,
|
|
392
|
+
percent: update.percent,
|
|
393
|
+
message: update.message,
|
|
394
|
+
data: update.data,
|
|
395
|
+
sentAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
396
|
+
}).catch((err) => {
|
|
397
|
+
logger.warn({ err, leaseId }, "progress POST failed; continuing");
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function scopedLogger(logger, lease) {
|
|
402
|
+
const scope = { leaseId: lease.leaseId, taskId: lease.task.taskId };
|
|
403
|
+
return {
|
|
404
|
+
debug: (meta, msg) => logger.debug({ ...scope, ...meta }, msg),
|
|
405
|
+
info: (meta, msg) => logger.info({ ...scope, ...meta }, msg),
|
|
406
|
+
warn: (meta, msg) => logger.warn({ ...scope, ...meta }, msg),
|
|
407
|
+
error: (meta, msg) => logger.error({ ...scope, ...meta }, msg)
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/logger.ts
|
|
412
|
+
var noopLogger = {
|
|
413
|
+
debug() {
|
|
414
|
+
},
|
|
415
|
+
info() {
|
|
416
|
+
},
|
|
417
|
+
warn() {
|
|
418
|
+
},
|
|
419
|
+
error() {
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
var Ajv = AjvDefault__default.default.default ?? AjvDefault__default.default;
|
|
423
|
+
var addFormats = addFormatsDefault__default.default.default ?? addFormatsDefault__default.default;
|
|
424
|
+
var SchemaValidator = class {
|
|
425
|
+
ajv;
|
|
426
|
+
cache = /* @__PURE__ */ new WeakMap();
|
|
427
|
+
constructor() {
|
|
428
|
+
this.ajv = new Ajv({ allErrors: true, strict: false });
|
|
429
|
+
addFormats(this.ajv);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Returns null if the value matches the schema (or no schema given);
|
|
433
|
+
* otherwise an array of structured errors.
|
|
434
|
+
*/
|
|
435
|
+
validate(schema, data) {
|
|
436
|
+
if (!schema || typeof schema !== "object") return null;
|
|
437
|
+
let validator = this.cache.get(schema);
|
|
438
|
+
if (!validator) {
|
|
439
|
+
try {
|
|
440
|
+
validator = this.ajv.compile(schema);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return [
|
|
443
|
+
{
|
|
444
|
+
instancePath: "",
|
|
445
|
+
schemaPath: "",
|
|
446
|
+
keyword: "schema",
|
|
447
|
+
params: {},
|
|
448
|
+
message: `schema failed to compile: ${err instanceof Error ? err.message : String(err)}`
|
|
449
|
+
}
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
this.cache.set(schema, validator);
|
|
453
|
+
}
|
|
454
|
+
const v = validator;
|
|
455
|
+
const ok = v(data);
|
|
456
|
+
return ok ? null : v.errors ?? [];
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// src/sse-parser.ts
|
|
461
|
+
async function* parseSSEStream(stream, signal) {
|
|
462
|
+
const reader = stream.getReader();
|
|
463
|
+
const decoder = new TextDecoder();
|
|
464
|
+
let buffer = "";
|
|
465
|
+
const onAbort = () => {
|
|
466
|
+
void reader.cancel().catch(() => void 0);
|
|
467
|
+
};
|
|
468
|
+
signal?.addEventListener("abort", onAbort);
|
|
469
|
+
try {
|
|
470
|
+
while (true) {
|
|
471
|
+
if (signal?.aborted) return;
|
|
472
|
+
const { value, done } = await reader.read();
|
|
473
|
+
if (done) break;
|
|
474
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
475
|
+
let blockEnd;
|
|
476
|
+
while ((blockEnd = buffer.indexOf("\n\n")) !== -1) {
|
|
477
|
+
const block = buffer.slice(0, blockEnd);
|
|
478
|
+
buffer = buffer.slice(blockEnd + 2);
|
|
479
|
+
const event = parseBlock(block);
|
|
480
|
+
if (event) yield event;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
signal?.removeEventListener("abort", onAbort);
|
|
485
|
+
try {
|
|
486
|
+
reader.releaseLock();
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function parseBlock(block) {
|
|
492
|
+
if (!block.trim()) return null;
|
|
493
|
+
let event = "message";
|
|
494
|
+
const dataLines = [];
|
|
495
|
+
for (const rawLine of block.split("\n")) {
|
|
496
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
497
|
+
if (line.startsWith(":")) continue;
|
|
498
|
+
if (line.startsWith("event:")) {
|
|
499
|
+
event = line.slice("event:".length).trim();
|
|
500
|
+
} else if (line.startsWith("data:")) {
|
|
501
|
+
dataLines.push(line.slice("data:".length).replace(/^ /, ""));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (dataLines.length === 0) return null;
|
|
505
|
+
return { event, data: dataLines.join("\n") };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/transport.ts
|
|
509
|
+
var Transport = class {
|
|
510
|
+
opts;
|
|
511
|
+
callbacks;
|
|
512
|
+
abortController = null;
|
|
513
|
+
stopped = false;
|
|
514
|
+
connected = false;
|
|
515
|
+
reconnectAttempts = 0;
|
|
516
|
+
reconnectTimer = null;
|
|
517
|
+
constructor(opts) {
|
|
518
|
+
this.opts = {
|
|
519
|
+
gatewayUrl: opts.gatewayUrl.replace(/\/+$/, ""),
|
|
520
|
+
neuronId: opts.neuronId,
|
|
521
|
+
auth: opts.auth,
|
|
522
|
+
initialReconnectDelayMs: opts.initialReconnectDelayMs ?? 500,
|
|
523
|
+
maxReconnectDelayMs: opts.maxReconnectDelayMs ?? 3e4,
|
|
524
|
+
logger: opts.logger ?? noopLogger,
|
|
525
|
+
fetch: opts.fetch ?? globalThis.fetch.bind(globalThis)
|
|
526
|
+
};
|
|
527
|
+
this.callbacks = {
|
|
528
|
+
onOpen: opts.onOpen,
|
|
529
|
+
onClose: opts.onClose,
|
|
530
|
+
onFrame: opts.onFrame,
|
|
531
|
+
onError: opts.onError,
|
|
532
|
+
onReconnecting: opts.onReconnecting
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
isConnected() {
|
|
536
|
+
return this.connected;
|
|
537
|
+
}
|
|
538
|
+
/** Open the SSE stream. Resolves after the connection is established. */
|
|
539
|
+
async start() {
|
|
540
|
+
this.stopped = false;
|
|
541
|
+
await this.openStream();
|
|
542
|
+
}
|
|
543
|
+
/** Tear down the SSE stream and prevent further reconnect attempts. */
|
|
544
|
+
async stop() {
|
|
545
|
+
this.stopped = true;
|
|
546
|
+
if (this.reconnectTimer) {
|
|
547
|
+
clearTimeout(this.reconnectTimer);
|
|
548
|
+
this.reconnectTimer = null;
|
|
549
|
+
}
|
|
550
|
+
if (this.abortController) {
|
|
551
|
+
this.abortController.abort();
|
|
552
|
+
this.abortController = null;
|
|
553
|
+
}
|
|
554
|
+
this.connected = false;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* POST a client frame to the gateway. Refreshes the token via the auth
|
|
558
|
+
* callback and retries once on 401 — that's the contract: callers
|
|
559
|
+
* implement refresh inside their callback.
|
|
560
|
+
*/
|
|
561
|
+
async post(path, frame) {
|
|
562
|
+
return this.postRaw(path, frame, true);
|
|
563
|
+
}
|
|
564
|
+
async postRaw(path, frame, allowRetry) {
|
|
565
|
+
const token = await this.opts.auth();
|
|
566
|
+
const url = `${this.opts.gatewayUrl}${path}`;
|
|
567
|
+
const res = await this.opts.fetch(url, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: {
|
|
570
|
+
"Content-Type": "application/json",
|
|
571
|
+
Authorization: `Bearer ${token}`
|
|
572
|
+
},
|
|
573
|
+
body: JSON.stringify(frame)
|
|
574
|
+
});
|
|
575
|
+
if (res.status === 401 && allowRetry) {
|
|
576
|
+
this.opts.logger.warn({ path }, "gateway returned 401; retrying after auth refresh");
|
|
577
|
+
return this.postRaw(path, frame, false);
|
|
578
|
+
}
|
|
579
|
+
if (!res.ok) {
|
|
580
|
+
const text = await res.text().catch(() => "");
|
|
581
|
+
throw new TransportError(
|
|
582
|
+
`${path} failed: ${res.status} ${res.statusText}${text ? ` \u2014 ${text}` : ""}`,
|
|
583
|
+
res.status
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
return res.headers.get("content-type")?.includes("application/json") ? await res.json() : await res.text();
|
|
587
|
+
}
|
|
588
|
+
async openStream(allowAuthRetry = true) {
|
|
589
|
+
if (this.stopped) return;
|
|
590
|
+
const url = `${this.opts.gatewayUrl}${SSE_PATH}?neuronId=${encodeURIComponent(
|
|
591
|
+
this.opts.neuronId
|
|
592
|
+
)}`;
|
|
593
|
+
this.abortController = new AbortController();
|
|
594
|
+
const signal = this.abortController.signal;
|
|
595
|
+
let res;
|
|
596
|
+
try {
|
|
597
|
+
const token = await this.opts.auth();
|
|
598
|
+
res = await this.opts.fetch(url, {
|
|
599
|
+
method: "GET",
|
|
600
|
+
headers: {
|
|
601
|
+
Accept: "text/event-stream",
|
|
602
|
+
Authorization: `Bearer ${token}`
|
|
603
|
+
},
|
|
604
|
+
signal
|
|
605
|
+
});
|
|
606
|
+
} catch (err) {
|
|
607
|
+
this.scheduleReconnect(`fetch error: ${err.message}`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (res.status === 401 && allowAuthRetry) {
|
|
611
|
+
this.opts.logger.warn(
|
|
612
|
+
{ reason: "SSE 401" },
|
|
613
|
+
"SSE open returned 401; retrying after auth refresh"
|
|
614
|
+
);
|
|
615
|
+
this.abortController = null;
|
|
616
|
+
void res.body?.cancel().catch(() => void 0);
|
|
617
|
+
return this.openStream(false);
|
|
618
|
+
}
|
|
619
|
+
if (!res.ok || !res.body) {
|
|
620
|
+
const reason = `SSE open failed: ${res.status} ${res.statusText}`;
|
|
621
|
+
this.scheduleReconnect(reason);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
this.connected = true;
|
|
625
|
+
this.reconnectAttempts = 0;
|
|
626
|
+
this.callbacks.onOpen?.();
|
|
627
|
+
void this.consumeStream(res.body, signal);
|
|
628
|
+
}
|
|
629
|
+
async consumeStream(body, signal) {
|
|
630
|
+
try {
|
|
631
|
+
for await (const event of parseSSEStream(body, signal)) {
|
|
632
|
+
let parsed;
|
|
633
|
+
try {
|
|
634
|
+
const data = JSON.parse(event.data);
|
|
635
|
+
parsed = ServerToNeuronFrameSchema.parse(data);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
this.opts.logger.warn(
|
|
638
|
+
{ err, event: event.event, data: event.data.slice(0, 200) },
|
|
639
|
+
"dropped malformed SSE frame"
|
|
640
|
+
);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
this.callbacks.onFrame?.(parsed);
|
|
644
|
+
}
|
|
645
|
+
this.handleStreamEnd("clean close");
|
|
646
|
+
} catch (err) {
|
|
647
|
+
this.handleStreamEnd(`read error: ${err.message}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
handleStreamEnd(reason) {
|
|
651
|
+
if (!this.connected) return;
|
|
652
|
+
this.connected = false;
|
|
653
|
+
this.abortController = null;
|
|
654
|
+
this.callbacks.onClose?.(reason);
|
|
655
|
+
if (!this.stopped) {
|
|
656
|
+
this.scheduleReconnect(reason);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
scheduleReconnect(reason) {
|
|
660
|
+
if (this.stopped) return;
|
|
661
|
+
const attempt = this.reconnectAttempts++;
|
|
662
|
+
const delay = Math.min(
|
|
663
|
+
this.opts.maxReconnectDelayMs,
|
|
664
|
+
this.opts.initialReconnectDelayMs * Math.pow(2, attempt)
|
|
665
|
+
);
|
|
666
|
+
this.opts.logger.warn(
|
|
667
|
+
{ reason, attempt: attempt + 1, delayMs: delay },
|
|
668
|
+
"transport reconnecting"
|
|
669
|
+
);
|
|
670
|
+
this.callbacks.onReconnecting?.({ attempt: attempt + 1, delayMs: delay, reason });
|
|
671
|
+
this.reconnectTimer = setTimeout(() => {
|
|
672
|
+
this.reconnectTimer = null;
|
|
673
|
+
void this.openStream();
|
|
674
|
+
}, delay);
|
|
675
|
+
if (typeof this.reconnectTimer === "object" && this.reconnectTimer !== null && "unref" in this.reconnectTimer && typeof this.reconnectTimer.unref === "function") {
|
|
676
|
+
this.reconnectTimer.unref();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
var TransportError = class extends Error {
|
|
681
|
+
constructor(message, statusCode) {
|
|
682
|
+
super(message);
|
|
683
|
+
this.statusCode = statusCode;
|
|
684
|
+
this.name = "TransportError";
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// src/worker-pool.ts
|
|
689
|
+
var WorkerPool = class {
|
|
690
|
+
constructor(maxConcurrency) {
|
|
691
|
+
this.maxConcurrency = maxConcurrency;
|
|
692
|
+
if (maxConcurrency <= 0) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`WorkerPool requires positive concurrency, got ${maxConcurrency}`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
inFlight = 0;
|
|
699
|
+
queue = [];
|
|
700
|
+
draining = false;
|
|
701
|
+
drained = null;
|
|
702
|
+
/** Currently-running task count. */
|
|
703
|
+
size() {
|
|
704
|
+
return this.inFlight;
|
|
705
|
+
}
|
|
706
|
+
/** Tasks waiting for a slot. */
|
|
707
|
+
pending() {
|
|
708
|
+
return this.queue.length;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Run `fn` once a concurrency slot is available. Resolves with the
|
|
712
|
+
* function's return value (or rejects with its error). Throws
|
|
713
|
+
* ShutdownError if the pool is already draining.
|
|
714
|
+
*/
|
|
715
|
+
async run(fn) {
|
|
716
|
+
if (this.draining) {
|
|
717
|
+
throw new ShutdownError("worker pool is draining");
|
|
718
|
+
}
|
|
719
|
+
if (this.inFlight >= this.maxConcurrency) {
|
|
720
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
721
|
+
}
|
|
722
|
+
this.inFlight++;
|
|
723
|
+
try {
|
|
724
|
+
return await fn();
|
|
725
|
+
} finally {
|
|
726
|
+
this.inFlight--;
|
|
727
|
+
const next = this.queue.shift();
|
|
728
|
+
if (next) {
|
|
729
|
+
next();
|
|
730
|
+
} else if (this.draining && this.inFlight === 0 && this.drained) {
|
|
731
|
+
this.drained.resolve();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Update the max concurrency. Lowering doesn't kill in-flight tasks —
|
|
737
|
+
* they finish naturally — but it does prevent new ones from starting
|
|
738
|
+
* until the in-flight count drops below the new limit.
|
|
739
|
+
*/
|
|
740
|
+
setConcurrency(newMax) {
|
|
741
|
+
if (newMax <= 0) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
`WorkerPool concurrency must be positive, got ${newMax}`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
const oldMax = this.maxConcurrency;
|
|
747
|
+
this.maxConcurrency = newMax;
|
|
748
|
+
if (newMax > oldMax) {
|
|
749
|
+
const headroom = newMax - this.inFlight;
|
|
750
|
+
for (let i = 0; i < headroom && this.queue.length > 0; i++) {
|
|
751
|
+
const next = this.queue.shift();
|
|
752
|
+
next?.();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Stop accepting new submissions. Returns a promise that resolves once
|
|
758
|
+
* every in-flight AND queued task has finished — queued waiters from
|
|
759
|
+
* before drain are released as in-flight slots free, run normally, and
|
|
760
|
+
* are awaited as part of drain. Only fresh `run()` calls received after
|
|
761
|
+
* `drain()` reject with ShutdownError. Callers that need a hard stop
|
|
762
|
+
* should race this promise against a timeout and abort handlers.
|
|
763
|
+
*/
|
|
764
|
+
drain() {
|
|
765
|
+
if (!this.draining) {
|
|
766
|
+
this.draining = true;
|
|
767
|
+
let resolveFn;
|
|
768
|
+
const promise = new Promise((resolve) => {
|
|
769
|
+
resolveFn = resolve;
|
|
770
|
+
});
|
|
771
|
+
this.drained = { resolve: resolveFn, promise };
|
|
772
|
+
if (this.inFlight === 0) {
|
|
773
|
+
resolveFn();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return this.drained.promise;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
var ShutdownError = class extends Error {
|
|
780
|
+
constructor(message) {
|
|
781
|
+
super(message);
|
|
782
|
+
this.name = "ShutdownError";
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
// src/neuron.ts
|
|
787
|
+
var Neuron = class extends eventemitter3.EventEmitter {
|
|
788
|
+
transport;
|
|
789
|
+
handlers = /* @__PURE__ */ new Map();
|
|
790
|
+
inFlightLeases = /* @__PURE__ */ new Map();
|
|
791
|
+
/**
|
|
792
|
+
* Leases admitted by `dispatchLease` but still waiting for a worker-pool
|
|
793
|
+
* slot. Tracked separately from `inFlightLeases` so the heartbeat snapshot
|
|
794
|
+
* doesn't lie — only running leases extend the gateway's lease TTL.
|
|
795
|
+
*/
|
|
796
|
+
queuedLeases = /* @__PURE__ */ new Map();
|
|
797
|
+
heartbeat;
|
|
798
|
+
validator;
|
|
799
|
+
logger;
|
|
800
|
+
opts;
|
|
801
|
+
started = false;
|
|
802
|
+
stopping = false;
|
|
803
|
+
hasOpenedOnce = false;
|
|
804
|
+
constructor(opts) {
|
|
805
|
+
super();
|
|
806
|
+
this.opts = opts;
|
|
807
|
+
this.logger = opts.logger ?? noopLogger;
|
|
808
|
+
this.validator = new SchemaValidator();
|
|
809
|
+
this.transport = new Transport({
|
|
810
|
+
gatewayUrl: opts.gatewayUrl,
|
|
811
|
+
neuronId: opts.neuronId,
|
|
812
|
+
auth: opts.auth,
|
|
813
|
+
logger: this.logger,
|
|
814
|
+
initialReconnectDelayMs: opts.initialReconnectDelayMs,
|
|
815
|
+
maxReconnectDelayMs: opts.maxReconnectDelayMs,
|
|
816
|
+
fetch: opts.fetch,
|
|
817
|
+
// Internal callbacks — Transport has a single subscriber (this
|
|
818
|
+
// Neuron) so a callbacks-on-options surface is simpler than an
|
|
819
|
+
// event emitter. The Neuron is the one that re-publishes lifecycle
|
|
820
|
+
// signals to its own consumers via the public emitter.
|
|
821
|
+
onOpen: () => {
|
|
822
|
+
this.emit("connection:open");
|
|
823
|
+
void this.handleConnectionOpen();
|
|
824
|
+
},
|
|
825
|
+
onClose: (reason) => {
|
|
826
|
+
this.handleConnectionClose(reason);
|
|
827
|
+
this.emit("connection:close", reason);
|
|
828
|
+
},
|
|
829
|
+
onFrame: (frame) => this.handleFrame(frame),
|
|
830
|
+
onError: (err) => {
|
|
831
|
+
this.logger.error({ err }, "transport error");
|
|
832
|
+
this.emit("error", err);
|
|
833
|
+
},
|
|
834
|
+
onReconnecting: (info) => this.emit("connection:reconnecting", info)
|
|
835
|
+
});
|
|
836
|
+
this.heartbeat = new HeartbeatLoop({
|
|
837
|
+
transport: this.transport,
|
|
838
|
+
neuronId: opts.neuronId,
|
|
839
|
+
inFlight: () => this.snapshotInFlight(),
|
|
840
|
+
intervalMs: opts.heartbeatIntervalMs,
|
|
841
|
+
logger: this.logger
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
handle(defOrReg, optsOrHandler, maybeHandler) {
|
|
845
|
+
if (this.started) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
"Neuron.handle() must be called before start(). Use updateCapability(...) for runtime changes."
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
const isCapDef = "_zod" in defOrReg;
|
|
851
|
+
let capability;
|
|
852
|
+
let inputSchema;
|
|
853
|
+
let outputSchema;
|
|
854
|
+
let zodInput;
|
|
855
|
+
let zodOutput;
|
|
856
|
+
let handler;
|
|
857
|
+
if (isCapDef) {
|
|
858
|
+
const def = defOrReg;
|
|
859
|
+
let opts;
|
|
860
|
+
if (typeof optsOrHandler === "function") {
|
|
861
|
+
handler = optsOrHandler;
|
|
862
|
+
} else {
|
|
863
|
+
opts = optsOrHandler;
|
|
864
|
+
if (!maybeHandler) {
|
|
865
|
+
throw new Error("Neuron.handle(def, opts, fn): handler is required");
|
|
866
|
+
}
|
|
867
|
+
handler = maybeHandler;
|
|
868
|
+
}
|
|
869
|
+
capability = toRegistrationCapability(def, opts);
|
|
870
|
+
inputSchema = def.inputSchema;
|
|
871
|
+
outputSchema = def.outputSchema;
|
|
872
|
+
zodInput = def._zod.input;
|
|
873
|
+
zodOutput = def._zod.output;
|
|
874
|
+
} else {
|
|
875
|
+
const reg = defOrReg;
|
|
876
|
+
if (typeof optsOrHandler !== "function") {
|
|
877
|
+
throw new Error(
|
|
878
|
+
"Neuron.handle(registration, fn): expected handler function as second argument"
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
handler = optsOrHandler;
|
|
882
|
+
capability = { type: reg.type, scope: reg.scope, concurrency: reg.concurrency };
|
|
883
|
+
inputSchema = reg.inputSchema;
|
|
884
|
+
outputSchema = reg.outputSchema;
|
|
885
|
+
}
|
|
886
|
+
this.handlers.set(capability.type, {
|
|
887
|
+
capability,
|
|
888
|
+
inputSchema,
|
|
889
|
+
outputSchema,
|
|
890
|
+
zodInput,
|
|
891
|
+
zodOutput,
|
|
892
|
+
handler,
|
|
893
|
+
pool: new WorkerPool(capability.concurrency)
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Adjust an already-registered capability's concurrency at runtime and
|
|
898
|
+
* notify the gateway via an `update-capability` frame so prefetch can be
|
|
899
|
+
* tuned. Schema and handler are not replaced — re-register before start
|
|
900
|
+
* if those need to change.
|
|
901
|
+
*/
|
|
902
|
+
async updateCapability(reg) {
|
|
903
|
+
const entry = this.handlers.get(reg.type);
|
|
904
|
+
if (!entry) {
|
|
905
|
+
throw new Error(
|
|
906
|
+
`updateCapability: no handler registered for ${reg.type}`
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
entry.capability = { ...entry.capability, concurrency: reg.concurrency };
|
|
910
|
+
entry.pool.setConcurrency(reg.concurrency);
|
|
911
|
+
if (this.started && this.transport.isConnected()) {
|
|
912
|
+
await this.transport.post(POST_PATH.updateCapability, {
|
|
913
|
+
type: "update-capability",
|
|
914
|
+
neuronId: this.opts.neuronId,
|
|
915
|
+
capability: entry.capability
|
|
916
|
+
}).catch((err) => {
|
|
917
|
+
this.logger.warn(
|
|
918
|
+
{ err, capability: reg.type },
|
|
919
|
+
"update-capability POST failed; gateway will pick up new value on next reconnect"
|
|
920
|
+
);
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Open the SSE stream, post register with all advertised capabilities,
|
|
926
|
+
* and start the heartbeat loop. Resolves once the gateway accepts the
|
|
927
|
+
* register frame.
|
|
928
|
+
*/
|
|
929
|
+
async start() {
|
|
930
|
+
if (this.started) return;
|
|
931
|
+
if (this.handlers.size === 0) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
"Neuron.start() requires at least one handler \u2014 call Neuron.handle(...) first."
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
this.started = true;
|
|
937
|
+
await this.transport.start();
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Refuse new leases, drain in-flight handlers up to `timeoutMs`, then
|
|
941
|
+
* tear down the connection. Pending leases (queued but not started)
|
|
942
|
+
* are abandoned — the gateway's lease TTL will reclaim them.
|
|
943
|
+
*/
|
|
944
|
+
async stop(opts = {}) {
|
|
945
|
+
if (this.stopping) return;
|
|
946
|
+
this.stopping = true;
|
|
947
|
+
const drain = opts.drain ?? true;
|
|
948
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
949
|
+
this.heartbeat.stop();
|
|
950
|
+
if (drain) {
|
|
951
|
+
const drainPromises = Array.from(this.handlers.values()).map(
|
|
952
|
+
(h) => h.pool.drain()
|
|
953
|
+
);
|
|
954
|
+
const allDrained = Promise.all(drainPromises);
|
|
955
|
+
const timeout = new Promise((resolve) => {
|
|
956
|
+
const t = setTimeout(() => {
|
|
957
|
+
this.logger.warn({ timeoutMs }, "drain timeout exceeded; aborting in-flight leases");
|
|
958
|
+
for (const inFlight of this.inFlightLeases.values()) {
|
|
959
|
+
inFlight.abort.abort();
|
|
960
|
+
}
|
|
961
|
+
for (const queued of this.queuedLeases.values()) {
|
|
962
|
+
queued.abort.abort();
|
|
963
|
+
}
|
|
964
|
+
resolve();
|
|
965
|
+
}, timeoutMs);
|
|
966
|
+
if (typeof t.unref === "function") t.unref();
|
|
967
|
+
});
|
|
968
|
+
await Promise.race([allDrained, timeout]);
|
|
969
|
+
} else {
|
|
970
|
+
for (const inFlight of this.inFlightLeases.values()) {
|
|
971
|
+
inFlight.abort.abort();
|
|
972
|
+
}
|
|
973
|
+
for (const queued of this.queuedLeases.values()) {
|
|
974
|
+
queued.abort.abort();
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
await this.transport.stop();
|
|
978
|
+
}
|
|
979
|
+
// ----- internals ----------------------------------------------------
|
|
980
|
+
snapshotInFlight() {
|
|
981
|
+
return Array.from(this.inFlightLeases.entries()).map(([leaseId, info]) => ({
|
|
982
|
+
leaseId,
|
|
983
|
+
taskId: info.taskId,
|
|
984
|
+
startedAt: info.startedAt
|
|
985
|
+
}));
|
|
986
|
+
}
|
|
987
|
+
async handleConnectionOpen() {
|
|
988
|
+
try {
|
|
989
|
+
await this.postRegister();
|
|
990
|
+
this.hasOpenedOnce = true;
|
|
991
|
+
this.heartbeat.start();
|
|
992
|
+
} catch (err) {
|
|
993
|
+
this.logger.error({ err }, "register POST failed; transport will retry on reconnect");
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
handleConnectionClose(reason) {
|
|
997
|
+
this.logger.warn({ reason }, "transport disconnected");
|
|
998
|
+
this.heartbeat.stop();
|
|
999
|
+
}
|
|
1000
|
+
async postRegister() {
|
|
1001
|
+
await this.transport.post(POST_PATH.register, {
|
|
1002
|
+
type: "register",
|
|
1003
|
+
neuronId: this.opts.neuronId,
|
|
1004
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1005
|
+
capabilities: Array.from(this.handlers.values()).map((h) => h.capability)
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
handleFrame(frame) {
|
|
1009
|
+
this.emit("frame", frame);
|
|
1010
|
+
switch (frame.type) {
|
|
1011
|
+
case "registered":
|
|
1012
|
+
this.logger.info(
|
|
1013
|
+
{ sessionId: frame.sessionId, capabilities: frame.capabilities.length },
|
|
1014
|
+
"gateway accepted registration"
|
|
1015
|
+
);
|
|
1016
|
+
this.emit("connection:registered", {
|
|
1017
|
+
sessionId: frame.sessionId,
|
|
1018
|
+
capabilities: frame.capabilities.length
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
case "lease":
|
|
1022
|
+
void this.dispatchLease(frame.lease);
|
|
1023
|
+
return;
|
|
1024
|
+
case "cancel":
|
|
1025
|
+
this.handleCancel(frame.leaseId, frame.reason);
|
|
1026
|
+
return;
|
|
1027
|
+
case "capability-revoked":
|
|
1028
|
+
this.logger.warn(
|
|
1029
|
+
{ capability: frame.capability, reason: frame.reason },
|
|
1030
|
+
"capability revoked by gateway"
|
|
1031
|
+
);
|
|
1032
|
+
return;
|
|
1033
|
+
case "disconnect":
|
|
1034
|
+
this.logger.info({ reason: frame.reason, code: frame.code }, "gateway disconnect");
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
async dispatchLease(lease) {
|
|
1039
|
+
const handler = this.handlers.get(lease.task.capability);
|
|
1040
|
+
if (!handler) {
|
|
1041
|
+
this.logger.warn(
|
|
1042
|
+
{ capability: lease.task.capability, leaseId: lease.leaseId },
|
|
1043
|
+
"no handler registered for lease capability; nacking retryable"
|
|
1044
|
+
);
|
|
1045
|
+
await this.transport.post(POST_PATH.nack, {
|
|
1046
|
+
type: "nack",
|
|
1047
|
+
leaseId: lease.leaseId,
|
|
1048
|
+
kind: "retryable",
|
|
1049
|
+
reason: "no handler registered for capability",
|
|
1050
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1051
|
+
}).catch(() => void 0);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const abort = new AbortController();
|
|
1055
|
+
this.queuedLeases.set(lease.leaseId, { abort });
|
|
1056
|
+
try {
|
|
1057
|
+
await handler.pool.run(async () => {
|
|
1058
|
+
this.queuedLeases.delete(lease.leaseId);
|
|
1059
|
+
this.inFlightLeases.set(lease.leaseId, {
|
|
1060
|
+
taskId: lease.task.taskId,
|
|
1061
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1062
|
+
abort
|
|
1063
|
+
});
|
|
1064
|
+
try {
|
|
1065
|
+
await runLease({
|
|
1066
|
+
lease,
|
|
1067
|
+
handler: handler.handler,
|
|
1068
|
+
zodInput: handler.zodInput,
|
|
1069
|
+
zodOutput: handler.zodOutput,
|
|
1070
|
+
transport: this.transport,
|
|
1071
|
+
validator: this.validator,
|
|
1072
|
+
logger: this.logger,
|
|
1073
|
+
signal: abort.signal
|
|
1074
|
+
});
|
|
1075
|
+
} finally {
|
|
1076
|
+
this.inFlightLeases.delete(lease.leaseId);
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
this.queuedLeases.delete(lease.leaseId);
|
|
1081
|
+
if (err instanceof ShutdownError) {
|
|
1082
|
+
this.logger.info(
|
|
1083
|
+
{ leaseId: lease.leaseId },
|
|
1084
|
+
"lease refused during shutdown; nacking retryable"
|
|
1085
|
+
);
|
|
1086
|
+
await this.transport.post(POST_PATH.nack, {
|
|
1087
|
+
type: "nack",
|
|
1088
|
+
leaseId: lease.leaseId,
|
|
1089
|
+
kind: "retryable",
|
|
1090
|
+
reason: "neuron shutting down",
|
|
1091
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1092
|
+
}).catch(() => void 0);
|
|
1093
|
+
} else {
|
|
1094
|
+
this.logger.error({ err, leaseId: lease.leaseId }, "unhandled lease runner error");
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
handleCancel(leaseId, reason) {
|
|
1099
|
+
const tracked = this.inFlightLeases.get(leaseId) ?? this.queuedLeases.get(leaseId);
|
|
1100
|
+
if (!tracked) {
|
|
1101
|
+
this.logger.warn(
|
|
1102
|
+
{ leaseId, reason },
|
|
1103
|
+
"cancel for unknown lease; ignoring (already completed?)"
|
|
1104
|
+
);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
this.logger.info({ leaseId, reason }, "cancelling lease");
|
|
1108
|
+
tracked.abort.abort();
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
var SSE_EVENT_NAMES = SSE_EVENT;
|
|
1112
|
+
|
|
1113
|
+
exports.Neuron = Neuron;
|
|
1114
|
+
exports.SSE_EVENT_NAMES = SSE_EVENT_NAMES;
|
|
1115
|
+
exports.SchemaValidator = SchemaValidator;
|
|
1116
|
+
exports.ShutdownError = ShutdownError;
|
|
1117
|
+
exports.Transport = Transport;
|
|
1118
|
+
exports.TransportError = TransportError;
|
|
1119
|
+
exports.WorkerPool = WorkerPool;
|
|
1120
|
+
exports.defineCapability = defineCapability;
|
|
1121
|
+
exports.toPlanShape = toPlanShape;
|
|
1122
|
+
exports.toRegistrationCapability = toRegistrationCapability;
|
|
1123
|
+
//# sourceMappingURL=index.js.map
|
|
1124
|
+
//# sourceMappingURL=index.js.map
|