Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,22 @@ 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". 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
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/kernel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*/
Expand DownExpand Up@@ -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();

CopilotAIApr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preInjectCoreFallbacks() changes bootstrap semantics (core fallbacks become available during plugin start()), but there is no kernel-level test covering this contract. Consider adding a test that runs bootstrap with skipSystemValidation: false and a plugin whose start() calls ctx.getService('metadata') without registering metadata in init; the test should assert it no longer throws and that a fallback is injected before Phase 2.

Suggested change
// Enforce the bootstrap contract introduced by preInjectCoreFallbacks():
// core services that plugins may resolve during start() must already be
// available before Phase 2 begins.
constrequiredPreStartServices=['metadata'];
for(constserviceNameofrequiredPreStartServices){
try{
awaitthis.context.getService(serviceName);
}catch(error){
thrownewError(
`Core service "${serviceName}" is not available before Phase 2. `+
'preInjectCoreFallbacks() must make fallback-backed core services '+
'resolvable before plugin start() executes.'
);
}
}
this.logger.debug('Verified pre-start core service availability',{
services: requiredPreStartServices,
});

Copilot uses AI. Check for mistakes.
// Phase 2: Start - Plugins execute business logic
this.logger.info('Phase 2: Start plugins');
this.state = 'running';
Expand Down
152 changes: 93 additions & 59 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,75 @@ interface RouteDefinition {
handler: (req: any) => Promise<any>;
}

/**
* 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, String(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);
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.
Expand DownExpand Up@@ -402,68 +471,33 @@ 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`);
});

// ── 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 (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;
for (const route of cachedRoutes) {
const routePath = route.path.startsWith('/api/v1')
? route.path
: `${prefix}${route.path}`;
if (mountRouteOnServer(route, server, routePath)) {
Comment on lines +486 to +493

CopilotAIApr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cached-route fallback repeats the same /api/v1-pinned routePath logic, so recovered AI routes will also ignore a non-default prefix configuration. If you update the main hook’s path normalization, mirror the same logic here to ensure recovered routes are mounted under the configured prefix.

Copilot uses AI. Check for mistakes.
registered++;
}
}
if (registered > 0) {
ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
}
}
},
};
}
14 changes: 6 additions & 8 deletions packages/services/service-ai/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<IMetadataService>('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');
}
Comment on lines 308 to 318

CopilotAIApr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change fixes agent route registration by reusing the resolved metadataService, but there isn’t a test asserting that AIServicePlugin.start() includes the agent routes (e.g. GET /api/v1/ai/agents) in the ai:routes payload when a metadata service is present. Adding a unit test with a mock metadata service would prevent regressions of this exact bug.

Copilot uses AI. Check for mistakes.

Expand Down