mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 21:56:00 +00:00
feat(kap-server): expose engine feature list on /api/v1/meta (#3085)
This commit is contained in:
parent
571bcc2f75
commit
cb8a7e5f81
9 changed files with 147 additions and 16 deletions
|
|
@ -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 |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue