@kindgi/pack-conformance 0.0.0-bootstrap.0 → 0.1.1

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 (31) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +60 -2
  3. package/dist/index.d.ts +36 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +680 -0
  6. package/dist/index.js.map +1 -0
  7. package/fixtures/node-pack/agents/echo-agent.mjs +13 -0
  8. package/fixtures/node-pack/flows/echo-flow.mjs +12 -0
  9. package/fixtures/node-pack/guardrails/check-throws.mjs +16 -0
  10. package/fixtures/node-pack/guardrails/min-length.mjs +26 -0
  11. package/fixtures/node-pack/kindgi.config.mjs +5 -0
  12. package/fixtures/node-pack/tools/bad-output.mjs +12 -0
  13. package/fixtures/node-pack/tools/context.mjs +19 -0
  14. package/fixtures/node-pack/tools/defaults.mjs +24 -0
  15. package/fixtures/node-pack/tools/echo.mjs +21 -0
  16. package/fixtures/node-pack/tools/noisy.mjs +17 -0
  17. package/fixtures/node-pack/tools/process-env.mjs +16 -0
  18. package/fixtures/node-pack/tools/sleep.mjs +23 -0
  19. package/fixtures/node-pack/tools/throws.mjs +14 -0
  20. package/fixtures/python-pack/agents/echo_agent.py +17 -0
  21. package/fixtures/python-pack/flows/echo_flow.py +16 -0
  22. package/fixtures/python-pack/guardrails/checks.py +31 -0
  23. package/fixtures/python-pack/pyproject.toml +8 -0
  24. package/fixtures/python-pack/tools/context.py +21 -0
  25. package/fixtures/python-pack/tools/defaults.py +26 -0
  26. package/fixtures/python-pack/tools/echo.py +21 -0
  27. package/fixtures/python-pack/tools/failing.py +18 -0
  28. package/fixtures/python-pack/tools/noisy.py +16 -0
  29. package/fixtures/python-pack/tools/process_env.py +13 -0
  30. package/fixtures/python-pack/tools/sleep.py +23 -0
  31. package/package.json +47 -4
package/dist/index.js ADDED
@@ -0,0 +1,680 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+ /**
4
+ * The pack-service conformance suite — what any language's pack service and
5
+ * indexer must do, checked from the outside: the index it writes
6
+ * (`pack-index.schema.json`), the process contract (arguments, environment,
7
+ * the `listening` / `boot-failed` lines, SIGTERM drain) and every route,
8
+ * status and message of pack protocol v2 (`pack-protocol.schema.json`).
9
+ *
10
+ * A target names how to index the fixture pack (`FIXTURE.md`) and how to
11
+ * start its service; the suite does the rest:
12
+ *
13
+ * describePackServiceConformance({
14
+ * name: 'python',
15
+ * packDir: fixturePackDir('python-pack'),
16
+ * buildIndex: (out, pins) => …,
17
+ * command: [python, '-m', 'kindgi.pack', 'serve'],
18
+ * });
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { readFileSync } from 'node:fs';
23
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
24
+ import { createRequire } from 'node:module';
25
+ import { tmpdir } from 'node:os';
26
+ import { dirname, join } from 'node:path';
27
+ import { createInterface } from 'node:readline';
28
+ import { fileURLToPath } from 'node:url';
29
+ import * as addFormatsModule from 'ajv-formats';
30
+ import { Ajv2020 } from 'ajv/dist/2020.js';
31
+ import { afterAll, beforeAll, describe, expect, test } from 'vitest';
32
+ /** Root of a fixture pack shipped with this package (`node-pack`, `python-pack`). */
33
+ export function fixturePackDir(name) {
34
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', name);
35
+ }
36
+ export const FIXTURE_PACK_ID = 'conformance';
37
+ export const FIXTURE_TOOL_IDS = [
38
+ 'conformance.bad-output',
39
+ 'conformance.context',
40
+ 'conformance.defaults',
41
+ 'conformance.echo',
42
+ 'conformance.noisy',
43
+ 'conformance.process-env',
44
+ 'conformance.sleep',
45
+ 'conformance.throws',
46
+ ];
47
+ export const FIXTURE_CHECK_IDS = [
48
+ 'conformance.checks.min-length',
49
+ 'conformance.checks.throws',
50
+ ];
51
+ const PINS = { artifactVersion: '20261001.7', publishedAt: '2026-10-01T12:00:00.000Z' };
52
+ const PROTOCOL_ID = 'https://kindgi.com/schemas/v1/pack-protocol.schema.json';
53
+ const CTX = { tenantId: 't-conformance', runId: 'run-conformance' };
54
+ // ---------------------------------------------------------------------------
55
+ // Spec validators
56
+ // ---------------------------------------------------------------------------
57
+ const require = createRequire(import.meta.url);
58
+ const addFormats = (addFormatsModule.default ??
59
+ addFormatsModule);
60
+ function specValidators() {
61
+ const ajv = new Ajv2020({ strict: true, allErrors: true, allowUnionTypes: false });
62
+ addFormats(ajv);
63
+ const load = (name) => JSON.parse(readFileSync(require.resolve(`@kindgi/specs/${name}.schema.json`), 'utf8'));
64
+ ajv.addSchema(load('pack-protocol'));
65
+ const index = ajv.compile(load('pack-index'));
66
+ const get = (fragment) => {
67
+ const fn = ajv.getSchema(`${PROTOCOL_ID}#/$defs/${fragment}`);
68
+ if (fn === undefined)
69
+ throw new Error(`pack-protocol.schema.json has no $defs/${fragment}`);
70
+ return fn;
71
+ };
72
+ const loose = new Ajv2020({ strict: false, allErrors: true });
73
+ addFormats(loose);
74
+ return {
75
+ index,
76
+ response: get('response'),
77
+ info: get('info'),
78
+ compile: (schema) => loose.compile(schema),
79
+ };
80
+ }
81
+ function expectValid(validate, value) {
82
+ expect(validate(value) ? [] : validate.errors).toEqual([]);
83
+ }
84
+ function serviceArgs(target, indexPath) {
85
+ const [, ...rest] = target.command;
86
+ return [...rest, '--index', indexPath, '--module-root', target.packDir, '--host', '127.0.0.1'];
87
+ }
88
+ function serviceEnv(target, extra) {
89
+ const env = { PORT: '0' };
90
+ if (process.env.PATH !== undefined)
91
+ env.PATH = process.env.PATH;
92
+ return { ...env, ...target.env, ...extra };
93
+ }
94
+ function spawnService(target, indexPath, env) {
95
+ const command = target.command[0];
96
+ if (command === undefined)
97
+ throw new Error(`${target.name}: empty command`);
98
+ const child = spawn(command, serviceArgs(target, indexPath), {
99
+ env: serviceEnv(target, env),
100
+ stdio: ['ignore', 'pipe', 'pipe'],
101
+ });
102
+ const events = [];
103
+ const stdout = [];
104
+ const stderr = [];
105
+ if (child.stdout !== null) {
106
+ createInterface({ input: child.stdout }).on('line', (line) => stdout.push(line));
107
+ }
108
+ if (child.stderr !== null) {
109
+ createInterface({ input: child.stderr }).on('line', (line) => {
110
+ stderr.push(line);
111
+ const event = parseEvent(line);
112
+ if (event !== undefined)
113
+ events.push(event);
114
+ });
115
+ }
116
+ return { child, events, stdout, stderr };
117
+ }
118
+ function parseEvent(line) {
119
+ try {
120
+ const value = JSON.parse(line);
121
+ return typeof value === 'object' &&
122
+ value !== null &&
123
+ typeof value.kind === 'string'
124
+ ? value
125
+ : undefined;
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ function exitOf(child) {
132
+ return new Promise((resolve) => {
133
+ if (child.exitCode !== null || child.signalCode !== null) {
134
+ resolve({ code: child.exitCode, signal: child.signalCode });
135
+ return;
136
+ }
137
+ child.once('exit', (code, signal) => resolve({ code, signal }));
138
+ });
139
+ }
140
+ async function startService(target, indexPath, extraEnv = {}) {
141
+ const token = randomBytes(18).toString('base64url');
142
+ const { child, events, stdout, stderr } = spawnService(target, indexPath, {
143
+ KINDGI_PACK_SERVICE_TOKEN: token,
144
+ ...extraEnv,
145
+ });
146
+ const exited = exitOf(child);
147
+ const port = await new Promise((resolve, reject) => {
148
+ const timer = setInterval(() => {
149
+ const listening = events.find((e) => e.kind === 'listening');
150
+ if (listening !== undefined && typeof listening.port === 'number') {
151
+ clearInterval(timer);
152
+ resolve(listening.port);
153
+ }
154
+ }, 20);
155
+ void exited.then(({ code, signal }) => {
156
+ clearInterval(timer);
157
+ reject(new Error(`${target.name}: the service exited (${signal ?? code}) before listening:\n${stderr.join('\n')}`));
158
+ });
159
+ });
160
+ return {
161
+ url: `http://127.0.0.1:${port}`,
162
+ token,
163
+ events,
164
+ stdout,
165
+ exited,
166
+ async terminate() {
167
+ if (child.exitCode === null && child.signalCode === null)
168
+ child.kill('SIGTERM');
169
+ return exited;
170
+ },
171
+ };
172
+ }
173
+ async function runToExit(target, indexPath, env) {
174
+ const { child, events } = spawnService(target, indexPath, env);
175
+ const { code } = await exitOf(child);
176
+ // Let the stderr reader deliver the last line.
177
+ await new Promise((r) => setTimeout(r, 50));
178
+ return { code, events };
179
+ }
180
+ async function waitFor(probe, timeoutMs, what) {
181
+ const deadline = Date.now() + timeoutMs;
182
+ for (;;) {
183
+ const value = probe();
184
+ if (value !== undefined)
185
+ return value;
186
+ if (Date.now() > deadline)
187
+ throw new Error(`timed out waiting for ${what}`);
188
+ await new Promise((r) => setTimeout(r, 25));
189
+ }
190
+ }
191
+ async function call(service, path, init = {}) {
192
+ const headers = { ...init.headers };
193
+ const token = init.token === undefined ? service.token : init.token;
194
+ if (token !== null)
195
+ headers['kindgi-pack-token'] = token;
196
+ const response = await fetch(`${service.url}${path}`, {
197
+ method: init.method ?? 'GET',
198
+ headers,
199
+ ...(init.body !== undefined && { body: init.body }),
200
+ ...(init.signal !== undefined && { signal: init.signal }),
201
+ });
202
+ const text = await response.text();
203
+ let json = {};
204
+ try {
205
+ json = JSON.parse(text);
206
+ }
207
+ catch {
208
+ json = { unparsed: text };
209
+ }
210
+ return { status: response.status, headers: response.headers, json };
211
+ }
212
+ function invoke(service, message, headers = {}, signal) {
213
+ return call(service, '/v1/invoke', {
214
+ method: 'POST',
215
+ body: typeof message === 'string' ? message : JSON.stringify(message),
216
+ headers: { 'content-type': 'application/json', ...headers },
217
+ ...(signal !== undefined && { signal }),
218
+ });
219
+ }
220
+ function toolCall(id, input, extra = {}) {
221
+ return { v: 2, kind: 'invoke', tool: { id }, input, ctx: CTX, ...extra };
222
+ }
223
+ function checkCall(id, config, output) {
224
+ return {
225
+ v: 2,
226
+ kind: 'check-invoke',
227
+ check: { id },
228
+ config,
229
+ trace: { ...CTX, output, toolCalls: [], toolResults: [], modelCalls: [], mode: 'runtime' },
230
+ };
231
+ }
232
+ function canonical(value) {
233
+ const sort = (v) => {
234
+ if (Array.isArray(v))
235
+ return v.map(sort);
236
+ if (v === null || typeof v !== 'object')
237
+ return v;
238
+ const rec = v;
239
+ return Object.fromEntries(Object.keys(rec)
240
+ .sort()
241
+ .map((k) => [k, sort(rec[k])]));
242
+ };
243
+ return `${JSON.stringify(sort(value), null, 2)}\n`;
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // The suite
247
+ // ---------------------------------------------------------------------------
248
+ /** Register the conformance suite for one target (vitest `describe` blocks). */
249
+ export function describePackServiceConformance(target) {
250
+ const spec = specValidators();
251
+ describe(`pack service conformance — ${target.name}`, () => {
252
+ let workDir = '';
253
+ let indexPath = '';
254
+ let indexText = '';
255
+ let index;
256
+ let service;
257
+ const response = (answer) => {
258
+ expect(answer.status).toBe(200);
259
+ expectValid(spec.response, answer.json);
260
+ return answer.json;
261
+ };
262
+ beforeAll(async () => {
263
+ workDir = await mkdtemp(join(tmpdir(), 'kindgi-conformance-'));
264
+ indexPath = join(workDir, 'index.json');
265
+ await target.buildIndex(indexPath, PINS);
266
+ indexText = await readFile(indexPath, 'utf8');
267
+ index = JSON.parse(indexText);
268
+ service = await startService(target, indexPath);
269
+ });
270
+ afterAll(async () => {
271
+ await service?.terminate();
272
+ if (workDir !== '')
273
+ await rm(workDir, { recursive: true, force: true });
274
+ });
275
+ describe('index', () => {
276
+ test('matches pack-index.schema.json', () => {
277
+ expectValid(spec.index, index);
278
+ });
279
+ test('lists the fixture pack, every list sorted by id', () => {
280
+ expect(index.packId).toBe(FIXTURE_PACK_ID);
281
+ expect(index.packVersion).toBe('1.0.0');
282
+ expect(index.artifactVersion).toBe(PINS.artifactVersion);
283
+ expect(index.publishedAt).toBe(PINS.publishedAt);
284
+ expect(index.tools.map((t) => t.id)).toEqual([...FIXTURE_TOOL_IDS]);
285
+ expect(index.tools.every((t) => t.version === '1.0.0')).toBe(true);
286
+ expect(index.guardrails.map((g) => g.checkId ?? g.id).sort()).toEqual([
287
+ ...FIXTURE_CHECK_IDS,
288
+ ]);
289
+ expect(index.guardrails.map((g) => g.id)).toEqual([
290
+ 'conformance.check-throws',
291
+ 'conformance.min-length',
292
+ ]);
293
+ expect(index.agents.map((a) => a.id)).toEqual(['conformance.echo-agent']);
294
+ expect(index.agents[0]?.tools).toEqual([{ id: 'conformance.echo', version: '1.0.0' }]);
295
+ expect(index.flows.map((f) => f.id)).toEqual(['conformance.echo-flow']);
296
+ expect(index.flows[0]?.kernelPayloadVersion).toBe(1);
297
+ });
298
+ test('is canonical: keys sorted, two-space indent, trailing newline', () => {
299
+ expect(indexText).toBe(canonical(index));
300
+ });
301
+ test('the same source indexes to the same bytes', async () => {
302
+ const again = join(workDir, 'index-again.json');
303
+ await target.buildIndex(again, PINS);
304
+ expect(await readFile(again, 'utf8')).toBe(indexText);
305
+ });
306
+ test("a tool's schemas accept and reject what the fixture says", () => {
307
+ const echo = index.tools.find((t) => t.id === 'conformance.echo');
308
+ const input = spec.compile(echo?.input);
309
+ expect(input({ message: 'hi' })).toBe(true);
310
+ expect(input({})).toBe(false);
311
+ expect(input({ message: '' })).toBe(false);
312
+ expect(input({ message: 'hi', extra: 1 })).toBe(false);
313
+ const minLength = index.guardrails.find((g) => g.id === 'conformance.min-length');
314
+ const config = spec.compile(minLength?.configSchema);
315
+ expect(config({ minLength: 2 })).toBe(true);
316
+ expect(config({ minLength: -1 })).toBe(false);
317
+ });
318
+ });
319
+ describe('process', () => {
320
+ test('writes a listening line with its port and pack', () => {
321
+ const listening = service.events.find((e) => e.kind === 'listening');
322
+ expect(listening).toMatchObject({ kind: 'listening', packId: FIXTURE_PACK_ID });
323
+ expect(typeof listening?.port).toBe('number');
324
+ });
325
+ test('without a token: exit 1 with a config-invalid line', async () => {
326
+ const { code, events } = await runToExit(target, indexPath, {
327
+ KINDGI_PACK_SERVICE_TOKEN: '',
328
+ });
329
+ expect(code).toBe(1);
330
+ expect(events.map((e) => e.kind)).toContain('config-invalid');
331
+ });
332
+ test('a module the index names is missing: exit 1 with a boot-failed line', async () => {
333
+ const broken = JSON.parse(indexText);
334
+ const first = broken.tools[0];
335
+ const ext = String(first.modulePath).split('.').pop();
336
+ first.modulePath = `tools/does-not-exist.${ext}`;
337
+ const brokenPath = join(workDir, 'broken-index.json');
338
+ await writeFile(brokenPath, JSON.stringify(broken), 'utf8');
339
+ const { code, events } = await runToExit(target, brokenPath, {
340
+ KINDGI_PACK_SERVICE_TOKEN: 'x',
341
+ });
342
+ expect(code).toBe(1);
343
+ const failed = events.find((e) => e.kind === 'boot-failed');
344
+ expect(failed?.problems).toContain(`Missing module: tools/does-not-exist.${ext}`);
345
+ });
346
+ test('an unreadable index: exit 1 with a boot-failed line', async () => {
347
+ const { code, events } = await runToExit(target, join(workDir, 'nope.json'), {
348
+ KINDGI_PACK_SERVICE_TOKEN: 'x',
349
+ });
350
+ expect(code).toBe(1);
351
+ const failed = events.find((e) => e.kind === 'boot-failed');
352
+ expect(String(failed?.problems?.[0])).toMatch(/^Cannot read the pack index/);
353
+ });
354
+ test('SIGTERM: in-flight calls finish, readyz and new calls answer 503, exit 0', async () => {
355
+ const draining = await startService(target, indexPath);
356
+ const slow = invoke(draining, toolCall('conformance.sleep', { ms: 800 }));
357
+ await new Promise((r) => setTimeout(r, 200));
358
+ const exited = draining.terminate();
359
+ await waitFor(() => draining.events.find((e) => e.kind === 'draining'), 5_000, 'draining');
360
+ const ready = await call(draining, '/readyz', { token: null });
361
+ expect(ready.status).toBe(503);
362
+ expect(ready.headers.get('retry-after')).not.toBeNull();
363
+ const refused = await invoke(draining, toolCall('conformance.echo', { message: 'late' }));
364
+ expect(refused.status).toBe(503);
365
+ expect(response(await slow).output).toEqual({ slept: 800 });
366
+ const { code } = await exited;
367
+ expect(code).toBe(0);
368
+ expect(draining.events.map((e) => e.kind)).toContain('stopped');
369
+ });
370
+ test('at the concurrency cap: 503 with Retry-After (the call did not run)', async () => {
371
+ const capped = await startService(target, indexPath, {
372
+ KINDGI_PACK_SERVICE_MAX_CONCURRENCY: '1',
373
+ });
374
+ try {
375
+ const busy = invoke(capped, toolCall('conformance.sleep', { ms: 500 }));
376
+ await new Promise((r) => setTimeout(r, 150));
377
+ const second = await invoke(capped, toolCall('conformance.echo', { message: 'hi' }));
378
+ expect(second.status).toBe(503);
379
+ expect(second.headers.get('retry-after')).not.toBeNull();
380
+ expect(response(await busy).kind).toBe('result');
381
+ }
382
+ finally {
383
+ await capped.terminate();
384
+ }
385
+ });
386
+ });
387
+ describe.skipIf(target.unsupported?.includes('pack-env') === true)('declared env', () => {
388
+ const DECLARED = {
389
+ optional: ['CONFORMANCE_OPTIONAL'],
390
+ required: ['CONFORMANCE_A', 'CONFORMANCE_B'],
391
+ };
392
+ /** The fixture index with `env` declared, written beside it. */
393
+ async function envIndexPath() {
394
+ const declared = { ...JSON.parse(indexText), env: DECLARED };
395
+ expectValid(spec.index, declared);
396
+ const path = join(workDir, 'env-index.json');
397
+ await writeFile(path, JSON.stringify(declared), 'utf8');
398
+ return path;
399
+ }
400
+ test('strict: a required name unset or empty keeps readyz and calls at 503, naming it', async () => {
401
+ const strict = await startService(target, await envIndexPath(), { CONFORMANCE_A: '' });
402
+ try {
403
+ const health = await call(strict, '/healthz', { token: null });
404
+ expect(health.status).toBe(200);
405
+ const ready = await call(strict, '/readyz', { token: null });
406
+ expect([ready.status, ready.json]).toEqual([
407
+ 503,
408
+ { error: 'missing env', missingEnv: ['CONFORMANCE_A', 'CONFORMANCE_B'] },
409
+ ]);
410
+ expect(ready.headers.get('retry-after')).not.toBeNull();
411
+ const refused = await invoke(strict, toolCall('conformance.echo', { message: 'hi' }));
412
+ expect([refused.status, refused.json]).toEqual([
413
+ 503,
414
+ { error: 'missing env', missingEnv: ['CONFORMANCE_A', 'CONFORMANCE_B'] },
415
+ ]);
416
+ const info = await call(strict, '/v1/info');
417
+ expectValid(spec.info, info.json);
418
+ expect(info.json.missingEnv).toEqual(['CONFORMANCE_A', 'CONFORMANCE_B']);
419
+ }
420
+ finally {
421
+ await strict.terminate();
422
+ }
423
+ });
424
+ test('warn: it serves, and /v1/info still names what is missing', async () => {
425
+ const warn = await startService(target, await envIndexPath(), {
426
+ KINDGI_PACK_ENV_CHECK: 'warn',
427
+ CONFORMANCE_A: 'set',
428
+ });
429
+ try {
430
+ const ready = await call(warn, '/readyz', { token: null });
431
+ expect([ready.status, ready.json]).toEqual([200, { status: 'ready' }]);
432
+ expect(response(await invoke(warn, toolCall('conformance.echo', { message: 'hi' }))).kind).toBe('result');
433
+ expect((await call(warn, '/v1/info')).json.missingEnv).toEqual(['CONFORMANCE_B']);
434
+ }
435
+ finally {
436
+ await warn.terminate();
437
+ }
438
+ });
439
+ test('every required name set (optional ones need not be): ready, nothing missing', async () => {
440
+ const full = await startService(target, await envIndexPath(), {
441
+ CONFORMANCE_A: 'a',
442
+ CONFORMANCE_B: ' ',
443
+ });
444
+ try {
445
+ expect((await call(full, '/readyz', { token: null })).status).toBe(200);
446
+ expect((await call(full, '/v1/info')).json.missingEnv).toEqual([]);
447
+ }
448
+ finally {
449
+ await full.terminate();
450
+ }
451
+ });
452
+ test('an index without env: /v1/info lists nothing missing', async () => {
453
+ expect((await call(service, '/v1/info')).json.missingEnv).toEqual([]);
454
+ });
455
+ test('KINDGI_PACK_ENV_CHECK other than strict or warn: exit 1 with a config-invalid line', async () => {
456
+ const { code, events } = await runToExit(target, indexPath, {
457
+ KINDGI_PACK_SERVICE_TOKEN: 'x',
458
+ KINDGI_PACK_ENV_CHECK: 'loose',
459
+ });
460
+ expect(code).toBe(1);
461
+ expect(events.map((e) => e.kind)).toContain('config-invalid');
462
+ });
463
+ });
464
+ describe('routes', () => {
465
+ test('GET /healthz and /readyz need no token', async () => {
466
+ const health = await call(service, '/healthz', { token: null });
467
+ expect([health.status, health.json]).toEqual([200, { status: 'ok' }]);
468
+ const ready = await call(service, '/readyz', { token: null });
469
+ expect([ready.status, ready.json]).toEqual([200, { status: 'ready' }]);
470
+ });
471
+ test('GET /v1/info lists the pack', async () => {
472
+ const info = await call(service, '/v1/info');
473
+ expect(info.status).toBe(200);
474
+ expectValid(spec.info, info.json);
475
+ expect(info.json).toMatchObject({
476
+ protocol: 2,
477
+ packId: FIXTURE_PACK_ID,
478
+ artifactVersion: PINS.artifactVersion,
479
+ });
480
+ expect(info.json.tools).toContainEqual({ id: 'conformance.echo', version: '1.0.0' });
481
+ expect([...info.json.checks].sort()).toEqual([...FIXTURE_CHECK_IDS]);
482
+ });
483
+ test('401 without the token, or with a wrong one', async () => {
484
+ expect((await call(service, '/v1/info', { token: null })).status).toBe(401);
485
+ expect((await call(service, '/v1/info', { token: 'wrong' })).status).toBe(401);
486
+ const message = JSON.stringify(toolCall('conformance.echo', { message: 'hi' }));
487
+ const noToken = await call(service, '/v1/invoke', {
488
+ method: 'POST',
489
+ body: message,
490
+ headers: { 'content-type': 'application/json' },
491
+ token: null,
492
+ });
493
+ expect(noToken.status).toBe(401);
494
+ });
495
+ test('404 for an unknown route, 405 for the wrong method', async () => {
496
+ expect((await call(service, '/v1/nope')).status).toBe(404);
497
+ expect((await call(service, '/v1/invoke')).status).toBe(405);
498
+ });
499
+ test('415 when the body is not application/json', async () => {
500
+ const answer = await call(service, '/v1/invoke', {
501
+ method: 'POST',
502
+ body: JSON.stringify(toolCall('conformance.echo', { message: 'hi' })),
503
+ headers: { 'content-type': 'text/plain' },
504
+ });
505
+ expect(answer.status).toBe(415);
506
+ });
507
+ test('413 when the body is over 10 MiB', async () => {
508
+ const big = JSON.stringify(toolCall('conformance.echo', { message: 'x'.repeat(10 * 1024 * 1024) }));
509
+ expect((await invoke(service, big)).status).toBe(413);
510
+ });
511
+ });
512
+ describe('tools', () => {
513
+ test('a call returns the output, with duration and artifact-version headers', async () => {
514
+ const answer = await invoke(service, toolCall('conformance.echo', { message: 'hi' }));
515
+ expect(response(answer)).toEqual({ v: 2, kind: 'result', output: { message: 'hi' } });
516
+ expect(Number(answer.headers.get('kindgi-duration-ms'))).toBeGreaterThanOrEqual(0);
517
+ expect(answer.headers.get('kindgi-artifact-version')).toBe(PINS.artifactVersion);
518
+ });
519
+ test('a call is logged as a JSON line on stderr', async () => {
520
+ await invoke(service, toolCall('conformance.echo', { message: 'logged' }));
521
+ const logged = await waitFor(() => service.events.find((e) => e.kind === 'call' && e.id === 'conformance.echo' && e.outcome === 'ok'), 3_000, 'the call line');
522
+ expect(logged).toMatchObject({ target: 'tool' });
523
+ expect(typeof logged.durationMs).toBe('number');
524
+ });
525
+ test('a named version must match the indexed one', async () => {
526
+ const ok = await invoke(service, toolCall('conformance.echo', { message: 'hi' }, { tool: { id: 'conformance.echo', version: '1.0.0' } }));
527
+ expect(response(ok).kind).toBe('result');
528
+ const wrong = response(await invoke(service, toolCall('conformance.echo', { message: 'hi' }, { tool: { id: 'conformance.echo', version: '9.9.9' } })));
529
+ expect(wrong).toMatchObject({
530
+ kind: 'error',
531
+ code: 'tool-version-mismatch',
532
+ toolId: 'conformance.echo',
533
+ });
534
+ });
535
+ test('an unknown tool: tool-not-in-pack', async () => {
536
+ const answer = response(await invoke(service, toolCall('conformance.nope', {})));
537
+ expect(answer).toMatchObject({
538
+ kind: 'error',
539
+ code: 'tool-not-in-pack',
540
+ toolId: 'conformance.nope',
541
+ });
542
+ });
543
+ test('invalid input: input-validation-failed with Ajv-shaped issues', async () => {
544
+ const missing = response(await invoke(service, toolCall('conformance.echo', {})));
545
+ expect(missing).toMatchObject({
546
+ kind: 'error',
547
+ code: 'input-validation-failed',
548
+ toolId: 'conformance.echo',
549
+ });
550
+ expect(missing.issues).toContainEqual(expect.objectContaining({
551
+ instancePath: '',
552
+ keyword: 'required',
553
+ params: { missingProperty: 'message' },
554
+ }));
555
+ const extra = response(await invoke(service, toolCall('conformance.echo', { message: 'hi', extra: 1 })));
556
+ expect(extra.issues).toContainEqual(expect.objectContaining({
557
+ instancePath: '',
558
+ keyword: 'additionalProperties',
559
+ params: { additionalProperty: 'extra' },
560
+ }));
561
+ const wrongType = response(await invoke(service, toolCall('conformance.echo', { message: 7 })));
562
+ expect(wrongType.issues).toContainEqual(expect.objectContaining({ instancePath: '/message', keyword: 'type' }));
563
+ });
564
+ test('output that breaks the output schema: output-validation-failed', async () => {
565
+ const answer = response(await invoke(service, toolCall('conformance.bad-output', {})));
566
+ expect(answer).toMatchObject({
567
+ kind: 'error',
568
+ code: 'output-validation-failed',
569
+ toolId: 'conformance.bad-output',
570
+ });
571
+ expect(answer.issues).toContainEqual(expect.objectContaining({ instancePath: '/message', keyword: 'type' }));
572
+ });
573
+ test('a throw: handler-throw with the cause', async () => {
574
+ const answer = response(await invoke(service, toolCall('conformance.throws', {})));
575
+ expect(answer).toMatchObject({
576
+ kind: 'error',
577
+ code: 'handler-throw',
578
+ toolId: 'conformance.throws',
579
+ });
580
+ expect(String(answer.message)).toContain('boom');
581
+ expect(String(answer.cause.message)).toContain('boom');
582
+ });
583
+ test("the handler receives its input with the schema's defaults filled in", async () => {
584
+ const filled = response(await invoke(service, toolCall('conformance.defaults', { name: 'Ada' })));
585
+ expect(filled.output).toEqual({ name: 'Ada', greeting: 'Hello', options: { loud: false } });
586
+ const given = response(await invoke(service, toolCall('conformance.defaults', { name: 'Ada', greeting: 'Hi', options: {} })));
587
+ expect(given.output).toEqual({ name: 'Ada', greeting: 'Hi', options: { loud: false } });
588
+ });
589
+ test('the handler receives the call context', async () => {
590
+ const ctx = {
591
+ ...CTX,
592
+ requestId: 'req-1',
593
+ env: { REGION: 'eu' },
594
+ secrets: { API_KEY: 's3cret' },
595
+ config: { mode: 'fast' },
596
+ };
597
+ const answer = response(await invoke(service, toolCall('conformance.context', {}, { ctx })));
598
+ expect(answer.output).toEqual(ctx);
599
+ });
600
+ test("pack code doesn't see the service token", async () => {
601
+ // The token authenticates the service's callers. A dependency of
602
+ // the pack's code that could read it could call the pack's tools
603
+ // around the runtime: the service takes it out of its process
604
+ // environment before it loads the pack's code.
605
+ const answer = response(await invoke(service, toolCall('conformance.process-env', {})));
606
+ expect(answer.output).toMatchObject({ names: expect.any(Array) });
607
+ expect(answer.output.names).not.toContain('KINDGI_PACK_SERVICE_TOKEN');
608
+ });
609
+ test('what a handler prints never reaches the response', async () => {
610
+ const answer = response(await invoke(service, toolCall('conformance.noisy', {})));
611
+ expect(answer.output).toEqual({ ok: true });
612
+ await waitFor(() => service.stdout.find((l) => l.includes('noisy: a line on stdout')), 3_000, 'stdout');
613
+ });
614
+ test('malformed requests are answered with error messages', async () => {
615
+ expect(response(await invoke(service, 'not json'))).toMatchObject({
616
+ code: 'malformed-message',
617
+ });
618
+ expect(response(await invoke(service, { ...toolCall('conformance.echo', {}), v: 1 }))).toMatchObject({
619
+ code: 'unknown-protocol-version',
620
+ });
621
+ expect(response(await invoke(service, { ...toolCall('conformance.echo', {}), kind: 'bogus' }))).toMatchObject({
622
+ code: 'unexpected-message-kind',
623
+ });
624
+ expect(response(await invoke(service, toolCall('conformance.echo', {}, { ctx: { tenantId: 't' } })))).toMatchObject({ code: 'malformed-message' });
625
+ expect(response(await invoke(service, toolCall('conformance.echo', {}, { tool: {} })))).toMatchObject({
626
+ code: 'malformed-message',
627
+ });
628
+ });
629
+ test('past kindgi-timeout-ms: deadline-exceeded, promptly', async () => {
630
+ const started = Date.now();
631
+ const answer = response(await invoke(service, toolCall('conformance.sleep', { ms: 5_000 }), {
632
+ 'kindgi-timeout-ms': '300',
633
+ }));
634
+ expect(answer).toMatchObject({
635
+ kind: 'error',
636
+ code: 'deadline-exceeded',
637
+ toolId: 'conformance.sleep',
638
+ });
639
+ expect(Date.now() - started).toBeLessThan(3_000);
640
+ });
641
+ test('a caller that disconnects: the call ends as cancelled', async () => {
642
+ const controller = new AbortController();
643
+ const pending = invoke(service, toolCall('conformance.sleep', { ms: 5_000 }), {}, controller.signal).catch(() => undefined);
644
+ await new Promise((r) => setTimeout(r, 200));
645
+ controller.abort();
646
+ await pending;
647
+ await waitFor(() => service.events.find((e) => e.kind === 'call' && e.id === 'conformance.sleep' && e.outcome === 'cancelled'), 3_000, 'a cancelled call line');
648
+ });
649
+ });
650
+ describe('checks', () => {
651
+ test('a passing and a failing verdict', async () => {
652
+ const passed = response(await invoke(service, checkCall('conformance.checks.min-length', { minLength: 2 }, 'hello')));
653
+ expect(passed).toMatchObject({ kind: 'check-result', result: { passed: true } });
654
+ const failed = response(await invoke(service, checkCall('conformance.checks.min-length', { minLength: 3 }, 'a')));
655
+ expect(failed).toMatchObject({
656
+ kind: 'check-result',
657
+ result: { passed: false, reason: 'too short' },
658
+ });
659
+ });
660
+ test('an unknown check: check-not-in-pack', async () => {
661
+ const answer = response(await invoke(service, checkCall('conformance.checks.nope', {}, 'x')));
662
+ expect(answer).toMatchObject({
663
+ kind: 'error',
664
+ code: 'check-not-in-pack',
665
+ checkId: 'conformance.checks.nope',
666
+ });
667
+ });
668
+ test('a check that throws: handler-throw', async () => {
669
+ const answer = response(await invoke(service, checkCall('conformance.checks.throws', {}, 'x')));
670
+ expect(answer).toMatchObject({
671
+ kind: 'error',
672
+ code: 'handler-throw',
673
+ checkId: 'conformance.checks.throws',
674
+ });
675
+ expect(String(answer.message)).toContain('check boom');
676
+ });
677
+ });
678
+ });
679
+ }
680
+ //# sourceMappingURL=index.js.map