@kindgi/handler-runtime 0.0.0-bootstrap.0 → 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.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +218 -2
  3. package/dist/build-extensions.d.ts +75 -0
  4. package/dist/build-extensions.d.ts.map +1 -0
  5. package/dist/build-extensions.js +27 -0
  6. package/dist/build-extensions.js.map +1 -0
  7. package/dist/discovery.d.ts +19 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +81 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/entrypoint.d.ts +10 -0
  12. package/dist/entrypoint.d.ts.map +1 -0
  13. package/dist/entrypoint.js +24 -0
  14. package/dist/entrypoint.js.map +1 -0
  15. package/dist/handler-runner.d.ts +127 -0
  16. package/dist/handler-runner.d.ts.map +1 -0
  17. package/dist/handler-runner.js +318 -0
  18. package/dist/handler-runner.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +7 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/kindgi-index-main.d.ts +2 -0
  24. package/dist/kindgi-index-main.d.ts.map +1 -0
  25. package/dist/kindgi-index-main.js +15 -0
  26. package/dist/kindgi-index-main.js.map +1 -0
  27. package/dist/kindgi-index.d.ts +316 -0
  28. package/dist/kindgi-index.d.ts.map +1 -0
  29. package/dist/kindgi-index.js +1201 -0
  30. package/dist/kindgi-index.js.map +1 -0
  31. package/dist/pack-env.d.ts +66 -0
  32. package/dist/pack-env.d.ts.map +1 -0
  33. package/dist/pack-env.js +97 -0
  34. package/dist/pack-env.js.map +1 -0
  35. package/dist/pack-service/index.d.ts +7 -0
  36. package/dist/pack-service/index.d.ts.map +1 -0
  37. package/dist/pack-service/index.js +6 -0
  38. package/dist/pack-service/index.js.map +1 -0
  39. package/dist/pack-service/main.d.ts +47 -0
  40. package/dist/pack-service/main.d.ts.map +1 -0
  41. package/dist/pack-service/main.js +201 -0
  42. package/dist/pack-service/main.js.map +1 -0
  43. package/dist/pack-service/service.d.ts +61 -0
  44. package/dist/pack-service/service.d.ts.map +1 -0
  45. package/dist/pack-service/service.js +341 -0
  46. package/dist/pack-service/service.js.map +1 -0
  47. package/dist/pack-service/supervisor.d.ts +138 -0
  48. package/dist/pack-service/supervisor.d.ts.map +1 -0
  49. package/dist/pack-service/supervisor.js +424 -0
  50. package/dist/pack-service/supervisor.js.map +1 -0
  51. package/dist/protocol.d.ts +104 -0
  52. package/dist/protocol.d.ts.map +1 -0
  53. package/dist/protocol.js +116 -0
  54. package/dist/protocol.js.map +1 -0
  55. package/package.json +87 -4
  56. package/src/build-extensions.ts +100 -0
  57. package/src/discovery.ts +89 -0
  58. package/src/entrypoint.ts +23 -0
  59. package/src/handler-runner.ts +500 -0
  60. package/src/index.ts +66 -0
  61. package/src/kindgi-index-main.ts +17 -0
  62. package/src/kindgi-index.ts +1605 -0
  63. package/src/pack-env.ts +148 -0
  64. package/src/pack-service/index.ts +17 -0
  65. package/src/pack-service/main.ts +246 -0
  66. package/src/pack-service/service.ts +478 -0
  67. package/src/pack-service/supervisor.ts +600 -0
  68. package/src/protocol.ts +214 -0
@@ -0,0 +1,478 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The pack service: an HTTP server that runs a pack's code — its tool
6
+ * handlers and guardrail checks — for a Kindgi runtime, speaking pack
7
+ * protocol v2 (`../protocol.ts`).
8
+ *
9
+ * Long-lived and warm: modules are imported once (at `prewarm`) and
10
+ * reused across calls, and calls run concurrently up to a cap. Handler
11
+ * console output goes to the process's own stdout/stderr and can never
12
+ * corrupt a response.
13
+ *
14
+ * Routes:
15
+ * - `POST /v1/invoke` (token) — body: a `PackRequest`; 200 with a `PackResponse`
16
+ * - `GET /v1/info` (token) — the pack's identity, protocol, tools and checks,
17
+ * and the required env names it lacks (`missingEnv`)
18
+ * - `GET /healthz` — the process is up
19
+ * - `GET /readyz` — prewarmed, not draining, and (under the
20
+ * `strict` env check) every required env name set
21
+ *
22
+ * Non-2xx statuses are transport-level only: 401 (token), 404, 405, 413
23
+ * (body too large), 415 (not JSON), 503 with `Retry-After` (not ready,
24
+ * draining or at the concurrency cap), and 500 if the service itself
25
+ * fails while handling a call.
26
+ */
27
+
28
+ import { timingSafeEqual } from 'node:crypto';
29
+ import type { IncomingMessage, ServerResponse } from 'node:http';
30
+ import { pathToFileURL } from 'node:url';
31
+
32
+ import type { HandlerError } from '../handler-runner.js';
33
+ import { runCheck, runHandler } from '../handler-runner.js';
34
+ import type { Index, IndexedGuardrail, IndexedTool } from '../kindgi-index.js';
35
+ import { type PackEnvCheck, missingPackEnv } from '../pack-env.js';
36
+ import {
37
+ type CheckInvokeMessage,
38
+ PACK_HEADERS,
39
+ PACK_PROTOCOL_VERSION,
40
+ type PackErrorCode,
41
+ type PackErrorMessage,
42
+ type PackResponse,
43
+ type ToolInvokeMessage,
44
+ packError,
45
+ parsePackRequest,
46
+ } from '../protocol.js';
47
+
48
+ export interface PackServiceOptions {
49
+ /** The pack's `index.json`, as built. */
50
+ readonly index: Index;
51
+ /** Where an index entry's `modulePath` lives on this host (absolute path). */
52
+ readonly resolveModule: (modulePath: string) => string;
53
+ /** Callers must send it in the `kindgi-pack-token` header. */
54
+ readonly token: string;
55
+ /** Concurrent calls before the service answers 503. Default 32. */
56
+ readonly maxConcurrency?: number;
57
+ /** Largest accepted request body. Default 10 MiB. */
58
+ readonly maxBodyBytes?: number;
59
+ /** Deadline when a call sends no `kindgi-timeout-ms`. Default 120 s. */
60
+ readonly defaultTimeoutMs?: number;
61
+ readonly logger?: (event: PackServiceLogEvent) => void;
62
+ /**
63
+ * The process environment the index's `env.required` names are checked
64
+ * against, once, at creation. Default `process.env`.
65
+ */
66
+ readonly env?: Readonly<Record<string, string | undefined>>;
67
+ /**
68
+ * `strict` (default): with a required name missing, the service isn't
69
+ * ready (`/readyz` and calls answer 503 naming it). `warn`: it serves,
70
+ * and reports the names in the log and `/v1/info`.
71
+ */
72
+ readonly envCheck?: PackEnvCheck;
73
+ /** Test seam: how a module is loaded from its absolute path. */
74
+ readonly importModule?: (absolutePath: string) => Promise<unknown>;
75
+ }
76
+
77
+ export type PackServiceLogEvent =
78
+ | {
79
+ readonly kind: 'call';
80
+ readonly target: 'tool' | 'check';
81
+ readonly id: string;
82
+ readonly durationMs: number;
83
+ readonly outcome: 'ok' | PackErrorCode;
84
+ }
85
+ | {
86
+ /** A handler ignored its abort signal and finished after its call ended. */
87
+ readonly kind: 'handler-finished-late';
88
+ readonly target: 'tool' | 'check';
89
+ readonly id: string;
90
+ readonly afterMs: number;
91
+ }
92
+ | {
93
+ /** Required env names the process doesn't have, logged once at startup. */
94
+ readonly kind: 'missing-env';
95
+ readonly check: PackEnvCheck;
96
+ readonly names: readonly string[];
97
+ };
98
+
99
+ export interface PackService {
100
+ readonly handle: (req: IncomingMessage, res: ServerResponse) => void;
101
+ /** Import every tool and check module; returns the ones that failed. */
102
+ prewarm(): Promise<readonly PackErrorMessage[]>;
103
+ /** Stop accepting calls (readyz → 503) and wait for in-flight ones, up to `graceMs`. */
104
+ drain(graceMs: number): Promise<void>;
105
+ inFlight(): number;
106
+ ready(): boolean;
107
+ }
108
+
109
+ const DEFAULT_MAX_CONCURRENCY = 32;
110
+ const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
111
+ const DEFAULT_TIMEOUT_MS = 120_000;
112
+
113
+ export function createPackService(options: PackServiceOptions): PackService {
114
+ const maxConcurrency = options.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
115
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
116
+ const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
117
+ const logger = options.logger ?? (() => undefined);
118
+ const importModule =
119
+ options.importModule ?? ((p: string) => import(pathToFileURL(p).href) as Promise<unknown>);
120
+ const expectedToken = Buffer.from(options.token);
121
+
122
+ const tools = new Map<string, IndexedTool>(options.index.tools.map((t) => [t.id, t]));
123
+ const checks = new Map<string, IndexedGuardrail>(
124
+ options.index.guardrails.map((g) => [g.checkId ?? g.id, g]),
125
+ );
126
+
127
+ let inFlight = 0;
128
+ let ready = false;
129
+ let draining = false;
130
+
131
+ const envCheck = options.envCheck ?? 'strict';
132
+ const missingEnv = missingPackEnv(options.index.env, options.env ?? process.env);
133
+ if (missingEnv.length > 0) logger({ kind: 'missing-env', check: envCheck, names: missingEnv });
134
+
135
+ /** Why the service can't take calls now, or `undefined` when it can. */
136
+ function notReady(): Reply | undefined {
137
+ if (draining) return unavailable('draining');
138
+ if (envCheck === 'strict' && missingEnv.length > 0) {
139
+ return unavailable('missing env', { missingEnv });
140
+ }
141
+ if (!ready) return unavailable('not ready');
142
+ return undefined;
143
+ }
144
+
145
+ function authorized(req: IncomingMessage): boolean {
146
+ const got = req.headers[PACK_HEADERS.token];
147
+ if (typeof got !== 'string') return false;
148
+ const buf = Buffer.from(got);
149
+ return buf.length === expectedToken.length && timingSafeEqual(buf, expectedToken);
150
+ }
151
+
152
+ async function prewarm(): Promise<readonly PackErrorMessage[]> {
153
+ const failures: PackErrorMessage[] = [];
154
+ const targets = [
155
+ ...[...tools.values()].map((t) => ({ id: t.id, modulePath: t.modulePath, check: false })),
156
+ ...[...checks.entries()].map(([id, g]) => ({
157
+ id,
158
+ modulePath: g.checkModulePath,
159
+ check: true,
160
+ })),
161
+ ];
162
+ for (const target of targets) {
163
+ try {
164
+ await importModule(options.resolveModule(target.modulePath));
165
+ } catch (cause) {
166
+ failures.push(
167
+ packError('handler-import-failed', `${target.id}: ${describe(cause)}`, {
168
+ ...(target.check ? { checkId: target.id } : { toolId: target.id }),
169
+ }),
170
+ );
171
+ }
172
+ }
173
+ ready = failures.length === 0;
174
+ return failures;
175
+ }
176
+
177
+ async function drain(graceMs: number): Promise<void> {
178
+ draining = true;
179
+ const deadline = Date.now() + graceMs;
180
+ while (inFlight > 0 && Date.now() < deadline) {
181
+ await new Promise((r) => setTimeout(r, 25));
182
+ }
183
+ }
184
+
185
+ function info(): Record<string, unknown> {
186
+ return {
187
+ protocol: PACK_PROTOCOL_VERSION,
188
+ packId: options.index.packId,
189
+ packVersion: options.index.packVersion,
190
+ artifactVersion: options.index.artifactVersion,
191
+ tools: [...tools.values()].map((t) => ({
192
+ id: t.id,
193
+ ...(t.version && { version: t.version }),
194
+ })),
195
+ checks: [...checks.keys()],
196
+ missingEnv,
197
+ };
198
+ }
199
+
200
+ function callTool(message: ToolInvokeMessage, signal: AbortSignal): Promise<PackResponse> {
201
+ const tool = tools.get(message.tool.id);
202
+ if (tool === undefined) {
203
+ return Promise.resolve(
204
+ packError('tool-not-in-pack', `This pack has no tool "${message.tool.id}"`, {
205
+ toolId: message.tool.id,
206
+ }),
207
+ );
208
+ }
209
+ if (message.tool.version !== undefined && message.tool.version !== tool.version) {
210
+ return Promise.resolve(
211
+ packError(
212
+ 'tool-version-mismatch',
213
+ `Tool "${tool.id}" is ${tool.version ?? 'unversioned'} in this pack; the caller asked for ${message.tool.version}`,
214
+ { toolId: tool.id },
215
+ ),
216
+ );
217
+ }
218
+ return runHandler({
219
+ tool: {
220
+ id: tool.id,
221
+ modulePath: options.resolveModule(tool.modulePath),
222
+ inputSchema: tool.input,
223
+ outputSchema: tool.output,
224
+ },
225
+ input: message.input,
226
+ ctx: { ...message.ctx, abortSignal: signal },
227
+ importHandler: (p) => importModule(p) as never,
228
+ }).then((r) =>
229
+ r.kind === 'ok'
230
+ ? { v: PACK_PROTOCOL_VERSION, kind: 'result', output: r.value }
231
+ : fromHandlerError(r.error, { toolId: tool.id }),
232
+ );
233
+ }
234
+
235
+ function callCheck(message: CheckInvokeMessage, signal: AbortSignal): Promise<PackResponse> {
236
+ const guardrail = checks.get(message.check.id);
237
+ if (guardrail === undefined) {
238
+ return Promise.resolve(
239
+ packError('check-not-in-pack', `This pack has no check "${message.check.id}"`, {
240
+ checkId: message.check.id,
241
+ }),
242
+ );
243
+ }
244
+ return runCheck({
245
+ check: { id: message.check.id, modulePath: options.resolveModule(guardrail.checkModulePath) },
246
+ config: message.config,
247
+ trace: message.trace,
248
+ abortSignal: signal,
249
+ importCheck: (p) => importModule(p) as never,
250
+ }).then((r) =>
251
+ r.kind === 'ok'
252
+ ? { v: PACK_PROTOCOL_VERSION, kind: 'check-result', result: r.value }
253
+ : fromHandlerError(r.error, { checkId: message.check.id }),
254
+ );
255
+ }
256
+
257
+ /** Why a call can't be taken right now, if it can't. */
258
+ function refuseInvoke(req: IncomingMessage): Reply | undefined {
259
+ const unready = notReady();
260
+ if (unready !== undefined) return unready;
261
+ if (inFlight >= maxConcurrency) return unavailable('overloaded');
262
+ if (!(req.headers['content-type'] ?? '').includes('application/json')) {
263
+ return { status: 415, body: { error: 'Content-Type must be application/json' } };
264
+ }
265
+ return undefined;
266
+ }
267
+
268
+ async function invoke(req: IncomingMessage, res: ServerResponse): Promise<void> {
269
+ const refused = refuseInvoke(req);
270
+ if (refused !== undefined) {
271
+ reply(res, refused);
272
+ return;
273
+ }
274
+ inFlight += 1;
275
+ const started = Date.now();
276
+ try {
277
+ const body = await readBody(req, maxBodyBytes);
278
+ const response = body === undefined ? undefined : await dispatch(req, res, body);
279
+ if (res.writableEnded || res.destroyed) return;
280
+ reply(
281
+ res,
282
+ response === undefined
283
+ ? { status: 413, body: { error: 'Request body too large' } }
284
+ : {
285
+ status: 200,
286
+ body: response,
287
+ headers: {
288
+ [PACK_HEADERS.durationMs]: String(Date.now() - started),
289
+ [PACK_HEADERS.artifactVersion]: options.index.artifactVersion,
290
+ },
291
+ },
292
+ );
293
+ } finally {
294
+ inFlight -= 1;
295
+ }
296
+ }
297
+
298
+ async function dispatch(
299
+ req: IncomingMessage,
300
+ res: ServerResponse,
301
+ body: string,
302
+ ): Promise<PackResponse> {
303
+ let json: unknown;
304
+ try {
305
+ json = JSON.parse(body);
306
+ } catch {
307
+ return packError('malformed-message', 'Request body is not valid JSON');
308
+ }
309
+ const parsed = parsePackRequest(json);
310
+ if (parsed.kind === 'err') return parsed.error;
311
+ const message = parsed.value;
312
+ const target = message.kind === 'invoke' ? 'tool' : 'check';
313
+ const id = message.kind === 'invoke' ? message.tool.id : message.check.id;
314
+
315
+ const controller = new AbortController();
316
+ const timeoutMs = parseTimeout(req.headers[PACK_HEADERS.timeoutMs]) ?? defaultTimeoutMs;
317
+ const timer = setTimeout(() => controller.abort('deadline-exceeded'), timeoutMs);
318
+ // The caller gave up (closed the connection) before we answered.
319
+ res.on('close', () => {
320
+ if (!res.writableEnded) controller.abort('cancelled');
321
+ });
322
+
323
+ const started = Date.now();
324
+ const work =
325
+ message.kind === 'invoke'
326
+ ? callTool(message, controller.signal)
327
+ : callCheck(message, controller.signal);
328
+ const outcome = await Promise.race([work, aborted(controller.signal, id, target)]);
329
+ clearTimeout(timer);
330
+ if (controller.signal.aborted) {
331
+ void work.then(() =>
332
+ logger({ kind: 'handler-finished-late', target, id, afterMs: Date.now() - started }),
333
+ );
334
+ }
335
+ logger({
336
+ kind: 'call',
337
+ target,
338
+ id,
339
+ durationMs: Date.now() - started,
340
+ outcome: outcome.kind === 'error' ? outcome.code : 'ok',
341
+ });
342
+ return outcome;
343
+ }
344
+
345
+ /** The transport-level answer for a request before any route runs, if any. */
346
+ function refuseRequest(req: IncomingMessage, route: Route | undefined): Reply | undefined {
347
+ if (route === undefined) return { status: 404, body: { error: `No route ${req.url ?? '/'}` } };
348
+ if (req.method !== route.method) return { status: 405, body: { error: `Use ${route.method}` } };
349
+ if (route.auth && !authorized(req)) return { status: 401, body: { error: 'Bad pack token' } };
350
+ return undefined;
351
+ }
352
+
353
+ function probe(name: Route['name']): Reply {
354
+ if (name === 'healthz') return { status: 200, body: { status: 'ok' } };
355
+ if (name === 'readyz') return notReady() ?? { status: 200, body: { status: 'ready' } };
356
+ return { status: 200, body: info() };
357
+ }
358
+
359
+ function handle(req: IncomingMessage, res: ServerResponse): void {
360
+ const route = ROUTES[(req.url ?? '/').split('?')[0] ?? ''];
361
+ const refused = refuseRequest(req, route);
362
+ if (refused !== undefined || route === undefined) {
363
+ reply(res, refused ?? { status: 404, body: {} });
364
+ return;
365
+ }
366
+ if (route.name !== 'invoke') {
367
+ reply(res, probe(route.name));
368
+ return;
369
+ }
370
+ void invoke(req, res).catch((cause) => {
371
+ if (!res.writableEnded) reply(res, { status: 500, body: { error: describe(cause) } });
372
+ });
373
+ }
374
+
375
+ return {
376
+ handle,
377
+ prewarm,
378
+ drain,
379
+ inFlight: () => inFlight,
380
+ ready: () => notReady() === undefined,
381
+ };
382
+ }
383
+
384
+ interface Route {
385
+ readonly name: 'invoke' | 'info' | 'healthz' | 'readyz';
386
+ readonly method: 'GET' | 'POST';
387
+ readonly auth: boolean;
388
+ }
389
+
390
+ const ROUTES: Readonly<Record<string, Route>> = {
391
+ '/v1/invoke': { name: 'invoke', method: 'POST', auth: true },
392
+ '/v1/info': { name: 'info', method: 'GET', auth: true },
393
+ '/healthz': { name: 'healthz', method: 'GET', auth: false },
394
+ '/readyz': { name: 'readyz', method: 'GET', auth: false },
395
+ };
396
+
397
+ function aborted(
398
+ signal: AbortSignal,
399
+ id: string,
400
+ target: 'tool' | 'check',
401
+ ): Promise<PackErrorMessage> {
402
+ return new Promise((resolve) => {
403
+ const settle = (): void => {
404
+ const reason = signal.reason === 'deadline-exceeded' ? 'deadline-exceeded' : 'cancelled';
405
+ const message =
406
+ reason === 'deadline-exceeded' ? `${id} passed its deadline` : `${id} was cancelled`;
407
+ resolve(packError(reason, message, target === 'tool' ? { toolId: id } : { checkId: id }));
408
+ };
409
+ if (signal.aborted) settle();
410
+ else signal.addEventListener('abort', settle, { once: true });
411
+ });
412
+ }
413
+
414
+ function fromHandlerError(
415
+ error: HandlerError,
416
+ ids: { readonly toolId?: string; readonly checkId?: string },
417
+ ): PackErrorMessage {
418
+ return packError(error.code as PackErrorCode, error.message, {
419
+ ...ids,
420
+ ...(error.cause !== undefined && { cause: error.cause }),
421
+ ...(error.issues !== undefined && { issues: error.issues }),
422
+ });
423
+ }
424
+
425
+ function parseTimeout(raw: string | string[] | undefined): number | undefined {
426
+ if (typeof raw !== 'string') return undefined;
427
+ const ms = Number(raw);
428
+ return Number.isInteger(ms) && ms > 0 ? ms : undefined;
429
+ }
430
+
431
+ /** The body as text, or `undefined` once it exceeds `limit` (the rest is drained). */
432
+ function readBody(req: IncomingMessage, limit: number): Promise<string | undefined> {
433
+ return new Promise((resolve, reject) => {
434
+ const chunks: Buffer[] = [];
435
+ let size = 0;
436
+ let tooLarge = false;
437
+ req.on('data', (chunk: Buffer) => {
438
+ size += chunk.length;
439
+ if (size > limit) tooLarge = true;
440
+ if (!tooLarge) chunks.push(chunk);
441
+ });
442
+ req.on('end', () => resolve(tooLarge ? undefined : Buffer.concat(chunks).toString('utf8')));
443
+ req.on('error', reject);
444
+ });
445
+ }
446
+
447
+ interface Reply {
448
+ readonly status: number;
449
+ readonly body: unknown;
450
+ readonly headers?: Readonly<Record<string, string>>;
451
+ }
452
+
453
+ function unavailable(reason: string, details?: Readonly<Record<string, unknown>>): Reply {
454
+ return { status: 503, body: { error: reason, ...details }, headers: { 'retry-after': '1' } };
455
+ }
456
+
457
+ function reply(res: ServerResponse, r: Reply): void {
458
+ sendJson(res, r.status, r.body, r.headers);
459
+ }
460
+
461
+ function sendJson(
462
+ res: ServerResponse,
463
+ status: number,
464
+ body: unknown,
465
+ headers: Readonly<Record<string, string>> = {},
466
+ ): void {
467
+ const text = JSON.stringify(body);
468
+ res.writeHead(status, {
469
+ 'content-type': 'application/json; charset=utf-8',
470
+ 'content-length': Buffer.byteLength(text),
471
+ ...headers,
472
+ });
473
+ res.end(text);
474
+ }
475
+
476
+ function describe(cause: unknown): string {
477
+ return cause instanceof Error ? cause.message : String(cause);
478
+ }