@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/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