@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/README.md
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
# @holokai/neuron-sdk
|
|
2
|
+
|
|
3
|
+
SDK for building neurons — processes that connect to the BigBrain gateway and execute tasks on behalf of workflows.
|
|
4
|
+
|
|
5
|
+
A neuron advertises one or more **capabilities** (typed task handlers), opens a persistent connection to the gateway, and receives work via SSE. The gateway routes tasks to the right neuron based on capability type and scope. The built-in `apps/worker` uses this SDK; so will any external neuron (desktop app, on-prem agent, custom integration).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @holokai/neuron-sdk
|
|
13
|
+
# or: npm install @holokai/neuron-sdk
|
|
14
|
+
# or: yarn add @holokai/neuron-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Runs in **Node 20+** (uses the global `fetch` and `ReadableStream`) and **modern browsers** (Chromium, Firefox, Safari latest). The package has no `node:*` imports — Holokai Desktop / Electron and the in-browser neuron-tester both consume the same SDK build.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { Neuron } from '@holokai/neuron-sdk';
|
|
25
|
+
|
|
26
|
+
const neuron = new Neuron({
|
|
27
|
+
gatewayUrl: 'https://api.example.com',
|
|
28
|
+
neuronId: 'my-worker-001', // stable per process/device
|
|
29
|
+
auth: () => process.env.GATEWAY_TOKEN!, // or an async refresh function
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
neuron.handle(
|
|
33
|
+
{ type: 'core/script.python', scope: 'any', concurrency: 4 },
|
|
34
|
+
async (input, ctx) => {
|
|
35
|
+
ctx.log.info({}, 'running python script');
|
|
36
|
+
const result = await runPython(input as { code: string }, ctx.signal);
|
|
37
|
+
return { output: result };
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await neuron.start();
|
|
42
|
+
|
|
43
|
+
process.on('SIGTERM', () => neuron.stop({ drain: true, timeoutMs: 30_000 }));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Core concepts
|
|
49
|
+
|
|
50
|
+
### Capability
|
|
51
|
+
|
|
52
|
+
A capability is a named contract a neuron can fulfill. It has:
|
|
53
|
+
|
|
54
|
+
| Field | Type | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `type` | `string` | Namespaced identifier: `{owner}/{package}[/{name}][.{variant}]` |
|
|
57
|
+
| `scope` | `'any' \| { onBehalfOf: userId }` | Who this handler serves |
|
|
58
|
+
| `concurrency` | `number` | Max parallel tasks for this capability |
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
- `core/script.python` — server-side Python execution, any user
|
|
62
|
+
- `desktop/mcp/drive.read` — user's Google Drive, scoped to that user
|
|
63
|
+
- `org/01HBQ8ZK/custom-tool` — org-specific capability
|
|
64
|
+
|
|
65
|
+
### Namespace authorization
|
|
66
|
+
|
|
67
|
+
The gateway enforces which identities may advertise which namespaces:
|
|
68
|
+
|
|
69
|
+
| Namespace prefix | Permitted advertisers | Required scope |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `core/*` | Server service identities | `any` |
|
|
72
|
+
| `desktop/*` | Attested desktop binaries | `{ onBehalfOf: userId }` |
|
|
73
|
+
| `org/{orgId}/*` | Tenant-installed extensions | `any` within org |
|
|
74
|
+
| `user/{userId}/*` | Personal extensions | `{ onBehalfOf: userId }` |
|
|
75
|
+
|
|
76
|
+
Registration is rejected (HTTP 403) if the JWT is not authorized for the namespace.
|
|
77
|
+
|
|
78
|
+
### Scope
|
|
79
|
+
|
|
80
|
+
- **`'any'`** — the neuron handles tasks for any user. Shared queue; no per-user binding.
|
|
81
|
+
- **`{ onBehalfOf: userId }`** — the neuron handles tasks for that specific user only. The JWT's user binding must match; the gateway enforces this at registration time.
|
|
82
|
+
|
|
83
|
+
One neuron may advertise mixed-scope capabilities:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
neuron.handle({ type: 'desktop/mcp/drive.read', scope: { onBehalfOf: 'user:abc' }, concurrency: 1 }, driveHandler);
|
|
87
|
+
neuron.handle({ type: 'core/script.python', scope: 'any', concurrency: 4 }, pythonHandler);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Lease
|
|
91
|
+
|
|
92
|
+
A lease is how the gateway delivers a task. When the broker dispatches a task to a neuron, the gateway:
|
|
93
|
+
1. Writes a DB row with `leaseId` and expiry.
|
|
94
|
+
2. Sends a `lease` frame over SSE.
|
|
95
|
+
|
|
96
|
+
The SDK validates input, invokes your handler, and sends either `ack` (success) or `nack` (failure) over HTTPS POST. Until the ack arrives, the message remains unacknowledged in the broker — if the neuron disconnects, the broker redelivers automatically.
|
|
97
|
+
|
|
98
|
+
**Handlers must be idempotent.** A task that completed a side effect but failed to ack will be retried. Use capability-native idempotency keys (e.g., Google Drive `requestId`, HTTP `Idempotency-Key`).
|
|
99
|
+
|
|
100
|
+
### Neuron ID
|
|
101
|
+
|
|
102
|
+
`neuronId` must be **stable across restarts** for the same process/device. The gateway uses it to correlate reconnects. Pick something meaningful and persistent:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// Server process
|
|
106
|
+
neuronId: `worker-${process.env.HOSTNAME}-${process.pid}`
|
|
107
|
+
|
|
108
|
+
// Desktop app — persist a UUID to disk
|
|
109
|
+
neuronId: await readOrCreateStoredId('/var/lib/myapp/neuron-id')
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## API reference
|
|
115
|
+
|
|
116
|
+
### `new Neuron(opts: NeuronOptions)`
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
interface NeuronOptions {
|
|
120
|
+
gatewayUrl: string; // Base URL, e.g. 'https://api.example.com'
|
|
121
|
+
neuronId: string; // Stable per process/device
|
|
122
|
+
auth: AuthCallback; // Token provider (see below)
|
|
123
|
+
logger?: NeuronLogger; // Plug in pino, console, etc. Default: silent
|
|
124
|
+
heartbeatIntervalMs?: number; // Default 10_000 (10s)
|
|
125
|
+
initialReconnectDelayMs?: number; // Default 500ms
|
|
126
|
+
maxReconnectDelayMs?: number; // Default 30_000 (30s)
|
|
127
|
+
fetch?: typeof fetch; // Override for tests
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### `neuron.handle(reg, handler)`
|
|
134
|
+
|
|
135
|
+
Register a handler for a capability. Must be called **before** `neuron.start()`.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
neuron.handle(reg: CapabilityRegistration, handler: Handler): void
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
interface CapabilityRegistration {
|
|
143
|
+
type: string; // Namespaced capability type
|
|
144
|
+
scope: 'any' | { onBehalfOf: string };
|
|
145
|
+
concurrency: number; // Max parallel executions
|
|
146
|
+
inputSchema?: JsonSchema; // Optional; validated before invoking handler
|
|
147
|
+
outputSchema?: JsonSchema; // Optional; validated before acking
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type Handler<TInput = unknown, TOutput = unknown> = (
|
|
151
|
+
input: TInput,
|
|
152
|
+
ctx: HandlerContext,
|
|
153
|
+
) => Promise<TOutput> | TOutput;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If `inputSchema` is provided and the lease input fails validation, the handler is never called — the SDK sends a `schema-mismatch` nack automatically. Same for `outputSchema` on the return value.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### `neuron.start()`
|
|
161
|
+
|
|
162
|
+
Opens the SSE stream, posts `register` with all advertised capabilities, and starts the heartbeat loop.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
await neuron.start(): Promise<void>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Throws if no handlers have been registered. Subsequent calls are no-ops.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### `neuron.stop(opts?)`
|
|
173
|
+
|
|
174
|
+
Graceful shutdown: refuse new leases, drain in-flight handlers, disconnect.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
await neuron.stop({ drain?: boolean, timeoutMs?: number }): Promise<void>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Option | Default | Description |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `drain` | `true` | Wait for in-flight handlers to finish |
|
|
183
|
+
| `timeoutMs` | `30_000` | After this, abort remaining in-flight tasks |
|
|
184
|
+
|
|
185
|
+
Queued leases (received but not yet started due to concurrency limits) are nacked `retryable` on shutdown so the broker redelivers immediately.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### `neuron.updateCapability(reg)`
|
|
190
|
+
|
|
191
|
+
Change concurrency for an already-registered capability at runtime. Useful when available resources change (e.g., desktop plugged into power).
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
await neuron.updateCapability({ type: string, concurrency: number }): Promise<void>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Notifies the gateway via `update-capability` so it can tune AMQP prefetch. Safe to call while tasks are in flight — in-flight tasks finish at the old concurrency; new slots open at the new limit.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
### `HandlerContext`
|
|
202
|
+
|
|
203
|
+
The second argument to every handler:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
interface HandlerContext {
|
|
207
|
+
task: TaskPayload; // Full task metadata
|
|
208
|
+
leaseId: string; // Gateway lease ID (for logging/debugging)
|
|
209
|
+
signal: AbortSignal; // Fires on gateway cancel or SDK shutdown
|
|
210
|
+
log: NeuronLogger; // Logger scoped to this lease
|
|
211
|
+
progress(update: { percent?: number; message?: string; data?: unknown }): void;
|
|
212
|
+
fail(reason: string, opts?: { code?: string }): never;
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### `ctx.signal`
|
|
217
|
+
|
|
218
|
+
Pass this to any fetch/HTTP calls so they abort cleanly:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
const resp = await fetch(url, { signal: ctx.signal });
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
When the gateway sends a `cancel` frame (workflow was cancelled by the user), `ctx.signal` fires and your handler should throw/return promptly. An `AbortError` from a cancelled signal is treated as `nack(kind='cancelled')` — benign, not counted as a failure.
|
|
225
|
+
|
|
226
|
+
#### `ctx.progress()`
|
|
227
|
+
|
|
228
|
+
Report progress for long-running tasks. Non-blocking; safe to call many times.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
ctx.progress({ percent: 50, message: 'halfway done' });
|
|
232
|
+
ctx.progress({ data: { rowsProcessed: 1200 } });
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
#### `ctx.fail()`
|
|
236
|
+
|
|
237
|
+
Terminal failure — nacks the lease with `kind='terminal'` (no retry). Use when the task can never succeed regardless of retries.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
if (!validApiKey) {
|
|
241
|
+
ctx.fail('invalid API key', { code: 'INVALID_API_KEY' });
|
|
242
|
+
// unreachable — fail() throws internally
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
### `AuthCallback`
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
type AuthCallback = () => Promise<string> | string;
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Called on every POST and SSE open. The SDK retries once on 401, so implement token refresh inside the callback:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
let token = await fetchInitialToken();
|
|
258
|
+
|
|
259
|
+
const auth = async () => {
|
|
260
|
+
if (isExpired(token)) {
|
|
261
|
+
token = await refreshToken();
|
|
262
|
+
}
|
|
263
|
+
return token.accessToken;
|
|
264
|
+
};
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### `NeuronLogger`
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
interface NeuronLogger {
|
|
273
|
+
debug(meta: Record<string, unknown>, msg?: string): void;
|
|
274
|
+
info(meta: Record<string, unknown>, msg?: string): void;
|
|
275
|
+
warn(meta: Record<string, unknown>, msg?: string): void;
|
|
276
|
+
error(meta: Record<string, unknown>, msg?: string): void;
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Matches pino's call signature directly. To use pino:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import pino from 'pino';
|
|
284
|
+
const logger = pino({ level: 'info' });
|
|
285
|
+
const neuron = new Neuron({ ..., logger });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
### `defineCapability()`
|
|
291
|
+
|
|
292
|
+
A typed helper that produces a capability definition from zod schemas, with handler inputs/outputs fully inferred:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { defineCapability, Neuron } from '@holokai/neuron-sdk';
|
|
296
|
+
import { z } from 'zod';
|
|
297
|
+
|
|
298
|
+
const pythonCap = defineCapability({
|
|
299
|
+
type: 'core/script.python',
|
|
300
|
+
description: 'Run a Python script in a sandbox',
|
|
301
|
+
input: z.object({ code: z.string(), args: z.record(z.unknown()).optional() }),
|
|
302
|
+
output: z.object({ stdout: z.string(), exitCode: z.number() }),
|
|
303
|
+
scope: 'any', // optional default; overridable at handle() time
|
|
304
|
+
concurrency: 4, // optional default; overridable at handle() time
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Handler input/output inferred — no manual type annotations:
|
|
308
|
+
neuron.handle(pythonCap, async (input, ctx) => {
|
|
309
|
+
// input: { code: string; args?: Record<string, unknown> }
|
|
310
|
+
return { stdout: '...', exitCode: 0 };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Or override defaults per deployment:
|
|
314
|
+
neuron.handle(pythonCap, { scope: { onBehalfOf: 'user-123' }, concurrency: 8 }, fn);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Validation uses the **original zod schemas** (so `.refine()`, `.transform()`, `.preprocess()`, `.brand()` all run as expected). The JSON Schema produced by `zod-to-json-schema` is shipped on the wire to the gateway and planner but is not used as the runtime validator — that avoids silently dropping zod features that don't translate.
|
|
318
|
+
|
|
319
|
+
The fall-back `neuron.handle(registration, fn)` shape (raw JSON Schema + capability fields) is still accepted for low-level callers that don't have zod schemas on hand.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
### Events
|
|
324
|
+
|
|
325
|
+
`Neuron` extends `EventEmitter` from [`eventemitter3`](https://github.com/primus/eventemitter3) — a browser-clean, type-safe drop-in for Node's `EventEmitter`. Subscribe with `.on()`, `.once()`, or `.off()`:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const off = neuron.on('frame', (frame) => {
|
|
329
|
+
log.append(frame); // see every parsed SSE frame
|
|
330
|
+
});
|
|
331
|
+
off(); // unsubscribe
|
|
332
|
+
|
|
333
|
+
neuron.once('connection:registered', ({ sessionId }) => {
|
|
334
|
+
console.log(`registered as ${sessionId}`);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
neuron.on('connection:reconnecting', ({ attempt, delayMs, reason }) => {
|
|
338
|
+
statusBar.show(`reconnecting (attempt ${attempt}, ${delayMs}ms)`, reason);
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The full event map:
|
|
343
|
+
|
|
344
|
+
| Event | Payload | When |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| `connection:open` | _none_ | SSE handshake succeeded; before `register` is posted |
|
|
347
|
+
| `connection:registered` | `{ sessionId: string; capabilities: number }` | Gateway accepted the register frame |
|
|
348
|
+
| `connection:close` | `reason: string` | SSE stream ended (clean shutdown or transient error) |
|
|
349
|
+
| `connection:reconnecting` | `{ attempt, delayMs, reason }` | Reconnect scheduled, before backoff delay |
|
|
350
|
+
| `error` | `err: Error` | Transport / parse error (reconnect handling is automatic) |
|
|
351
|
+
| `frame` | `frame: ServerToNeuronFrame` | Pass-through of every parsed inbound SSE frame |
|
|
352
|
+
|
|
353
|
+
The `frame` event is a firehose mirror of the gateway's `/neuron/events` SSE stream. It fires for **every** frame including ones the SDK handles internally (`lease`, `cancel`) — useful for debug UIs, observability, and audit logs. The high-level `neuron.handle()` API is implemented as a `frame` listener filtered to `lease` frames, so `handle()` and direct `frame` subscribers compose without conflict.
|
|
354
|
+
|
|
355
|
+
Subscribe **before** calling `neuron.start()` if you care about events that fire as part of the connection sequence (`connection:open`, the initial `registered` frame). Late subscribers will still see subsequent events.
|
|
356
|
+
|
|
357
|
+
The event surface is designed to grow — future entries (e.g. `'workflow:task-completed'` for cross-neuron notifications) slot in without breaking subscribers.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Examples
|
|
362
|
+
|
|
363
|
+
### Simple any-scoped capability
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { Neuron } from '@holokai/neuron-sdk';
|
|
367
|
+
|
|
368
|
+
const neuron = new Neuron({
|
|
369
|
+
gatewayUrl: process.env.GATEWAY_URL!,
|
|
370
|
+
neuronId: `worker-${process.env.HOSTNAME}`,
|
|
371
|
+
auth: () => process.env.GATEWAY_TOKEN!,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
neuron.handle(
|
|
375
|
+
{ type: 'core/http', scope: 'any', concurrency: 8 },
|
|
376
|
+
async (input, ctx) => {
|
|
377
|
+
const { url, method = 'GET', headers = {}, body } = input as {
|
|
378
|
+
url: string;
|
|
379
|
+
method?: string;
|
|
380
|
+
headers?: Record<string, string>;
|
|
381
|
+
body?: string;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const resp = await fetch(url, { method, headers, body, signal: ctx.signal });
|
|
385
|
+
const text = await resp.text();
|
|
386
|
+
|
|
387
|
+
return { status: resp.status, body: text };
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
await neuron.start();
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### User-scoped capability (desktop / MCP)
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
const userId = await getUserIdFromToken(authToken);
|
|
398
|
+
|
|
399
|
+
neuron.handle(
|
|
400
|
+
{ type: 'desktop/mcp/drive.read', scope: { onBehalfOf: userId }, concurrency: 1 },
|
|
401
|
+
async (input, ctx) => {
|
|
402
|
+
const { fileId } = input as { fileId: string };
|
|
403
|
+
ctx.progress({ message: 'fetching file metadata' });
|
|
404
|
+
|
|
405
|
+
const file = await driveClient.files.get({ fileId, signal: ctx.signal });
|
|
406
|
+
ctx.progress({ percent: 50, message: 'downloading content' });
|
|
407
|
+
|
|
408
|
+
const content = await driveClient.files.download({ fileId, signal: ctx.signal });
|
|
409
|
+
return { name: file.name, mimeType: file.mimeType, content };
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Long-running task with progress
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
neuron.handle(
|
|
418
|
+
{ type: 'core/data.transform', scope: 'any', concurrency: 2 },
|
|
419
|
+
async (input, ctx) => {
|
|
420
|
+
const { rows } = input as { rows: unknown[] };
|
|
421
|
+
const results: unknown[] = [];
|
|
422
|
+
|
|
423
|
+
for (let i = 0; i < rows.length; i++) {
|
|
424
|
+
if (ctx.signal.aborted) break; // respect cancellation
|
|
425
|
+
|
|
426
|
+
results.push(await processRow(rows[i]));
|
|
427
|
+
ctx.progress({ percent: Math.round(((i + 1) / rows.length) * 100) });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { results };
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Error handling
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
neuron.handle(
|
|
439
|
+
{ type: 'core/llm', scope: 'any', concurrency: 4 },
|
|
440
|
+
async (input, ctx) => {
|
|
441
|
+
const { prompt, model } = input as { prompt: string; model: string };
|
|
442
|
+
|
|
443
|
+
let response;
|
|
444
|
+
try {
|
|
445
|
+
response = await llmClient.complete({ prompt, model, signal: ctx.signal });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if ((err as Error).name === 'AbortError') {
|
|
448
|
+
throw err; // let the SDK handle cancellation
|
|
449
|
+
}
|
|
450
|
+
if ((err as any).status === 400) {
|
|
451
|
+
// Bad request — no point retrying
|
|
452
|
+
ctx.fail(`invalid LLM request: ${(err as Error).message}`, { code: 'INVALID_REQUEST' });
|
|
453
|
+
}
|
|
454
|
+
// Anything else: throw to get a retryable nack
|
|
455
|
+
throw err;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { text: response.text, tokensUsed: response.usage.total };
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Graceful shutdown
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
await neuron.start();
|
|
467
|
+
|
|
468
|
+
const shutdown = async (signal: string) => {
|
|
469
|
+
console.log(`${signal} received — draining...`);
|
|
470
|
+
await neuron.stop({ drain: true, timeoutMs: 30_000 });
|
|
471
|
+
process.exit(0);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
475
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Runtime concurrency update
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import { powerMonitor } from 'electron'; // or any power/resource API
|
|
482
|
+
|
|
483
|
+
powerMonitor.on('on-ac', async () => {
|
|
484
|
+
await neuron.updateCapability({ type: 'core/script.python', concurrency: 8 });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
powerMonitor.on('on-battery', async () => {
|
|
488
|
+
await neuron.updateCapability({ type: 'core/script.python', concurrency: 1 });
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Configuration reference
|
|
495
|
+
|
|
496
|
+
| Option | Default | Notes |
|
|
497
|
+
|---|---|---|
|
|
498
|
+
| `gatewayUrl` | — | Base URL, no trailing slash |
|
|
499
|
+
| `neuronId` | — | Stable per device/process; reused on reconnect |
|
|
500
|
+
| `auth` | — | Called on every POST + SSE open |
|
|
501
|
+
| `logger` | silent no-op | Plug in pino or console |
|
|
502
|
+
| `heartbeatIntervalMs` | `10_000` | Sent every 10s; renews lease expiries |
|
|
503
|
+
| `initialReconnectDelayMs` | `500` | First reconnect delay |
|
|
504
|
+
| `maxReconnectDelayMs` | `30_000` | Reconnect delay ceiling (exponential backoff) |
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
## Protocol details
|
|
509
|
+
|
|
510
|
+
This section covers enough to debug; the wire-level frame schemas are bundled into the SDK and exported alongside the public types.
|
|
511
|
+
|
|
512
|
+
### Transport
|
|
513
|
+
|
|
514
|
+
- **Server → Neuron**: SSE stream at `GET /neuron/events?neuronId={id}`
|
|
515
|
+
- **Neuron → Server**: HTTPS POST endpoints (one per frame type)
|
|
516
|
+
|
|
517
|
+
No WebSockets — SSE + HTTPS POST traverses enterprise proxies cleanly.
|
|
518
|
+
|
|
519
|
+
### Frame types
|
|
520
|
+
|
|
521
|
+
**Neuron → Server (POST):**
|
|
522
|
+
|
|
523
|
+
| Frame | Path | When |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| `register` | `/neuron/register` | On SSE open |
|
|
526
|
+
| `update-capability` | `/neuron/update-capability` | On concurrency change |
|
|
527
|
+
| `heartbeat` | `/neuron/heartbeat` | Every 10s while connected |
|
|
528
|
+
| `ack` | `/neuron/ack` | Handler succeeded |
|
|
529
|
+
| `nack` | `/neuron/nack` | Handler failed |
|
|
530
|
+
| `progress` | `/neuron/progress` | During handler execution |
|
|
531
|
+
|
|
532
|
+
**Server → Neuron (SSE):**
|
|
533
|
+
|
|
534
|
+
| Frame | When |
|
|
535
|
+
|---|---|
|
|
536
|
+
| `registered` | Gateway accepted the register frame; effective capabilities returned |
|
|
537
|
+
| `lease` | Task to execute |
|
|
538
|
+
| `cancel` | Gateway cancels a specific lease (workflow cancelled) |
|
|
539
|
+
| `capability-revoked` | A capability was deauthorized mid-session |
|
|
540
|
+
| `disconnect` | Gateway is closing the connection cleanly |
|
|
541
|
+
|
|
542
|
+
### Heartbeat
|
|
543
|
+
|
|
544
|
+
Every ~10s the SDK posts:
|
|
545
|
+
|
|
546
|
+
```json
|
|
547
|
+
{
|
|
548
|
+
"type": "heartbeat",
|
|
549
|
+
"neuronId": "worker-host-1234",
|
|
550
|
+
"inFlight": [
|
|
551
|
+
{ "leaseId": "01HB...", "taskId": "task-abc", "startedAt": "2024-01-01T12:00:00Z" }
|
|
552
|
+
],
|
|
553
|
+
"sentAt": "2024-01-01T12:00:10Z"
|
|
554
|
+
}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The gateway renews lease expiries for every `leaseId` in `inFlight` in a single DB round-trip. **Only running leases appear in `inFlight`** — queued leases (admitted but waiting for a concurrency slot) are excluded so the reaper doesn't mistakenly extend their TTL.
|
|
558
|
+
|
|
559
|
+
### Lease lifecycle
|
|
560
|
+
|
|
561
|
+
```
|
|
562
|
+
Gateway Neuron SDK
|
|
563
|
+
│ │
|
|
564
|
+
│── lease (SSE) ──────────────────────────▶ │
|
|
565
|
+
│ │ validate input schema
|
|
566
|
+
│ │ wait for pool slot
|
|
567
|
+
│ │ invoke handler
|
|
568
|
+
│◀── heartbeats (POST every 10s) ────────── │
|
|
569
|
+
│ │ handler returns
|
|
570
|
+
│ │ validate output schema
|
|
571
|
+
│◀── ack (POST) ────────────────────────── │
|
|
572
|
+
│
|
|
573
|
+
│ [or on failure]
|
|
574
|
+
│◀── nack(kind='retryable') ────────────── │ broker redelivers
|
|
575
|
+
│◀── nack(kind='terminal') ─────────────── │ workflow fails
|
|
576
|
+
│◀── nack(kind='schema-mismatch') ──────── │ structured error surfaced
|
|
577
|
+
│◀── nack(kind='cancelled') ────────────── │ benign; signal fired
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Reconnection
|
|
581
|
+
|
|
582
|
+
The SDK uses exponential backoff (default: 500ms initial, doubling, 30s ceiling). On reconnect:
|
|
583
|
+
1. SSE stream re-opens.
|
|
584
|
+
2. `register` is posted with the full capability list.
|
|
585
|
+
3. Heartbeat loop resumes.
|
|
586
|
+
|
|
587
|
+
In-flight leases from before the disconnect were never acked — the broker redelivers them. The reaper flips stale `assigned` DB rows back to `pending`. Your handlers will receive the same task again; make them idempotent.
|
|
588
|
+
|
|
589
|
+
---
|
|
590
|
+
|
|
591
|
+
## Troubleshooting
|
|
592
|
+
|
|
593
|
+
### `Neuron.handle() must be called before start()`
|
|
594
|
+
|
|
595
|
+
Register all capabilities before calling `start()`. For runtime changes, use `updateCapability()`.
|
|
596
|
+
|
|
597
|
+
### `Neuron.start() requires at least one handler`
|
|
598
|
+
|
|
599
|
+
You must call `neuron.handle(...)` at least once before `start()`.
|
|
600
|
+
|
|
601
|
+
### Registration rejected with HTTP 403
|
|
602
|
+
|
|
603
|
+
Your JWT is not authorized for the namespace you're trying to advertise. Check:
|
|
604
|
+
- The capability type uses the correct namespace prefix (`core/*` requires a service identity, not a user JWT).
|
|
605
|
+
- For user-scoped capabilities, the `onBehalfOf` value matches the user in your JWT.
|
|
606
|
+
|
|
607
|
+
### Tasks not being received
|
|
608
|
+
|
|
609
|
+
1. Check the `registered` log line — it shows the **effective** capabilities after the gateway's policy/auth filters. If a capability is missing, the gateway filtered it.
|
|
610
|
+
2. Check `neuronId` — if it changes across restarts, the gateway may have stale session state.
|
|
611
|
+
3. Check heartbeat logs — if heartbeats are failing, the lease reaper may be reclaiming tasks faster than they're running.
|
|
612
|
+
|
|
613
|
+
### Handler keeps getting re-delivered
|
|
614
|
+
|
|
615
|
+
The handler completed but the `ack` POST failed (gateway returned 4xx or 5xx). Look for `ack POST rejected by gateway` in logs. Root causes:
|
|
616
|
+
- Output failed the `outputSchema` check — fix your handler's return shape.
|
|
617
|
+
- Gateway was restarting — the next delivery will succeed once it's back.
|
|
618
|
+
|
|
619
|
+
### Tasks stuck in `assigned` state
|
|
620
|
+
|
|
621
|
+
Heartbeats aren't reaching the gateway, so the reaper hasn't reclaimed them. Check:
|
|
622
|
+
- Is the SSE stream still connected? (`transport reconnecting` log entries)
|
|
623
|
+
- Are heartbeat POSTs failing? (`heartbeat post failed` log entries)
|
|
624
|
+
- Is `heartbeatIntervalMs` misconfigured to a very large value?
|
|
625
|
+
|
|
626
|
+
### Auth 401 on every request
|
|
627
|
+
|
|
628
|
+
The `auth` callback isn't returning a valid token. If using a refresh flow, ensure the callback fetches a fresh token on each call (the SDK caches nothing — it's your callback's job).
|