Skip to content

Commit 1368a4a

Browse files
authored
Merge pull request #1032 from objectstack-ai/copilot/fix-discovery-api-routing-issues-again
2 parents 53764cf + a1e6a1f commit 1368a4a

10 files changed

Lines changed: 569 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Discovery Schema — `ServiceStatus` enum & `handlerReady` field** — Added `'registered'`
12+
status to `ServiceInfoSchema` to distinguish routes that are declared in the dispatcher
13+
table but whose HTTP handler has not been verified. Added optional `handlerReady` boolean
14+
field (omitted = unverified/unknown) so clients can filter handler-ready services before
15+
displaying endpoints when the value is explicitly `true`.
16+
- **Discovery Schema — `RouteHealthReportSchema`** — New schema for automated route/handler
17+
coverage reporting at startup. Includes per-route health entries (`pass`, `fail`, `missing`,
18+
`skip`) and summary counters.
19+
- **Dispatcher Schema — `DispatcherErrorCode` & `DispatcherErrorResponseSchema`** — Semantic
20+
error codes (`404`/`405`/`501`/`503`) with machine-readable `type` field
21+
(`ROUTE_NOT_FOUND`, `METHOD_NOT_ALLOWED`, `NOT_IMPLEMENTED`, `SERVICE_UNAVAILABLE`) and
22+
developer-facing `hint` strings.
23+
- **Dispatcher Schema — `/health` route** — Added health endpoint to `DEFAULT_DISPATCHER_ROUTES`.
24+
- **REST API Plugin — `handlerStatus` field** — Added `handlerStatus` (`implemented`, `stub`,
25+
`planned`) to `RestApiEndpointSchema` to track handler implementation readiness.
26+
- **REST API Plugin — `RouteCoverageReportSchema`** — Schema for adapter-generated coverage
27+
reports listing every declared endpoint and its implementation status.
28+
- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports
29+
1030
### Changed
1131
- **AI Chat Protocol Aligned with Vercel AI SDK** — Removed custom AI chat protocol
1232
types and Zod schemas (`AIMessage`, `AIToolCall`, `AIStreamEvent`,
@@ -32,6 +52,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3252
- Conversation services serialize/deserialize `ModelMessage` union to flat DB columns
3353
- All 158 service-ai tests updated and passing
3454

55+
### Fixed
56+
- **Runtime Dispatcher — semantic error differentiation**`HttpDispatcher.dispatch()` now
57+
returns typed 404 (`ROUTE_NOT_FOUND`) with diagnostic info instead of bare `{ handled: false }`.
58+
Added `routeNotFound()` (404) helper method.
59+
- **Runtime Dispatcher — `/health` handler** — Added health endpoint returning `status`,
60+
`timestamp`, `version`, and `uptime`.
61+
- **Runtime Dispatcher — `handlerReady` in discovery**`getDiscoveryInfo()` now emits
62+
`handlerReady: true` for services with confirmed handlers and `handlerReady: false` for
63+
unavailable services.
64+
- **Dispatcher Plugin — semantic 404**`sendResult()` now returns `ROUTE_NOT_FOUND` error
65+
type with a hint pointing to the discovery endpoint. Added `/health` handler registration.
66+
- **Studio — handler-ready filtering**`useApiDiscovery()` now checks both `enabled` and
67+
`handlerReady` (or `status === 'available' | 'degraded'` for backward compatibility) before
68+
displaying service endpoints in the UI.
69+
3570
### Removed
3671
- `AiChatRequestSchema` / `AiChatResponseSchema` Zod schemas from
3772
`@objectstack/spec/api` — the AI chat wire protocol now uses Vercel AI SDK's
@@ -40,9 +75,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4075
`@ai-sdk/react/useChat` directly
4176
- AI `/chat` endpoint from `DEFAULT_AI_ROUTES` plugin REST API definition
4277

43-
### Added
44-
- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports
45-
4678
## [4.0.1] — 2026-03-31
4779

4880
### Fixed

apps/studio/src/hooks/use-api-discovery.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,15 +227,23 @@ export function useApiDiscovery() {
227227
// 2. Build service endpoints from discovery
228228
const serviceEndpoints: EndpointDef[] = [];
229229
for (const [serviceName, catalog] of Object.entries(SERVICE_ENDPOINT_CATALOG)) {
230-
const serviceInfo = discoveredServices[serviceName];
230+
const serviceInfo = discoveredServices[serviceName] as
231+
| { enabled: boolean; status?: string; handlerReady?: boolean; route?: string }
232+
| undefined;
233+
234+
// Only include services that are both enabled and have a handler ready.
235+
// Backwards-compatible: if handlerReady is not present (older backends),
236+
// treat status === 'available' or status === 'degraded' as equivalent to handlerReady: true.
231237
const isEnabled = serviceInfo?.enabled ?? false;
238+
const hasHandler = serviceInfo?.handlerReady
239+
?? (serviceInfo?.status === 'available' || serviceInfo?.status === 'degraded');
232240

233241
// Use route from discovery services, discovery routes map, or catalog default
234242
const routePrefix = serviceInfo?.route
235243
?? discoveredRoutes[serviceName]
236244
?? catalog.defaultRoute;
237245

238-
if (isEnabled) {
246+
if (isEnabled && hasHandler) {
239247
serviceEndpoints.push(...buildServiceEndpoints(serviceName, routePrefix));
240248
}
241249
}

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export interface DispatcherPluginConfig {
1212
}
1313

1414
/**
15-
* Send an HttpDispatcherResult through IHttpResponse
15+
* Send an HttpDispatcherResult through IHttpResponse.
16+
* Differentiates between handled, unhandled (404), and special results.
1617
*/
1718
function sendResult(result: HttpDispatcherResult, res: any): void {
1819
if (result.handled) {
@@ -32,7 +33,16 @@ function sendResult(result: HttpDispatcherResult, res: any): void {
3233
return;
3334
}
3435
}
35-
res.status(404).json({ success: false, error: { message: 'Not Found', code: 404 } });
36+
// Semantic 404: no route matched — include diagnostic info
37+
res.status(404).json({
38+
success: false,
39+
error: {
40+
message: 'Not Found',
41+
code: 404,
42+
type: 'ROUTE_NOT_FOUND',
43+
hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
44+
},
45+
});
3646
}
3747

3848
function errorResponse(err: any, res: any): void {
@@ -96,6 +106,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
96106
res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
97107
});
98108

109+
// ── Health ──────────────────────────────────────────────────
110+
server.get(`${prefix}/health`, async (_req: any, res: any) => {
111+
try {
112+
const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req });
113+
sendResult(result, res);
114+
} catch (err: any) {
115+
errorResponse(err, res);
116+
}
117+
});
118+
99119
// ── Auth ────────────────────────────────────────────────────
100120
server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
101121
try {

packages/runtime/src/http-dispatcher.root.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,16 @@ describe('HttpDispatcher Root Handling', () => {
6161
expect(data.routes).toBeDefined();
6262
});
6363

64-
it('should NOT handle POST request to root path ("")', async () => {
64+
it('should return semantic 404 for POST request to root path ("")', async () => {
6565
const context = { request: {} };
6666
const method = 'POST';
6767
const path = '';
6868

6969
const result = await dispatcher.dispatch(method, path, {}, {}, context);
7070

71-
expect(result.handled).toBe(false);
71+
// The dispatcher now returns a typed 404 (ROUTE_NOT_FOUND) instead of { handled: false }
72+
expect(result.handled).toBe(true);
73+
expect(result.response?.status).toBe(404);
74+
expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
7275
});
7376
});

packages/runtime/src/http-dispatcher.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ export class HttpDispatcher {
5959
};
6060
}
6161

62+
/**
63+
* 404 Route Not Found — no route is registered for this path.
64+
*/
65+
private routeNotFound(route: string) {
66+
return {
67+
status: 404,
68+
body: {
69+
success: false,
70+
error: {
71+
code: 404,
72+
message: `Route Not Found: ${route}`,
73+
type: 'ROUTE_NOT_FOUND' as const,
74+
route,
75+
hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
76+
},
77+
},
78+
};
79+
}
80+
6281
private ensureBroker() {
6382
if (!this.kernel.broker) {
6483
throw { statusCode: 500, message: 'Kernel Broker not available' };
@@ -133,11 +152,14 @@ export class HttpDispatcher {
133152
};
134153

135154
// Build per-service status map
155+
// handlerReady: true means the dispatcher has a real, bound handler for this route.
156+
// handlerReady: false means the route is present in the discovery table but may not
157+
// yet have a concrete implementation or may be served by a stub.
136158
const svcAvailable = (route?: string, provider?: string) => ({
137-
enabled: true, status: 'available' as const, route, provider,
159+
enabled: true, status: 'available' as const, handlerReady: true, route, provider,
138160
});
139161
const svcUnavailable = (name: string) => ({
140-
enabled: false, status: 'unavailable' as const,
162+
enabled: false, status: 'unavailable' as const, handlerReady: false,
141163
message: `Install a ${name} plugin to enable`,
142164
});
143165

@@ -174,7 +196,7 @@ export class HttpDispatcher {
174196
},
175197
services: {
176198
// Kernel-provided (always available via protocol implementation)
177-
metadata: { enabled: true, status: 'degraded' as const, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
199+
metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
178200
data: svcAvailable(routes.data, 'kernel'),
179201
// Plugin-provided — only available when a plugin registers the service
180202
auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
@@ -1207,6 +1229,19 @@ export class HttpDispatcher {
12071229
};
12081230
}
12091231

1232+
// 0b. Health Endpoint (GET /health)
1233+
if (cleanPath === '/health' && method === 'GET') {
1234+
return {
1235+
handled: true,
1236+
response: this.success({
1237+
status: 'ok',
1238+
timestamp: new Date().toISOString(),
1239+
version: '1.0.0',
1240+
uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
1241+
}),
1242+
};
1243+
}
1244+
12101245
// 1. System Protocols (Prefix-based)
12111246
if (cleanPath.startsWith('/auth')) {
12121247
return this.handleAuth(cleanPath.substring(5), method, body, context);
@@ -1265,8 +1300,11 @@ export class HttpDispatcher {
12651300
const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
12661301
if (result.handled) return result;
12671302

1268-
// 3. Fallback (404)
1269-
return { handled: false };
1303+
// 3. Fallback — return semantic 404 with diagnostic info
1304+
return {
1305+
handled: true,
1306+
response: this.routeNotFound(cleanPath),
1307+
};
12701308
}
12711309

12721310
/**

0 commit comments

Comments
 (0)