From 5d2b72205ff60859c75ef364e42023a48ac079fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:22:15 +0000 Subject: [PATCH 1/5] Initial plan From 9e0a5029cac08918e922d090529d399f06a494a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:27:25 +0000 Subject: [PATCH 2/5] fix: AI agent routes not registered due to redundant ctx.getService('metadata') call Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/35f9d46b-5132-451b-89f4-7dbee7066a1f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 7 ++ packages/runtime/src/dispatcher-plugin.ts | 74 ++++++++++++++++++++++ packages/services/service-ai/src/plugin.ts | 14 ++-- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9e64934a..234b5fa4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 match the current monorepo layout. ### Fixed +- **AI Chat agent selector missing `data_chat` and `metadata_assistant`** — Fixed `GET /api/v1/ai/agents` + returning 404, which caused the Studio AI Chat panel to show only "General Chat". Root cause was a + redundant second `ctx.getService('metadata')` call inside `AIServicePlugin.start()` that shadowed + the outer resolved variable and failed silently, preventing `buildAgentRoutes()` from being called. + Fix: reuse the already-resolved `metadataService` variable instead of re-fetching it. Additionally, + added a fallback in `DispatcherPlugin.start()` that recovers AI routes from the `kernel.__aiRoutes` + cache in case the `ai:routes` hook fires before the listener is registered (timing edge case). - **ObjectQLPlugin: cold-start metadata restoration** — `ObjectQLPlugin.start()` now calls `protocol.loadMetaFromDb()` after driver initialization and before schema sync, restoring all persisted metadata (objects, views, apps, etc.) from the `sys_metadata` table into the diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index dfd6ebde8b..a0dc665cc3 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -464,6 +464,80 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`); }); + + // ── Fallback: recover routes cached before hook was registered ── + // If AIServicePlugin.start() ran before DispatcherPlugin.start() + // (possible when plugin start order differs from registration order), + // the 'ai:routes' trigger fires with no listener. The AIServicePlugin + // caches the routes on the kernel as __aiRoutes so we can recover here. + const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined; + if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) { + let registered = 0; + for (const route of cachedRoutes) { + const routePath = route.path.startsWith('/api/v1') + ? route.path + : `${prefix}${route.path}`; + + const handler = async (req: any, res: any) => { + try { + const result = await route.handler({ + body: req.body, + params: req.params, + query: req.query, + }); + + if (result.stream && result.events) { + res.status(result.status); + if (result.headers) { + for (const [k, v] of Object.entries(result.headers)) { + res.header(k, v as string); + } + } else { + res.header('Content-Type', 'text/event-stream'); + res.header('Cache-Control', 'no-cache'); + res.header('Connection', 'keep-alive'); + } + if (typeof res.write === 'function' && typeof res.end === 'function') { + for await (const event of result.events) { + res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); + } + res.end(); + } else { + const events = []; + for await (const event of result.events) { + events.push(event); + } + res.json({ events }); + } + } else { + res.status(result.status); + if (result.body !== undefined) { + res.json(result.body); + } else { + res.end(); + } + } + } catch (err: any) { + errorResponse(err, res); + } + }; + + const m = route.method.toLowerCase(); + if (m === 'get' && typeof server.get === 'function') { + server.get(routePath, handler); + registered++; + } else if (m === 'post' && typeof server.post === 'function') { + server.post(routePath, handler); + registered++; + } else if (m === 'delete' && typeof server.delete === 'function') { + server.delete(routePath, handler); + registered++; + } + } + if (registered > 0) { + ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`); + } + } }, }; } diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index 24e9bc4e6e..a493619b54 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -308,14 +308,12 @@ export class AIServicePlugin implements Plugin { const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger); // Build agent routes if metadata service is available - try { - const metadataService = ctx.getService('metadata'); - if (metadataService) { - const agentRuntime = new AgentRuntime(metadataService); - const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger); - routes.push(...agentRoutes); - } - } catch { + if (metadataService) { + const agentRuntime = new AgentRuntime(metadataService); + const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger); + routes.push(...agentRoutes); + ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`); + } else { ctx.logger.debug('[AI] Metadata service not available, skipping agent routes'); } From e75eb0b6e11b88ccb634c3239edad45b9c1a2ca2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:31:09 +0000 Subject: [PATCH 3/5] refactor: extract mountRouteOnServer helper to eliminate duplicate route registration logic Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/35f9d46b-5132-451b-89f4-7dbee7066a1f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/runtime/src/dispatcher-plugin.ts | 184 +++++++++------------- 1 file changed, 71 insertions(+), 113 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index a0dc665cc3..c0bbb45798 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -22,6 +22,75 @@ interface RouteDefinition { handler: (req: any) => Promise; } +/** + * Register a single RouteDefinition on the HTTP server. + * Returns true if the route was successfully registered. + */ +function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean { + const handler = async (req: any, res: any) => { + try { + const result = await route.handler({ + body: req.body, + params: req.params, + query: req.query, + }); + + if (result.stream && result.events) { + // SSE streaming response + res.status(result.status); + + // Apply headers from the route result if available + if (result.headers) { + for (const [k, v] of Object.entries(result.headers)) { + res.header(k, v as string); + } + } else { + res.header('Content-Type', 'text/event-stream'); + res.header('Cache-Control', 'no-cache'); + res.header('Connection', 'keep-alive'); + } + + // Write the stream — events are pre-encoded SSE strings + if (typeof res.write === 'function' && typeof res.end === 'function') { + for await (const event of result.events) { + res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); + } + res.end(); + } else { + // Fallback: collect events into array + const events = []; + for await (const event of result.events) { + events.push(event); + } + res.json({ events }); + } + } else { + res.status(result.status); + if (result.body !== undefined) { + res.json(result.body); + } else { + res.end(); + } + } + } catch (err: any) { + errorResponse(err, res); + } + }; + + const m = route.method.toLowerCase(); + if (m === 'get' && typeof server.get === 'function') { + server.get(routePath, handler); + return true; + } else if (m === 'post' && typeof server.post === 'function') { + server.post(routePath, handler); + return true; + } else if (m === 'delete' && typeof server.delete === 'function') { + server.delete(routePath, handler); + return true; + } + return false; +} + /** * Send an HttpDispatcherResult through IHttpResponse. * Differentiates between handled, unhandled (404), and special results. @@ -402,65 +471,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const routePath = route.path.startsWith('/api/v1') ? route.path : `${prefix}${route.path}`; - - const handler = async (req: any, res: any) => { - try { - const result = await route.handler({ - body: req.body, - params: req.params, - query: req.query, - }); - - if (result.stream && result.events) { - // SSE streaming response - res.status(result.status); - - // Apply headers from the route result if available - if (result.headers) { - for (const [k, v] of Object.entries(result.headers)) { - res.header(k, v); - } - } else { - res.header('Content-Type', 'text/event-stream'); - res.header('Cache-Control', 'no-cache'); - res.header('Connection', 'keep-alive'); - } - - // Write the stream — events are pre-encoded SSE strings - if (typeof res.write === 'function' && typeof res.end === 'function') { - for await (const event of result.events) { - res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); - } - res.end(); - } else { - // Fallback: collect events into array - const events = []; - for await (const event of result.events) { - events.push(event); - } - res.json({ events }); - } - } else { - res.status(result.status); - if (result.body !== undefined) { - res.json(result.body); - } else { - res.end(); - } - } - } catch (err: any) { - errorResponse(err, res); - } - }; - - const m = route.method.toLowerCase(); - if (m === 'get' && typeof server.get === 'function') { - server.get(routePath, handler); - } else if (m === 'post' && typeof server.post === 'function') { - server.post(routePath, handler); - } else if (m === 'delete' && typeof server.delete === 'function') { - server.delete(routePath, handler); - } + mountRouteOnServer(route, server, routePath); } ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`); }); @@ -477,60 +488,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const routePath = route.path.startsWith('/api/v1') ? route.path : `${prefix}${route.path}`; - - const handler = async (req: any, res: any) => { - try { - const result = await route.handler({ - body: req.body, - params: req.params, - query: req.query, - }); - - if (result.stream && result.events) { - res.status(result.status); - if (result.headers) { - for (const [k, v] of Object.entries(result.headers)) { - res.header(k, v as string); - } - } else { - res.header('Content-Type', 'text/event-stream'); - res.header('Cache-Control', 'no-cache'); - res.header('Connection', 'keep-alive'); - } - if (typeof res.write === 'function' && typeof res.end === 'function') { - for await (const event of result.events) { - res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); - } - res.end(); - } else { - const events = []; - for await (const event of result.events) { - events.push(event); - } - res.json({ events }); - } - } else { - res.status(result.status); - if (result.body !== undefined) { - res.json(result.body); - } else { - res.end(); - } - } - } catch (err: any) { - errorResponse(err, res); - } - }; - - const m = route.method.toLowerCase(); - if (m === 'get' && typeof server.get === 'function') { - server.get(routePath, handler); - registered++; - } else if (m === 'post' && typeof server.post === 'function') { - server.post(routePath, handler); - registered++; - } else if (m === 'delete' && typeof server.delete === 'function') { - server.delete(routePath, handler); + if (mountRouteOnServer(route, server, routePath)) { registered++; } } From 5dcf58b9eccb1498f982d16ab27aa30fbeaaa98a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:33:49 +0000 Subject: [PATCH 4/5] fix: use String() coercion for header values and add TODO comment for kernel cache API Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/35f9d46b-5132-451b-89f4-7dbee7066a1f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/runtime/src/dispatcher-plugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index c0bbb45798..8113ae72d6 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -42,7 +42,7 @@ function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePa // Apply headers from the route result if available if (result.headers) { for (const [k, v] of Object.entries(result.headers)) { - res.header(k, v as string); + res.header(k, String(v)); } } else { res.header('Content-Type', 'text/event-stream'); @@ -480,7 +480,9 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // If AIServicePlugin.start() ran before DispatcherPlugin.start() // (possible when plugin start order differs from registration order), // the 'ai:routes' trigger fires with no listener. The AIServicePlugin - // caches the routes on the kernel as __aiRoutes so we can recover here. + // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start()) + // as an internal cross-plugin protocol so we can recover them here. + // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release. const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined; if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) { let registered = 0; From b083c75f40c4eb800edcb9caa6cdac40342c87aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 07:58:16 +0000 Subject: [PATCH 5/5] fix: pre-inject core service fallbacks before Phase 2 so ctx.getService('metadata') succeeds during plugin starts Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/f9c31840-b5a5-498e-85f1-f42b38e65983 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- CHANGELOG.md | 21 +++++++++++++++------ packages/core/src/kernel.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 234b5fa4e2..5066cdc628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,12 +72,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **AI Chat agent selector missing `data_chat` and `metadata_assistant`** — Fixed `GET /api/v1/ai/agents` - returning 404, which caused the Studio AI Chat panel to show only "General Chat". Root cause was a - redundant second `ctx.getService('metadata')` call inside `AIServicePlugin.start()` that shadowed - the outer resolved variable and failed silently, preventing `buildAgentRoutes()` from being called. - Fix: reuse the already-resolved `metadataService` variable instead of re-fetching it. Additionally, - added a fallback in `DispatcherPlugin.start()` that recovers AI routes from the `kernel.__aiRoutes` - cache in case the `ai:routes` hook fires before the listener is registered (timing edge case). + returning 404, which caused the Studio AI Chat panel to show only "General Chat". There were two + root causes addressed by this fix: + 1. **Kernel bootstrap timing** (`packages/core/src/kernel.ts`): 'core' service in-memory fallbacks + (e.g. the 'metadata' service) were only injected in `validateSystemRequirements()` which runs + AFTER all plugin `start()` methods execute. This meant `ctx.getService('metadata')` always threw + during `AIServicePlugin.start()` when no explicit `MetadataPlugin` was loaded. Fix: added + `preInjectCoreFallbacks()` called between Phase 1 (init) and Phase 2 (start), ensuring all core + service fallbacks are available before any plugin's `start()` runs. + 2. **Shadowed variable** (`packages/services/service-ai/src/plugin.ts`): a redundant second + `ctx.getService('metadata')` call declared a new `const metadataService` that shadowed the outer + `let metadataService` and failed silently, preventing `buildAgentRoutes()` from being called even + if the metadata service was available. Fix: reuse the already-resolved outer variable. + Additionally, added a fallback in `DispatcherPlugin.start()` that recovers AI routes from the + `kernel.__aiRoutes` cache in case the `ai:routes` hook fires before the listener is registered + (timing edge case). - **ObjectQLPlugin: cold-start metadata restoration** — `ObjectQLPlugin.start()` now calls `protocol.loadMetaFromDb()` after driver initialization and before schema sync, restoring all persisted metadata (objects, views, apps, etc.) from the `sys_metadata` table into the diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index c16af9dea1..554b769b3f 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -213,6 +213,28 @@ export class ObjectKernel { return this; } + /** + * Pre-inject in-memory fallbacks for 'core' services that were not registered + * by plugins during Phase 1. Called before Phase 2 so that all core services + * (e.g. 'metadata', 'cache', 'queue') are resolvable via ctx.getService() + * when plugin start() methods execute. + */ + private preInjectCoreFallbacks() { + if (this.config.skipSystemValidation) return; + for (const [serviceName, criticality] of Object.entries(ServiceRequirementDef)) { + if (criticality !== 'core') continue; + const hasService = this.services.has(serviceName) || this.pluginLoader.hasService(serviceName); + if (!hasService) { + const factory = CORE_FALLBACK_FACTORIES[serviceName]; + if (factory) { + const fallback = factory(); + this.registerService(serviceName, fallback); + this.logger.debug(`[Kernel] Pre-injected in-memory fallback for '${serviceName}' before Phase 2`); + } + } + } + } + /** * Validate Critical System Requirements */ @@ -291,6 +313,12 @@ export class ObjectKernel { await this.initPluginWithTimeout(plugin); } + // Pre-inject in-memory fallbacks for 'core' services that were not + // registered by any plugin during Phase 1. This ensures services like + // 'metadata', 'cache', 'queue', etc. are always available when plugins + // call ctx.getService() during their start() methods. + this.preInjectCoreFallbacks(); + // Phase 2: Start - Plugins execute business logic this.logger.info('Phase 2: Start plugins'); this.state = 'running';