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