@objectstack/runtime 4.0.2 → 4.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack Core Runtime & Query Engine",
6
6
  "type": "module",
@@ -15,14 +15,14 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "zod": "^4.3.6",
18
- "@objectstack/core": "4.0.2",
19
- "@objectstack/rest": "4.0.2",
20
- "@objectstack/spec": "4.0.2",
21
- "@objectstack/types": "4.0.2"
18
+ "@objectstack/core": "4.0.3",
19
+ "@objectstack/types": "4.0.3",
20
+ "@objectstack/spec": "4.0.3",
21
+ "@objectstack/rest": "4.0.3"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^6.0.2",
25
- "vitest": "^4.1.2"
25
+ "vitest": "^4.1.4"
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsup --config ../../tsup.config.ts",
@@ -22,6 +22,75 @@ interface RouteDefinition {
22
22
  handler: (req: any) => Promise<any>;
23
23
  }
24
24
 
25
+ /**
26
+ * Register a single RouteDefinition on the HTTP server.
27
+ * Returns true if the route was successfully registered.
28
+ */
29
+ function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean {
30
+ const handler = async (req: any, res: any) => {
31
+ try {
32
+ const result = await route.handler({
33
+ body: req.body,
34
+ params: req.params,
35
+ query: req.query,
36
+ });
37
+
38
+ if (result.stream && result.events) {
39
+ // SSE streaming response
40
+ res.status(result.status);
41
+
42
+ // Apply headers from the route result if available
43
+ if (result.headers) {
44
+ for (const [k, v] of Object.entries(result.headers)) {
45
+ res.header(k, String(v));
46
+ }
47
+ } else {
48
+ res.header('Content-Type', 'text/event-stream');
49
+ res.header('Cache-Control', 'no-cache');
50
+ res.header('Connection', 'keep-alive');
51
+ }
52
+
53
+ // Write the stream — events are pre-encoded SSE strings
54
+ if (typeof res.write === 'function' && typeof res.end === 'function') {
55
+ for await (const event of result.events) {
56
+ res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
57
+ }
58
+ res.end();
59
+ } else {
60
+ // Fallback: collect events into array
61
+ const events = [];
62
+ for await (const event of result.events) {
63
+ events.push(event);
64
+ }
65
+ res.json({ events });
66
+ }
67
+ } else {
68
+ res.status(result.status);
69
+ if (result.body !== undefined) {
70
+ res.json(result.body);
71
+ } else {
72
+ res.end();
73
+ }
74
+ }
75
+ } catch (err: any) {
76
+ errorResponse(err, res);
77
+ }
78
+ };
79
+
80
+ const m = route.method.toLowerCase();
81
+ if (m === 'get' && typeof server.get === 'function') {
82
+ server.get(routePath, handler);
83
+ return true;
84
+ } else if (m === 'post' && typeof server.post === 'function') {
85
+ server.post(routePath, handler);
86
+ return true;
87
+ } else if (m === 'delete' && typeof server.delete === 'function') {
88
+ server.delete(routePath, handler);
89
+ return true;
90
+ }
91
+ return false;
92
+ }
93
+
25
94
  /**
26
95
  * Send an HttpDispatcherResult through IHttpResponse.
27
96
  * Differentiates between handled, unhandled (404), and special results.
@@ -402,68 +471,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
402
471
  const routePath = route.path.startsWith('/api/v1')
403
472
  ? route.path
404
473
  : `${prefix}${route.path}`;
405
-
406
- const handler = async (req: any, res: any) => {
407
- try {
408
- const result = await route.handler({
409
- body: req.body,
410
- params: req.params,
411
- query: req.query,
412
- });
413
-
414
- if (result.stream && result.events) {
415
- // SSE streaming response
416
- res.status(result.status);
417
-
418
- // Apply headers from the route result if available
419
- if (result.headers) {
420
- for (const [k, v] of Object.entries(result.headers)) {
421
- res.header(k, v);
422
- }
423
- } else {
424
- res.header('Content-Type', 'text/event-stream');
425
- res.header('Cache-Control', 'no-cache');
426
- res.header('Connection', 'keep-alive');
427
- }
428
-
429
- // Write the stream — events are pre-encoded SSE strings
430
- if (typeof res.write === 'function' && typeof res.end === 'function') {
431
- for await (const event of result.events) {
432
- res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
433
- }
434
- res.end();
435
- } else {
436
- // Fallback: collect events into array
437
- const events = [];
438
- for await (const event of result.events) {
439
- events.push(event);
440
- }
441
- res.json({ events });
442
- }
443
- } else {
444
- res.status(result.status);
445
- if (result.body !== undefined) {
446
- res.json(result.body);
447
- } else {
448
- res.end();
449
- }
450
- }
451
- } catch (err: any) {
452
- errorResponse(err, res);
453
- }
454
- };
455
-
456
- const m = route.method.toLowerCase();
457
- if (m === 'get' && typeof server.get === 'function') {
458
- server.get(routePath, handler);
459
- } else if (m === 'post' && typeof server.post === 'function') {
460
- server.post(routePath, handler);
461
- } else if (m === 'delete' && typeof server.delete === 'function') {
462
- server.delete(routePath, handler);
463
- }
474
+ mountRouteOnServer(route, server, routePath);
464
475
  }
465
476
  ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
466
477
  });
478
+
479
+ // ── Fallback: recover routes cached before hook was registered ──
480
+ // If AIServicePlugin.start() ran before DispatcherPlugin.start()
481
+ // (possible when plugin start order differs from registration order),
482
+ // the 'ai:routes' trigger fires with no listener. The AIServicePlugin
483
+ // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
484
+ // as an internal cross-plugin protocol so we can recover them here.
485
+ // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
486
+ const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
487
+ if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
488
+ let registered = 0;
489
+ for (const route of cachedRoutes) {
490
+ const routePath = route.path.startsWith('/api/v1')
491
+ ? route.path
492
+ : `${prefix}${route.path}`;
493
+ if (mountRouteOnServer(route, server, routePath)) {
494
+ registered++;
495
+ }
496
+ }
497
+ if (registered > 0) {
498
+ ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
499
+ }
500
+ }
467
501
  },
468
502
  };
469
503
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
4
4
  import { CoreServiceName } from '@objectstack/spec/system';
5
+ import { pluralToSingular } from '@objectstack/spec/shared';
5
6
 
6
7
  /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
7
8
  function randomUUID(): string {
@@ -341,22 +342,82 @@ export class HttpDispatcher {
341
342
 
342
343
  // GET /metadata/types
343
344
  if (parts[0] === 'types') {
344
- // Try protocol service for dynamic types
345
+ // PRIORITY 1: Try MetadataService directly (includes both typeRegistry with agent/tool AND runtime-registered types)
346
+ console.log('[HttpDispatcher] Attempting to resolve MetadataService...');
347
+ console.log('[HttpDispatcher] Available kernel methods:', {
348
+ hasGetServiceAsync: typeof this.kernel.getServiceAsync === 'function',
349
+ hasGetService: typeof this.kernel.getService === 'function',
350
+ hasContext: !!this.kernel.context,
351
+ hasContextGetService: typeof this.kernel.context?.getService === 'function',
352
+ });
353
+
354
+ // Try all service resolution paths with detailed logging
355
+ let metadataService: any = null;
356
+
357
+ // Path 1: kernel.getServiceAsync
358
+ if (typeof this.kernel.getServiceAsync === 'function') {
359
+ try {
360
+ metadataService = await this.kernel.getServiceAsync('metadata');
361
+ console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") returned:', !!metadataService);
362
+ } catch (e: any) {
363
+ console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") failed:', e.message);
364
+ }
365
+ }
366
+
367
+ // Path 2: kernel.getService (if not found via async)
368
+ if (!metadataService && typeof this.kernel.getService === 'function') {
369
+ try {
370
+ metadataService = await this.kernel.getService('metadata');
371
+ console.log('[HttpDispatcher] kernel.getService("metadata") returned:', !!metadataService);
372
+ } catch (e: any) {
373
+ console.log('[HttpDispatcher] kernel.getService("metadata") failed:', e.message);
374
+ }
375
+ }
376
+
377
+ // Path 3: kernel.context.getService (if not found)
378
+ if (!metadataService && this.kernel.context?.getService) {
379
+ try {
380
+ metadataService = await this.kernel.context.getService('metadata');
381
+ console.log('[HttpDispatcher] kernel.context.getService("metadata") returned:', !!metadataService);
382
+ } catch (e: any) {
383
+ console.log('[HttpDispatcher] kernel.context.getService("metadata") failed:', e.message);
384
+ }
385
+ }
386
+
387
+ console.log('[HttpDispatcher] Final metadataService:', !!metadataService, 'has getRegisteredTypes:', typeof (metadataService as any)?.getRegisteredTypes);
388
+
389
+ if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') {
390
+ try {
391
+ const types = await (metadataService as any).getRegisteredTypes();
392
+ console.log('[HttpDispatcher] MetadataService.getRegisteredTypes() returned:', types);
393
+ return { handled: true, response: this.success({ types }) };
394
+ } catch (e: any) {
395
+ // Log error but continue to fallbacks
396
+ console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message, e.stack);
397
+ }
398
+ } else {
399
+ console.log('[HttpDispatcher] MetadataService not available or missing getRegisteredTypes, falling back to protocol service');
400
+ }
401
+ // PRIORITY 2: Try protocol service (returns SchemaRegistry types only - missing agent/tool)
345
402
  const protocol = await this.resolveService('protocol');
346
403
  if (protocol && typeof protocol.getMetaTypes === 'function') {
347
404
  const result = await protocol.getMetaTypes({});
405
+ console.log('[HttpDispatcher] Protocol service returned types:', result);
348
406
  return { handled: true, response: this.success(result) };
349
407
  }
350
- // Fallback: ask broker for registered types
408
+ // PRIORITY 3: ask broker for registered types
351
409
  if (broker) {
352
410
  try {
353
411
  const data = await broker.call('metadata.types', {}, { request: context.request });
412
+ console.log('[HttpDispatcher] Broker returned types:', data);
354
413
  return { handled: true, response: this.success(data) };
355
- } catch {
414
+ } catch (e) {
415
+ console.log('[HttpDispatcher] Broker call failed:', e);
356
416
  // fall through to hardcoded defaults
357
417
  }
358
418
  }
359
419
  // Last resort: hardcoded defaults
420
+ console.warn('[HttpDispatcher] Falling back to hardcoded defaults for metadata types');
360
421
  return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
361
422
  }
362
423
 
@@ -429,9 +490,8 @@ export class HttpDispatcher {
429
490
  return { handled: true, response: this.error('Not found', 404) };
430
491
  }
431
492
 
432
- // If type is singular (e.g. 'app'), use it directly
433
- // If plural (e.g. 'apps'), slice it
434
- const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
493
+ // Normalize plural URL paths to singular registry type names
494
+ const singularType = pluralToSingular(type);
435
495
 
436
496
  // Try Protocol Service First (Preferred)
437
497
  const protocol = await this.resolveService('protocol');
@@ -464,7 +524,7 @@ export class HttpDispatcher {
464
524
  const typeOrName = parts[0];
465
525
  // Extract optional package filter from query string
466
526
  const packageId = query?.package || undefined;
467
-
527
+
468
528
  // Try protocol service first for any type
469
529
  const protocol = await this.resolveService('protocol');
470
530
  if (protocol && typeof protocol.getMetaItems === 'function') {
@@ -479,6 +539,22 @@ export class HttpDispatcher {
479
539
  }
480
540
  }
481
541
 
542
+ // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.)
543
+ const metadataService = await this.getService(CoreServiceName.enum.metadata);
544
+ if (metadataService && typeof (metadataService as any).list === 'function') {
545
+ try {
546
+ const items = await (metadataService as any).list(typeOrName);
547
+ if (items && items.length > 0) {
548
+ return { handled: true, response: this.success({ type: typeOrName, items }) };
549
+ }
550
+ } catch (e: any) {
551
+ // MetadataService doesn't know this type or failed, continue to other fallbacks
552
+ // Sanitize typeOrName to prevent log injection (CodeQL warning)
553
+ const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, '');
554
+ console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message);
555
+ }
556
+ }
557
+
482
558
  // Try broker for the type
483
559
  if (broker) {
484
560
  try {
package/vitest.config.ts CHANGED
@@ -12,6 +12,7 @@ export default defineConfig({
12
12
  '@objectstack/spec/contracts': path.resolve(__dirname, '../spec/src/contracts/index.ts'),
13
13
  '@objectstack/spec/data': path.resolve(__dirname, '../spec/src/data/index.ts'),
14
14
  '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'),
15
+ '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'),
15
16
  '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'),
16
17
  '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'),
17
18
  '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'),