@holokai/neuron-sdk 0.3.0 → 0.5.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 CHANGED
@@ -10,6 +10,7 @@ var CapabilityTypeSchema = z.string().min(1).regex(
10
10
  "capability type must be namespaced: {owner}/{package}[/{name}][.{variant}]"
11
11
  );
12
12
  var UserIdSchema = z.string().min(1);
13
+ var JsonSchemaSchema = z.unknown();
13
14
  var CapabilityScopeSchema = z.union([
14
15
  z.literal("any"),
15
16
  z.object({ onBehalfOf: UserIdSchema }).strict()
@@ -18,6 +19,36 @@ var CapabilitySchema = z.object({
18
19
  type: CapabilityTypeSchema,
19
20
  scope: CapabilityScopeSchema,
20
21
  concurrency: z.number().int().positive(),
22
+ /**
23
+ * Plain-language contract advertisement (HOL-2926). All three fields
24
+ * are optional and additive: older neurons keep sending
25
+ * `{ type, scope, concurrency }` and the gateway accepts both shapes.
26
+ * When present they flow (fire-and-forget) to Moku's
27
+ * `capability_catalog` so registry-sourced capabilities render with a
28
+ * real contract in the supervisor catalog instead of type-only stubs.
29
+ *
30
+ * Omit rather than null — the fields are `.optional()`, not
31
+ * `.nullable()`, and a `null` description fails validation.
32
+ */
33
+ description: z.string().optional(),
34
+ /**
35
+ * JSON Schema for the capability's input payload — opaque on the wire,
36
+ * but `null` is rejected: the reconcile push to Moku is last-writer-wins
37
+ * per present key, so a null here could wipe a stored schema.
38
+ * (`description` already rejects null via `z.string()`.)
39
+ */
40
+ // The `: boolean` return annotations stop TS (5.5+) from inferring the
41
+ // lambdas as type predicates, which would narrow the field from
42
+ // `unknown` to `{}` and ripple into every consumer.
43
+ inputSchema: JsonSchemaSchema.refine(
44
+ (v) => v !== null,
45
+ "omit inputSchema instead of sending null"
46
+ ).optional(),
47
+ /** JSON Schema for the capability's output payload — same rules as inputSchema. */
48
+ outputSchema: JsonSchemaSchema.refine(
49
+ (v) => v !== null,
50
+ "omit outputSchema instead of sending null"
51
+ ).optional(),
21
52
  /**
22
53
  * Per-capability lease TTL override in milliseconds (HOL-356).
23
54
  * The gateway uses this when minting a lease's `expiresAt`; absent
@@ -33,7 +64,6 @@ var CapabilitySchema = z.object({
33
64
  */
34
65
  leaseTtlMs: z.number().int().min(1e3).max(60 * 60 * 1e3).optional()
35
66
  }).strict();
36
- var JsonSchemaSchema = z.unknown();
37
67
  var LeaseIdSchema = z.string().min(1);
38
68
  var TaskPayloadSchema = z.object({
39
69
  taskId: z.string().min(1),
@@ -55,9 +85,10 @@ var NeuronIdSchema = z.string().min(1);
55
85
  var RegisterFrameSchema = z.object({
56
86
  type: z.literal("register"),
57
87
  neuronId: NeuronIdSchema,
58
- // Free-form by design — the gateway negotiates compatibility against its
59
- // own PROTOCOL_VERSION (semver), so a v1.1 server can still accept a v1.0
60
- // neuron. Do not pin this to the exported constant.
88
+ // Free-form by design — the gateway records this verbatim and performs
89
+ // no version negotiation; compatibility comes from additive-only frame
90
+ // evolution (see PROTOCOL_VERSION in transport.ts). Do not pin this to
91
+ // the exported constant.
61
92
  protocolVersion: z.string().min(1),
62
93
  capabilities: z.array(CapabilitySchema).min(1).refine(
63
94
  (caps) => new Set(caps.map((c) => c.type)).size === caps.length,
@@ -171,7 +202,7 @@ z.discriminatedUnion("type", [
171
202
  ]);
172
203
 
173
204
  // ../neuron-protocol/src/transport.ts
174
- var PROTOCOL_VERSION = "1.0.0";
205
+ var PROTOCOL_VERSION = "1.1.0";
175
206
  var SSE_PATH = "/neuron/events";
176
207
  var SSE_EVENT = {
177
208
  registered: "registered",
@@ -226,6 +257,12 @@ function toRegistrationCapability(def, opts) {
226
257
  type: def.type,
227
258
  scope,
228
259
  concurrency,
260
+ // Contract advertisement (HOL-2926). `description` is required on the
261
+ // def, so it is always present here; `opts.description` lets a
262
+ // deployment reword it without redefining the capability.
263
+ description: opts?.description ?? def.description,
264
+ inputSchema: def.inputSchema,
265
+ outputSchema: def.outputSchema,
229
266
  ...typeof leaseTtlMs === "number" ? { leaseTtlMs } : {}
230
267
  };
231
268
  }
@@ -245,7 +282,6 @@ var HeartbeatLoop = class {
245
282
  this.timer = setInterval(() => {
246
283
  void this.tick();
247
284
  }, this.intervalMs);
248
- if (typeof this.timer.unref === "function") this.timer.unref();
249
285
  }
250
286
  stop() {
251
287
  if (this.timer) {
@@ -496,9 +532,6 @@ var HttpTransport = class {
496
532
  this.reconnectTimer = null;
497
533
  void this.openStream();
498
534
  }, delay);
499
- if (typeof this.reconnectTimer === "object" && this.reconnectTimer !== null && "unref" in this.reconnectTimer && typeof this.reconnectTimer.unref === "function") {
500
- this.reconnectTimer.unref();
501
- }
502
535
  }
503
536
  };
504
537
  var TransportError = class extends Error {
@@ -518,6 +551,7 @@ var TerminalFailure = class extends Error {
518
551
  this.name = "TerminalFailure";
519
552
  }
520
553
  };
554
+ var DEFAULT_PROGRESS_TIMEOUT_MS = 5e3;
521
555
  function validateInput(zod, validator, jsonSchema, data) {
522
556
  if (zod) {
523
557
  const result = zod.safeParse(data);
@@ -530,8 +564,20 @@ function validateInput(zod, validator, jsonSchema, data) {
530
564
  return errors === null ? { ok: true, value: data } : { ok: false, errors };
531
565
  }
532
566
  async function runLease(opts) {
533
- const { lease, handler, zodInput, zodOutput, transport, validator, logger, signal } = opts;
534
- const inputCheck = validateInput(zodInput, validator, lease.inputSchema, lease.task.input);
567
+ const {
568
+ lease,
569
+ handler,
570
+ zodInput,
571
+ zodOutput,
572
+ inputSchema,
573
+ outputSchema,
574
+ transport,
575
+ validator,
576
+ logger,
577
+ signal,
578
+ progressTimeoutMs = DEFAULT_PROGRESS_TIMEOUT_MS
579
+ } = opts;
580
+ const inputCheck = validateInput(zodInput, validator, inputSchema, lease.task.input);
535
581
  if (!inputCheck.ok) {
536
582
  await sendNack(
537
583
  transport,
@@ -544,13 +590,13 @@ async function runLease(opts) {
544
590
  return;
545
591
  }
546
592
  const parsedInput = inputCheck.value;
547
- const progressFn = makeProgressFn(transport, lease.leaseId, logger);
593
+ const reporter = makeProgressReporter(transport, lease.leaseId, logger, signal, progressTimeoutMs);
548
594
  const ctx = {
549
595
  task: lease.task,
550
596
  leaseId: lease.leaseId,
551
597
  signal,
552
598
  log: scopedLogger(logger, lease),
553
- progress: progressFn,
599
+ progress: reporter.progress,
554
600
  fail: (reason, opts2) => {
555
601
  throw new TerminalFailure(reason, opts2?.code);
556
602
  }
@@ -559,7 +605,9 @@ async function runLease(opts) {
559
605
  try {
560
606
  output = await handler(parsedInput, ctx);
561
607
  } catch (err) {
562
- if (signal.aborted || err.name === "AbortError") {
608
+ const cancelled = signal.aborted || err.name === "AbortError";
609
+ if (!cancelled) await reporter.drained();
610
+ if (cancelled) {
563
611
  await sendNack(
564
612
  transport,
565
613
  lease.leaseId,
@@ -593,7 +641,8 @@ async function runLease(opts) {
593
641
  );
594
642
  return;
595
643
  }
596
- const outputCheck = validateInput(zodOutput, validator, lease.outputSchema, output);
644
+ await reporter.drained();
645
+ const outputCheck = validateInput(zodOutput, validator, outputSchema, output);
597
646
  if (!outputCheck.ok) {
598
647
  await sendNack(
599
648
  transport,
@@ -634,20 +683,37 @@ async function sendNack(transport, leaseId, kind, reason, extras = {}, logger) {
634
683
  );
635
684
  });
636
685
  }
637
- function makeProgressFn(transport, leaseId, logger) {
638
- return (update) => {
639
- void transport.post(POST_PATH.progress, {
640
- type: "progress",
641
- leaseId,
642
- percent: update.percent,
643
- message: update.message,
644
- data: update.data,
645
- sentAt: (/* @__PURE__ */ new Date()).toISOString()
646
- }).catch((err) => {
647
- logger.warn({ err, leaseId }, "progress POST failed; continuing");
648
- });
686
+ function makeProgressReporter(transport, leaseId, logger, signal, timeoutMs) {
687
+ let tail = Promise.resolve();
688
+ return {
689
+ progress: (update) => {
690
+ const frame = {
691
+ type: "progress",
692
+ leaseId,
693
+ percent: update.percent,
694
+ message: update.message,
695
+ data: update.data,
696
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
697
+ };
698
+ tail = tail.then(async () => {
699
+ if (signal.aborted) return;
700
+ try {
701
+ await withTimeout(transport.post(POST_PATH.progress, frame), timeoutMs);
702
+ } catch (err) {
703
+ logger.warn({ err, leaseId }, "progress POST failed; continuing");
704
+ }
705
+ });
706
+ },
707
+ drained: () => tail
649
708
  };
650
709
  }
710
+ function withTimeout(work, ms) {
711
+ let timer;
712
+ const timeout = new Promise((_resolve, reject) => {
713
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
714
+ });
715
+ return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
716
+ }
651
717
  function scopedLogger(logger, lease) {
652
718
  const scope = { leaseId: lease.leaseId, taskId: lease.task.taskId };
653
719
  return {
@@ -660,32 +726,39 @@ function scopedLogger(logger, lease) {
660
726
  var Ajv = AjvDefault.default ?? AjvDefault;
661
727
  var addFormats = addFormatsDefault.default ?? addFormatsDefault;
662
728
  var SchemaValidator = class {
663
- ajv;
664
- cache = /* @__PURE__ */ new WeakMap();
665
- constructor() {
729
+ constructor(onCompileError) {
730
+ this.onCompileError = onCompileError;
666
731
  this.ajv = new Ajv({ allErrors: true, strict: false });
667
732
  addFormats(this.ajv);
668
733
  }
734
+ ajv;
735
+ cache = /* @__PURE__ */ new WeakMap();
736
+ // Schemas whose compilation threw. Cannot-compile is an environment or
737
+ // schema-authoring problem, not evidence about the data — so validate()
738
+ // FAILS OPEN for these (returns null) instead of reporting a mismatch.
739
+ // Nacking on compile failure turned every lease into an
740
+ // INPUT_SCHEMA_MISMATCH retry loop on runtimes where ajv's codegen is
741
+ // unavailable (Electron renderers with CSP `script-src 'self'` block
742
+ // `new Function`) — HOL-2937.
743
+ uncompilable = /* @__PURE__ */ new WeakSet();
669
744
  /**
670
- * Returns null if the value matches the schema (or no schema given);
671
- * otherwise an array of structured errors.
745
+ * Returns null if the value matches the schema, no schema was given, or
746
+ * the schema cannot be compiled in this runtime (fail-open — the gateway
747
+ * remains the enforcement point); otherwise an array of structured errors.
672
748
  */
673
749
  validate(schema, data) {
674
750
  if (!schema || typeof schema !== "object") return null;
751
+ if (this.uncompilable.has(schema)) return null;
675
752
  let validator = this.cache.get(schema);
676
753
  if (!validator) {
677
754
  try {
678
755
  validator = this.ajv.compile(schema);
679
756
  } catch (err) {
680
- return [
681
- {
682
- instancePath: "",
683
- schemaPath: "",
684
- keyword: "schema",
685
- params: {},
686
- message: `schema failed to compile: ${err instanceof Error ? err.message : String(err)}`
687
- }
688
- ];
757
+ this.uncompilable.add(schema);
758
+ this.onCompileError?.(
759
+ `schema failed to compile \u2014 skipping SDK-side validation for this schema: ${err instanceof Error ? err.message : String(err)}`
760
+ );
761
+ return null;
689
762
  }
690
763
  this.cache.set(schema, validator);
691
764
  }
@@ -818,7 +891,9 @@ var Neuron = class extends EventEmitter {
818
891
  super();
819
892
  this.logger = opts.logger ?? noopLogger;
820
893
  this.neuronId = opts.neuronId;
821
- this.validator = new SchemaValidator();
894
+ this.validator = new SchemaValidator(
895
+ (message) => this.logger.warn({ neuronId: this.neuronId }, message)
896
+ );
822
897
  this.transport = isWithTransport(opts) ? opts.transport : new HttpTransport({
823
898
  gatewayUrl: opts.gatewayUrl,
824
899
  neuronId: opts.neuronId,
@@ -890,7 +965,15 @@ var Neuron = class extends EventEmitter {
890
965
  );
891
966
  }
892
967
  handler = optsOrHandler;
893
- capability = { type: reg.type, scope: reg.scope, concurrency: reg.concurrency };
968
+ capability = {
969
+ type: reg.type,
970
+ scope: reg.scope,
971
+ concurrency: reg.concurrency,
972
+ ...reg.description !== void 0 ? { description: reg.description } : {},
973
+ ...reg.inputSchema !== void 0 ? { inputSchema: reg.inputSchema } : {},
974
+ ...reg.outputSchema !== void 0 ? { outputSchema: reg.outputSchema } : {},
975
+ ...typeof reg.leaseTtlMs === "number" ? { leaseTtlMs: reg.leaseTtlMs } : {}
976
+ };
894
977
  inputSchema = reg.inputSchema;
895
978
  outputSchema = reg.outputSchema;
896
979
  }
@@ -1001,7 +1084,15 @@ var Neuron = class extends EventEmitter {
1001
1084
  this.hasOpenedOnce = true;
1002
1085
  this.heartbeat.start();
1003
1086
  } catch (err) {
1004
- this.logger.error({ err }, "register POST failed; transport will retry on reconnect");
1087
+ const cause = err instanceof Error ? err : new Error(String(err));
1088
+ this.logger.error(
1089
+ { err: cause },
1090
+ "register POST rejected; SSE is open but the neuron is NOT registered and will receive no leases"
1091
+ );
1092
+ this.emit(
1093
+ "error",
1094
+ new Error(`registration rejected by gateway: ${cause.message}`, { cause })
1095
+ );
1005
1096
  }
1006
1097
  }
1007
1098
  handleConnectionClose(reason) {
@@ -1078,6 +1169,8 @@ var Neuron = class extends EventEmitter {
1078
1169
  handler: handler.handler,
1079
1170
  zodInput: handler.zodInput,
1080
1171
  zodOutput: handler.zodOutput,
1172
+ inputSchema: handler.inputSchema,
1173
+ outputSchema: handler.outputSchema,
1081
1174
  transport: this.transport,
1082
1175
  validator: this.validator,
1083
1176
  logger: this.logger,