feat(kap-server): expose engine feature list on /api/v1/meta (#3085)

This commit is contained in:
Haozhe 2026-08-19 16:32:45 +08:00 committed by GitHub
parent 571bcc2f75
commit cb8a7e5f81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 147 additions and 16 deletions

View file

@ -31,7 +31,10 @@ registerFeature(PlanFeature); // import = register
`Feature extends Service`, so every contribution runs through the normal two-phase
construction protocol (declare contributions in the constructor; they are buffered and
flushed by the kernel). The helpers are thin compositions over the existing seams:
flushed by the kernel). A feature may also declare `static readonly meta = { ... }`
free-form self-description that `IFeatureManager.units()` introspection carries (and
kap-server surfaces via `GET /api/v1/meta`); it defaults to `{}`. The helpers are thin
compositions over the existing seams:
| Helper | Composition | Semantics |
|---|---|---|

View file

@ -35,6 +35,7 @@ export interface RecipeStatics {
readonly name?: string;
readonly inject?: readonly ServiceIdentifier<any>[];
readonly Config?: ConfigSchema;
readonly meta?: Record<string, unknown>;
}
export type ServiceClassRecipe =

View file

@ -13,6 +13,7 @@ export interface ManagedUnitInfo {
readonly name: string;
readonly state: FiberState;
readonly uid: number | undefined;
readonly meta: Record<string, unknown>;
}
export interface IFeatureManager {

View file

@ -3,6 +3,7 @@ import { Emitter, type Event } from '#/_base/event';
import type {
FiberHandle,
FiberProvideOptions,
RecipeStatics,
ServiceClassRecipe,
ServiceRecipe,
} from '#/_base/di/fiber';
@ -22,7 +23,10 @@ import {
export class FeatureManagerService extends Service implements IFeatureManager {
declare readonly _serviceBrand: undefined;
private readonly _units = new Map<string, FiberHandle>();
private readonly _units = new Map<
string,
{ handle: FiberHandle; meta: Record<string, unknown> }
>();
private readonly _onDidChangeUnits = new Emitter<void>();
readonly onDidChangeUnits: Event<void> = this._onDidChangeUnits.event;
@ -50,46 +54,47 @@ export class FeatureManagerService extends Service implements IFeatureManager {
: this.provide(first as ServiceRecipe, second as FiberProvideOptions | undefined);
const name = handle.name;
const previous = this._units.get(name);
if (previous !== undefined && previous !== handle) {
void previous.dispose();
if (previous !== undefined && previous.handle !== handle) {
void previous.handle.dispose();
}
this._units.set(name, handle);
const statics = (isServiceIdentifier(first) ? second : first) as RecipeStatics;
this._units.set(name, { handle, meta: Object.freeze({ ...statics.meta }) });
this._onDidChangeUnits.fire();
return handle;
}
async unprovideUnit(name: string): Promise<void> {
const handle = this._units.get(name);
if (handle === undefined) {
const entry = this._units.get(name);
if (entry === undefined) {
return;
}
this._units.delete(name);
try {
await handle.dispose();
await entry.handle.dispose();
} finally {
this._onDidChangeUnits.fire();
}
}
async updateUnit(name: string, config?: unknown): Promise<void> {
const handle = this._units.get(name);
if (handle === undefined) {
const entry = this._units.get(name);
if (entry === undefined) {
throw new Error(`feature unit '${name}' is not managed by this FeatureManager`);
}
await handle.update(config);
await entry.handle.update(config);
this._onDidChangeUnits.fire();
}
units(): readonly ManagedUnitInfo[] {
const infos: ManagedUnitInfo[] = [];
for (const [name, handle] of this._units) {
for (const [name, entry] of this._units) {
let uid: number | undefined;
try {
uid = handle.uid;
uid = entry.handle.uid;
} catch {
uid = undefined;
}
infos.push({ name, state: handle.state, uid });
infos.push({ name, state: entry.handle.state, uid, meta: entry.meta });
}
return infos;
}

View file

@ -36,7 +36,7 @@ describe('FeatureManager — dynamic unit assembly at App scope (§5.10)', () =>
expect(ix.invokeFunction((a) => a.get(IGizmo)).tag).toBe('gizmo');
const infos = manager.units();
expect(infos).toHaveLength(1);
expect(infos[0]).toMatchObject({ name: 'Gizmo', state: FiberState.Active });
expect(infos[0]).toMatchObject({ name: 'Gizmo', state: FiberState.Active, meta: {} });
expect(typeof infos[0]!.uid).toBe('number');
expect(events.length).toBe(1);
@ -72,4 +72,19 @@ describe('FeatureManager — dynamic unit assembly at App scope (§5.10)', () =>
ix.dispose();
await expect(Promise.resolve()).resolves.toBeUndefined();
});
it('carries a recipe-declared static meta into introspection', () => {
const { ix, manager } = host();
class Documented extends Service {
static readonly meta = { summary: 'a documented unit' };
}
manager.provideUnit(Documented);
expect(manager.units()[0]).toMatchObject({
name: 'Documented',
meta: { summary: 'a documented unit' },
});
manager.provideUnit(IGizmo, Gizmo);
expect(manager.units().find((unit) => unit.name === 'Gizmo')!.meta).toEqual({});
ix.dispose();
});
});

View file

@ -15,6 +15,22 @@ export const metaCapabilitiesSchema = z.object({
export type MetaCapabilities = z.infer<typeof metaCapabilitiesSchema>;
export const metaFeatureStateSchema = z.enum([
'Pending',
'Activating',
'Active',
'Unloading',
'Failed',
]);
export const metaFeatureSchema = z.object({
name: z.string().min(1),
state: metaFeatureStateSchema,
meta: z.record(z.string(), z.unknown()),
});
export type MetaFeature = z.infer<typeof metaFeatureSchema>;
export const metaResponseSchema = z.object({
server_version: z.string().min(1),
capabilities: metaCapabilitiesSchema,
@ -25,6 +41,7 @@ export const metaResponseSchema = z.object({
experimental_flags: z.record(z.string(), z.boolean()).optional(),
backend: z.enum(['v1', 'v2']).optional(),
web_title: z.string().optional(),
features: z.array(metaFeatureSchema).optional(),
});
export type MetaResponse = z.infer<typeof metaResponseSchema>;

View file

@ -1,7 +1,7 @@
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { metaResponseSchema } from '../protocol/rest-meta';
import type { MetaResponse } from '../protocol/rest-meta';
import type { MetaFeature, MetaResponse } from '../protocol/rest-meta';
interface RouteHost {
get(
@ -36,6 +36,12 @@ export interface MetaRouteOptions {
* always reflects the fully loaded config (never pre-load defaults).
*/
readonly getExperimentalFlags: () => Record<string, boolean> | Promise<Record<string, boolean>>;
/**
* Resolves the engine's current feature list at request time. Backed by
* `IFeatureManager.units()` in production, so runtime retraction or a failed
* assembly is reflected in the very next response.
*/
readonly getFeatures: () => MetaFeature[] | Promise<MetaFeature[]>;
}
export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void {
@ -69,6 +75,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void
const data: MetaResponse = {
...staticData,
experimental_flags: await opts.getExperimentalFlags(),
features: await opts.getFeatures(),
};
reply.send(okEnvelope(data, req.id));
},

View file

@ -1,9 +1,12 @@
import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2';
import { FiberState } from '@moonshot-ai/agent-core-v2/_base/di/fiber';
import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager';
import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag';
import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { ulid } from 'ulid';
import { okEnvelope } from '../envelope';
import type { MetaFeature } from '../protocol/rest-meta';
import { type IConnectionRegistry } from '../transport/ws/connectionRegistry';
import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster';
import type { TranscriptService } from '../services/transcript/transcriptService';
@ -110,6 +113,15 @@ export async function registerApiV1Routes(
await core.accessor.get(IConfigService).ready;
return core.accessor.get(IFlagService).snapshot();
},
getFeatures: () =>
core.accessor
.get(IFeatureManager)
.units()
.map((unit) => ({
name: unit.name,
state: FiberState[unit.state] as MetaFeature['state'],
meta: unit.meta,
})),
});
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);

View file

@ -2,6 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { IFeatureManager } from '@moonshot-ai/agent-core-v2/app/feature/featureManager';
import { getFeatureRecipes } from '@moonshot-ai/agent-core-v2/features/featureRegistry';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
@ -151,3 +153,71 @@ describe('/api/v1/meta web_title', () => {
expect(body.data.web_title).toBeUndefined();
});
});
describe('/api/v1/meta features', () => {
let server: RunningServer | undefined;
let home: string | undefined;
interface FeatureWire {
name: string;
state: string;
meta: Record<string, unknown>;
}
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
home = undefined;
}
});
async function boot(): Promise<string> {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-features-'));
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
return `http://127.0.0.1:${server.port}`;
}
async function getMetaFeatures(base: string): Promise<FeatureWire[]> {
const res = await authedFetch(server as RunningServer, base, '/api/v1/meta');
expect(res.status).toBe(200);
const body = (await res.json()) as { code: number; data: { features?: FeatureWire[] } };
expect(body.code).toBe(0);
expect(body.data.features).toBeDefined();
return body.data.features as FeatureWire[];
}
it('lists every registered built-in feature as Active with an empty meta', async () => {
const base = await boot();
const features = await getMetaFeatures(base);
const expected = getFeatureRecipes()
.map((recipe) => recipe.name)
.sort();
expect(features.map((feature) => feature.name).sort()).toEqual(expected);
for (const feature of features) {
expect(feature.state).toBe('Active');
expect(feature.meta).toEqual({});
}
});
it('drops a feature from the response after it is unprovided at runtime', async () => {
const base = await boot();
const before = await getMetaFeatures(base);
expect(before.some((feature) => feature.name === 'plan')).toBe(true);
await (server as RunningServer).core.accessor.get(IFeatureManager).unprovideUnit('plan');
const after = await getMetaFeatures(base);
expect(after.some((feature) => feature.name === 'plan')).toBe(false);
expect(after).toHaveLength(before.length - 1);
});
});