mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(server-v2): add minimal server wired to agent-core-v2 DI
Add `packages/server-v2`, a minimal Fastify server bootstrapped through the
`agent-core-v2` DI engine (`bootstrap()` -> Core `Scope`), speaking the same
`/api/v1` interface as the v1 server. Route handlers resolve Core services
via `core.accessor.get(IXxx)`.
- Serve healthz, meta, auth, oauth (login poll/start/cancel/logout) and
shutdown with the standard `{code, msg, data, request_id}` envelope.
- Re-point root `pnpm dev:server` to server-v2 and add a `src/main.ts`
foreground entry.
- Add `build/hash-imports-loader.mjs` so tsx resolves `agent-core-v2`'s `#/`
array-fallback subpath imports.
- `agent-core-v2` Phase 0 fixes so it compiles and boots: add the `event`
domain (`IEventService`), fix `gateway` `event.subscribe` -> `event.on`, fix
the `wireRecord` fallback `BlobStoreService` construction, export the
`provider` barrel, and fix a `tooldedup` test import casing.
This commit is contained in:
parent
ca5aea91b6
commit
4e7209394c
35 changed files with 1748 additions and 7 deletions
79
build/hash-imports-loader.mjs
Normal file
79
build/hash-imports-loader.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
/**
|
||||
* Node ESM `resolve` hook: correctly resolve `#/` subpath imports against the
|
||||
* importing package's own `package.json` `imports` field — including array
|
||||
* fallbacks such as `"#/*": ["./src/*.ts", "./src/<name>/index.ts"]`.
|
||||
*
|
||||
* `tsx` (its resolver) only honors the first array element and therefore breaks
|
||||
* on directory-style `#/` imports (for example `#/_base/errors` →
|
||||
* `_base/errors/index.ts`). This loader short-circuits `#/` resolution before
|
||||
* tsx sees it, mirroring the Vite `hashImportsPlugin` used by the v2 tests.
|
||||
*/
|
||||
|
||||
const pkgCache = new Map();
|
||||
|
||||
function findPackageJson(fromFileUrl) {
|
||||
let dir = dirname(fileURLToPath(fromFileUrl));
|
||||
for (;;) {
|
||||
const candidate = join(dir, 'package.json');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function readPackage(pkgPath) {
|
||||
let pkg = pkgCache.get(pkgPath);
|
||||
if (pkg === undefined) {
|
||||
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
pkgCache.set(pkgPath, pkg);
|
||||
}
|
||||
return pkg;
|
||||
}
|
||||
|
||||
function resolveTarget(pkgDir, target, rest) {
|
||||
const resolved = rest === undefined ? target : target.replace('*', rest);
|
||||
const full = join(pkgDir, resolved);
|
||||
return existsSync(full) ? pathToFileURL(full).href : undefined;
|
||||
}
|
||||
|
||||
function resolveHashImport(specifier, parentURL) {
|
||||
if (parentURL === undefined) return undefined;
|
||||
const pkgPath = findPackageJson(parentURL);
|
||||
if (pkgPath === undefined) return undefined;
|
||||
const imports = readPackage(pkgPath).imports;
|
||||
if (imports === undefined) return undefined;
|
||||
const pkgDir = dirname(pkgPath);
|
||||
|
||||
for (const [key, raw] of Object.entries(imports)) {
|
||||
if (!key.startsWith('#')) continue;
|
||||
const targets = Array.isArray(raw) ? raw : [raw];
|
||||
if (key.endsWith('*')) {
|
||||
const prefix = key.slice(0, -1);
|
||||
if (!specifier.startsWith(prefix)) continue;
|
||||
const rest = specifier.slice(prefix.length);
|
||||
for (const target of targets) {
|
||||
const url = resolveTarget(pkgDir, target, rest);
|
||||
if (url !== undefined) return url;
|
||||
}
|
||||
} else if (specifier === key) {
|
||||
for (const target of targets) {
|
||||
const url = resolveTarget(pkgDir, target, undefined);
|
||||
if (url !== undefined) return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith('#/')) {
|
||||
const url = resolveHashImport(specifier, context.parentURL);
|
||||
if (url !== undefined) return { url, shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
8
build/register-hash-imports-loader.mjs
Normal file
8
build/register-hash-imports-loader.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { register } from 'node:module';
|
||||
|
||||
/**
|
||||
* Registers the `#/` subpath-import resolver. Pass to Node via `--import`
|
||||
* (alongside tsx) so source-executed v2 code resolves directory-style `#/`
|
||||
* imports (array fallback) that tsx's resolver mishandles.
|
||||
*/
|
||||
register('./hash-imports-loader.mjs', import.meta.url);
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
./packages/agent-core
|
||||
./packages/agent-core-v2
|
||||
./packages/server
|
||||
./packages/server-v2
|
||||
./packages/server-e2e
|
||||
./packages/kaos
|
||||
./packages/kimi-migration-legacy
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
"@moonshot-ai/agent-core"
|
||||
"@moonshot-ai/agent-core-v2"
|
||||
"@moonshot-ai/server"
|
||||
"@moonshot-ai/server-v2"
|
||||
"@moonshot-ai/server-e2e"
|
||||
"@moonshot-ai/kaos"
|
||||
"@moonshot-ai/kosong"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"build:packages": "pnpm -r --filter './packages/*' run build",
|
||||
"dev:cli": "pnpm -C apps/kimi-code run dev",
|
||||
"dev:web": "pnpm -C apps/kimi-web run dev",
|
||||
"dev:server": "pnpm -C apps/kimi-code run dev:server",
|
||||
"dev:server": "pnpm -C packages/server-v2 run dev",
|
||||
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
|
||||
"vis": "pnpm -C apps/vis run dev",
|
||||
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
|
||||
|
|
|
|||
23
packages/agent-core-v2/src/event/event.ts
Normal file
23
packages/agent-core-v2/src/event/event.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* `event` domain (L0) — process-wide pub/sub event bus contract.
|
||||
*
|
||||
* Defines `IEventService`, a minimal type-tagged event bus used by business
|
||||
* domains to broadcast facts (for example session lifecycle changes) to an
|
||||
* unknown set of consumers. Bound at Core scope; a single global instance.
|
||||
*/
|
||||
|
||||
import { createDecorator, type IDisposable, type ServiceIdentifier } from '#/_base/di';
|
||||
|
||||
export interface DomainEvent {
|
||||
readonly type: string;
|
||||
readonly payload: unknown;
|
||||
}
|
||||
|
||||
export interface IEventService {
|
||||
readonly _serviceBrand: undefined;
|
||||
publish(event: DomainEvent): void;
|
||||
subscribe(handler: (event: DomainEvent) => void): IDisposable;
|
||||
}
|
||||
|
||||
export const IEventService: ServiceIdentifier<IEventService> =
|
||||
createDecorator<IEventService>('eventService');
|
||||
35
packages/agent-core-v2/src/event/eventService.ts
Normal file
35
packages/agent-core-v2/src/event/eventService.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* `event` domain (L0) — `IEventService` implementation.
|
||||
*
|
||||
* Delivers published events to subscribers through the `_base/event` `Emitter`
|
||||
* primitive. Bound at Core scope.
|
||||
*/
|
||||
|
||||
import { Disposable, type IDisposable } from '#/_base/di';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter } from '#/_base/event';
|
||||
|
||||
import { type DomainEvent, IEventService } from './event';
|
||||
|
||||
export class EventService extends Disposable implements IEventService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly emitter = this._register(new Emitter<DomainEvent>());
|
||||
|
||||
publish(event: DomainEvent): void {
|
||||
this.emitter.fire(event);
|
||||
}
|
||||
|
||||
subscribe(handler: (event: DomainEvent) => void): IDisposable {
|
||||
return this.emitter.event(handler);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Core,
|
||||
IEventService,
|
||||
EventService,
|
||||
InstantiationType.Delayed,
|
||||
'event',
|
||||
);
|
||||
8
packages/agent-core-v2/src/event/index.ts
Normal file
8
packages/agent-core-v2/src/event/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* `event` domain barrel — re-exports the event bus contract (`event`) and its
|
||||
* scoped service (`eventService`). Importing this barrel registers the
|
||||
* `IEventService` binding into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './event';
|
||||
export * from './eventService';
|
||||
|
|
@ -96,8 +96,10 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
|
|||
|
||||
constructor(@IEventSink event: IEventSink) {
|
||||
super();
|
||||
event.subscribe(() => {
|
||||
});
|
||||
this._register(
|
||||
event.on(() => {
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export * from './kosong/index';
|
|||
export * from './sessionIndex/index';
|
||||
export * from './sessionMetaStore/index';
|
||||
export * from './config/index';
|
||||
export * from './provider/index';
|
||||
|
||||
import './skill/index';
|
||||
export * from './permission/index';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
IBlobStoreService,
|
||||
type BlobStoreServiceOptions,
|
||||
} from '#/blobStore';
|
||||
import { IHostFileSystem } from '#/hostFs';
|
||||
import { IAppendLogStore } from '#/storage';
|
||||
import { BlobStoreService } from '../blobStore/blobStoreService';
|
||||
import { OrderedHookSlot } from '../hooks';
|
||||
|
|
@ -62,6 +63,7 @@ export class WireRecordService extends Disposable implements IWireRecord {
|
|||
private readonly options: WireRecordServiceOptions = {},
|
||||
@IBlobStoreService injectedBlobStore?: IBlobStoreService,
|
||||
@IAppendLogStore private readonly log?: IAppendLogStore,
|
||||
@IHostFileSystem private readonly hostFs?: IHostFileSystem,
|
||||
) {
|
||||
super();
|
||||
this.blobStore = this.resolveBlobStore(options, injectedBlobStore);
|
||||
|
|
@ -328,12 +330,12 @@ export class WireRecordService extends Disposable implements IWireRecord {
|
|||
injectedBlobStore: IBlobStoreService | undefined,
|
||||
): IBlobStoreService | undefined {
|
||||
if (options.blobStore !== undefined) return options.blobStore;
|
||||
if (options.homedir === undefined) return injectedBlobStore;
|
||||
if (options.homedir === undefined || this.hostFs === undefined) return injectedBlobStore;
|
||||
|
||||
const blobOptions: BlobStoreServiceOptions = {
|
||||
blobsDir: join(options.homedir, 'blobs'),
|
||||
};
|
||||
return new BlobStoreService(blobOptions);
|
||||
return new BlobStoreService(blobOptions, this.hostFs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
|
|||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { ITelemetryService, type TelemetryProperties } from '#/telemetry/telemetry';
|
||||
import { IToolDedupe, type ToolDedupResult } from '#/toolDedup/toolDedupe';
|
||||
import { ToolDedupeService, __testing } from '#/toolDedup/toolDedupeService';
|
||||
import { IToolDedupe, type ToolDedupResult } from '#/tooldedup/toolDedupe';
|
||||
import { ToolDedupeService, __testing } from '#/tooldedup/toolDedupeService';
|
||||
import { ITurnService } from '#/turn';
|
||||
import { stubTurnWithHooks } from '../turn/stubs';
|
||||
|
||||
|
|
|
|||
32
packages/server-v2/package.json
Normal file
32
packages/server-v2/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@moonshot-ai/server-v2",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Kimi Code server backed by the DI × Scope agent engine (agent-core-v2)",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs --import ../../build/register-hash-imports-loader.mjs ./src/main.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@moonshot-ai/agent-core-v2": "workspace:^",
|
||||
"@moonshot-ai/protocol": "workspace:^",
|
||||
"fastify": "^5.1.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"ulid": "^3.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
9
packages/server-v2/src/env.d.ts
vendored
Normal file
9
packages/server-v2/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Raw-string imports for prompt sources. `agent-core-v2` loads several prompt
|
||||
// templates via `*.md?raw`; this declaration lets server-v2's typecheck process
|
||||
// those transitive imports. Vite/Vitest handles `?raw` natively; tsdown uses the
|
||||
// shared `raw-text-plugin` for the same import shape.
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
12
packages/server-v2/src/envelope.ts
Normal file
12
packages/server-v2/src/envelope.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Re-export the envelope helpers from `@moonshot-ai/protocol`.
|
||||
*
|
||||
* The wire-shape source of truth lives in `@moonshot-ai/protocol`. Re-exporting
|
||||
* the protocol helpers preserves field order and JSON output for server
|
||||
* responses.
|
||||
*
|
||||
* Keep this file as a re-export shim (not a direct re-export from the package
|
||||
* barrel) so downstream `from './envelope'` imports inside the server stay
|
||||
* stable and don't all need to be touched.
|
||||
*/
|
||||
export { okEnvelope, errEnvelope, type Envelope } from '@moonshot-ai/protocol';
|
||||
51
packages/server-v2/src/error-handler.ts
Normal file
51
packages/server-v2/src/error-handler.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Fastify error hook for unhandled server exceptions.
|
||||
*
|
||||
* Wraps unhandled exceptions in the Feishu-style envelope:
|
||||
* - HTTP status ALWAYS 200 (business outcome lives in `code`);
|
||||
* - `code: 50001` (`internal.error`) for unknown exceptions;
|
||||
* - `request_id` echoes the inbound request id (set by Fastify's
|
||||
* `genReqId` via `resolveRequestId`);
|
||||
* - `data: null`.
|
||||
*
|
||||
* Validation failures are handled by route-level middleware as 40001
|
||||
* `validation.failed`; this handler remains the catch-all unknown-exception path.
|
||||
*
|
||||
* The handler logs `err` + the resolved `request_id` so operators can
|
||||
* correlate log lines with the envelope returned to the client. This is the
|
||||
* single place a stack trace ever crosses our process boundary into a log —
|
||||
* we never bleed it into the JSON response.
|
||||
*/
|
||||
|
||||
import { errEnvelope, ErrorCode } from '@moonshot-ai/protocol';
|
||||
import type { FastifyError } from 'fastify';
|
||||
|
||||
/**
|
||||
* Loose Fastify-instance shape so this helper accepts both the default
|
||||
* `FastifyInstance` and the server's pino-typed variant
|
||||
* (`FastifyInstance<…, ServerLogger>`). The type checker chokes on the
|
||||
* concrete generic mismatch otherwise.
|
||||
*/
|
||||
interface ErrorHandlerHost {
|
||||
setErrorHandler(
|
||||
handler: (
|
||||
err: FastifyError,
|
||||
req: { id: string; log: { error: (obj: object | string, msg?: string) => void } },
|
||||
reply: { status(code: number): { send(payload: unknown): void } },
|
||||
) => void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export function installErrorHandler(app: ErrorHandlerHost): void {
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
const requestId = req.id;
|
||||
req.log.error({ err, request_id: requestId }, 'unhandled error');
|
||||
reply.status(200).send(
|
||||
errEnvelope(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
err.message !== undefined && err.message !== '' ? err.message : 'internal error',
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
15
packages/server-v2/src/index.ts
Normal file
15
packages/server-v2/src/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* `@moonshot-ai/server-v2` public surface — the Kimi Code server backed by the
|
||||
* DI × Scope agent engine (`@moonshot-ai/agent-core-v2`).
|
||||
*/
|
||||
|
||||
export { startServer } from './start';
|
||||
export type { ServerStartOptions, RunningServer } from './start';
|
||||
export { okEnvelope, errEnvelope } from './envelope';
|
||||
export type { Envelope } from './envelope';
|
||||
export { createServerLogger } from './services/pinoLoggerService';
|
||||
export type {
|
||||
CreateLoggerOptions,
|
||||
ServerLogger,
|
||||
ServerLogLevel,
|
||||
} from './services/pinoLoggerService';
|
||||
94
packages/server-v2/src/main.ts
Normal file
94
packages/server-v2/src/main.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* CLI entry for `pnpm dev:server` — boots server-v2 (the `@moonshot-ai/agent-core-v2`
|
||||
* DI engine) in the foreground and blocks until SIGINT/SIGTERM.
|
||||
*
|
||||
* Flags: `--port <n>` (default 58627), `--host <h>` (default 127.0.0.1),
|
||||
* `--log-level <level>` (default info).
|
||||
*/
|
||||
|
||||
import type { ServerLogLevel } from './services/pinoLoggerService';
|
||||
import { type RunningServer, startServer } from './start';
|
||||
|
||||
interface CliOptions {
|
||||
readonly host?: string;
|
||||
readonly port?: number;
|
||||
readonly logLevel?: ServerLogLevel;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: readonly ServerLogLevel[] = [
|
||||
'fatal',
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'debug',
|
||||
'trace',
|
||||
'silent',
|
||||
];
|
||||
|
||||
function parseArgs(argv: readonly string[]): CliOptions {
|
||||
let host: string | undefined;
|
||||
let port: number | undefined;
|
||||
let logLevel: ServerLogLevel | undefined;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
if ((arg === '--host' || arg === '-h') && next !== undefined) {
|
||||
host = next;
|
||||
i++;
|
||||
} else if ((arg === '--port' || arg === '-p') && next !== undefined) {
|
||||
const n = Number(next);
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 65535) port = n;
|
||||
i++;
|
||||
} else if (
|
||||
arg === '--log-level' &&
|
||||
next !== undefined &&
|
||||
(LOG_LEVELS as readonly string[]).includes(next)
|
||||
) {
|
||||
logLevel = next as ServerLogLevel;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { host, port, logLevel };
|
||||
}
|
||||
|
||||
async function main(): Promise<never> {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
const server: RunningServer = await startServer({
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
logLevel: opts.logLevel ?? 'info',
|
||||
});
|
||||
|
||||
const origin = `http://${server.host}:${server.port}`;
|
||||
process.stdout.write(`Kimi server (v2) ready at ${origin}\n`);
|
||||
|
||||
let stopping = false;
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown();
|
||||
});
|
||||
|
||||
return new Promise<never>(() => {
|
||||
// Keeps the event loop alive; the process ends via shutdown()/process.exit.
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
314
packages/server-v2/src/middleware/defineRoute.ts
Normal file
314
packages/server-v2/src/middleware/defineRoute.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* `defineRoute` — single-source-of-truth route declaration helper.
|
||||
*
|
||||
* One object declares **both** the runtime Zod validators (preHandler) and the
|
||||
* Swagger/OpenAPI response schema. The 200-response is automatically expanded
|
||||
* into a `oneOf` union that covers the success envelope (code: 0) and every
|
||||
* declared error envelope (code: 4xxxx / 5xxxx) with its precise `details`
|
||||
* shape.
|
||||
*
|
||||
* Path params use OpenAPI `{param}` syntax; the helper converts them to
|
||||
* Fastify `:param` syntax internally.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* const route = defineRoute({
|
||||
* method: 'POST',
|
||||
* path: '/sessions/{session_id}/prompts',
|
||||
* body: promptSubmissionSchema,
|
||||
* params: sessionIdParamSchema,
|
||||
* success: { data: promptSubmitResultSchema },
|
||||
* errors: {
|
||||
* 40001: { detailsSchema: z.array(z.object({ path: z.string(), message: z.string() })) },
|
||||
* 40110: {},
|
||||
* 40111: { detailsSchema: z.object({ provider_id: z.string() }) },
|
||||
* 40113: { detailsSchema: z.object({ model_id: z.string() }).partial() },
|
||||
* 40401: {},
|
||||
* // Errors that carry non-null data (e.g. idempotent conflicts):
|
||||
* // 40903: { dataSchema: z.object({ aborted: z.literal(false) }) },
|
||||
* },
|
||||
* description: 'Submit a prompt to a session',
|
||||
* tags: ['prompts'],
|
||||
* }, async (req, reply) => {
|
||||
* // req.body → PromptSubmission (inferred from promptSubmissionSchema)
|
||||
* // req.params → { session_id: string } (inferred from sessionIdParamSchema)
|
||||
* });
|
||||
*
|
||||
* app.post(route.path, route.options, route.handler);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { jsonSchema, openApiDocumentJsonSchema } from './schema';
|
||||
import { validateBody, validateParams, validateQuery } from './validate';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert OpenAPI `{param}` segments to Fastify `:param` segments. */
|
||||
function toFastifyPath(openApiPath: string): string {
|
||||
return openApiPath.replace(/\{([^}]+)\}/g, ':$1');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a Zod schema for an error envelope with a specific code. */
|
||||
function buildErrorEnvelopeSchema(
|
||||
code: number,
|
||||
dataSchema: z.ZodTypeAny = z.null(),
|
||||
detailsSchema?: z.ZodTypeAny,
|
||||
): z.ZodTypeAny {
|
||||
const base = z.object({
|
||||
code: z.literal(code),
|
||||
msg: z.string(),
|
||||
data: dataSchema,
|
||||
request_id: z.string(),
|
||||
});
|
||||
|
||||
if (detailsSchema) {
|
||||
return base.extend({
|
||||
details: detailsSchema.nullable().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
return base.extend({
|
||||
details: z.unknown().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a Zod schema for the success envelope (code: 0). */
|
||||
function buildSuccessEnvelopeSchema(successDataSchema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
return z.object({
|
||||
code: z.literal(0),
|
||||
msg: z.string(),
|
||||
data: successDataSchema,
|
||||
request_id: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the unified 200-response schema.
|
||||
*
|
||||
* When error variants are present: returns a `oneOf` array where each variant
|
||||
* is an OpenAPI 3 schema object (success first, then errors in ascending code
|
||||
* order).
|
||||
*
|
||||
* When no error variants are present: returns the success envelope schema
|
||||
* directly for simpler Swagger output and backward compatibility with tests
|
||||
* that expect a plain schema object.
|
||||
*/
|
||||
function buildUnifiedResponseSchema(
|
||||
successDataSchema: z.ZodTypeAny,
|
||||
errors: Record<number, { dataSchema?: z.ZodTypeAny; detailsSchema?: z.ZodTypeAny }>,
|
||||
): Record<string, unknown> {
|
||||
// Error variants — sorted by code for deterministic output
|
||||
const errorEntries = Object.entries(errors)
|
||||
.map(([code, cfg]) => [Number(code), cfg] as const)
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
// No errors → return the plain envelope schema (not wrapped in oneOf)
|
||||
if (errorEntries.length === 0) {
|
||||
return openApiDocumentJsonSchema(
|
||||
buildSuccessEnvelopeSchema(successDataSchema),
|
||||
'output',
|
||||
);
|
||||
}
|
||||
|
||||
const variants: Record<string, unknown>[] = [];
|
||||
|
||||
// Success variant
|
||||
variants.push(
|
||||
openApiDocumentJsonSchema(
|
||||
buildSuccessEnvelopeSchema(successDataSchema),
|
||||
'output',
|
||||
),
|
||||
);
|
||||
|
||||
for (const [code, cfg] of errorEntries) {
|
||||
variants.push(
|
||||
openApiDocumentJsonSchema(
|
||||
buildErrorEnvelopeSchema(code, cfg.dataSchema, cfg.detailsSchema),
|
||||
'output',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { oneOf: variants };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InferZod<T extends z.ZodTypeAny | undefined> = T extends z.ZodTypeAny
|
||||
? z.infer<T>
|
||||
: unknown;
|
||||
|
||||
export interface DefineRouteOptions<
|
||||
TBody extends z.ZodTypeAny | undefined,
|
||||
TParams extends z.ZodTypeAny | undefined,
|
||||
TQuery extends z.ZodTypeAny | undefined,
|
||||
TSuccessData extends z.ZodTypeAny | undefined,
|
||||
> {
|
||||
/** HTTP method (used by the consumer for registration bookkeeping). */
|
||||
method: string;
|
||||
/** Route path with OpenAPI `{param}` syntax. */
|
||||
path: string;
|
||||
/** Request-body Zod schema. */
|
||||
body?: TBody;
|
||||
/** Route-params Zod schema. */
|
||||
params?: TParams;
|
||||
/** Query-string Zod schema. */
|
||||
querystring?: TQuery;
|
||||
/** Success payload schema (wrapped in envelope code:0 automatically). */
|
||||
success?: { data: TSuccessData };
|
||||
/**
|
||||
* Error variants for the 200-response oneOf.
|
||||
* Key = business error code.
|
||||
* `dataSchema` defaults to `z.null()`; override for idempotent-conflict
|
||||
* shapes such as `{code:40903, data:{aborted:false}}`.
|
||||
* `detailsSchema` describes the structured error context when present.
|
||||
*/
|
||||
errors?: Record<number, { dataSchema?: z.ZodTypeAny; detailsSchema?: z.ZodTypeAny }>;
|
||||
/**
|
||||
* Raw response schemas that are NOT envelope-wrapped.
|
||||
* Useful for binary-stream endpoints (e.g. file download).
|
||||
*/
|
||||
rawResponse?: Record<number, Record<string, unknown>>;
|
||||
/** Swagger description. */
|
||||
description?: string;
|
||||
/** Swagger summary. */
|
||||
summary?: string;
|
||||
/** Swagger tags. */
|
||||
tags?: string[];
|
||||
/** Swagger operationId. */
|
||||
operationId?: string;
|
||||
/** Swagger consumes. */
|
||||
consumes?: string[];
|
||||
}
|
||||
|
||||
export interface RouteDefinition<
|
||||
TBody extends z.ZodTypeAny | undefined,
|
||||
TParams extends z.ZodTypeAny | undefined,
|
||||
TQuery extends z.ZodTypeAny | undefined,
|
||||
> {
|
||||
method: string;
|
||||
/** Fastify-style path (`:param`). */
|
||||
path: string;
|
||||
options: {
|
||||
preHandler: unknown[];
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
handler: (
|
||||
req: {
|
||||
id: string;
|
||||
body: InferZod<TBody>;
|
||||
params: InferZod<TParams>;
|
||||
headers: Record<string, unknown>;
|
||||
} & (TQuery extends z.ZodTypeAny ? { query: InferZod<TQuery> } : {}),
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Declare a route from a single Zod-based definition.
|
||||
*
|
||||
* Returns a `RouteDefinition` carrying:
|
||||
* - `path` – converted to Fastify `:param` syntax
|
||||
* - `options` – `preHandler` (runtime validation) + `schema` (Swagger)
|
||||
* - `handler` – typed request / reply callback
|
||||
*
|
||||
* The caller is responsible for registering the definition on the correct
|
||||
* app verb, e.g. `app.post(route.path, route.options, route.handler)`.
|
||||
*/
|
||||
export function defineRoute<
|
||||
TBody extends z.ZodTypeAny | undefined,
|
||||
TParams extends z.ZodTypeAny | undefined,
|
||||
TQuery extends z.ZodTypeAny | undefined,
|
||||
TSuccessData extends z.ZodTypeAny | undefined,
|
||||
>(
|
||||
options: DefineRouteOptions<TBody, TParams, TQuery, TSuccessData>,
|
||||
handler: RouteDefinition<TBody, TParams, TQuery>['handler'],
|
||||
): RouteDefinition<TBody, TParams, TQuery> {
|
||||
// -- runtime validators ----------------------------------------------------
|
||||
const preHandler: unknown[] = [];
|
||||
|
||||
if (options.params) {
|
||||
preHandler.push(validateParams(options.params));
|
||||
}
|
||||
if (options.body) {
|
||||
preHandler.push(validateBody(options.body));
|
||||
}
|
||||
if (options.querystring) {
|
||||
preHandler.push(validateQuery(options.querystring));
|
||||
}
|
||||
|
||||
// -- swagger schema --------------------------------------------------------
|
||||
const schema: Record<string, unknown> = {};
|
||||
|
||||
if (options.body) {
|
||||
schema['body'] = jsonSchema(options.body);
|
||||
}
|
||||
if (options.params) {
|
||||
schema['params'] = jsonSchema(options.params);
|
||||
}
|
||||
if (options.querystring) {
|
||||
schema['querystring'] = jsonSchema(options.querystring);
|
||||
}
|
||||
|
||||
const hasResponse =
|
||||
options.success !== undefined ||
|
||||
(options.errors !== undefined && Object.keys(options.errors).length > 0) ||
|
||||
options.rawResponse !== undefined;
|
||||
|
||||
if (hasResponse) {
|
||||
const responses: Record<string, unknown> = {};
|
||||
|
||||
if (options.success || options.errors) {
|
||||
responses['200'] = buildUnifiedResponseSchema(
|
||||
options.success?.data ?? z.null(),
|
||||
options.errors ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.rawResponse) {
|
||||
for (const [code, rawSchema] of Object.entries(options.rawResponse)) {
|
||||
responses[String(code)] = rawSchema;
|
||||
}
|
||||
}
|
||||
|
||||
schema['response'] = responses;
|
||||
}
|
||||
|
||||
if (options.description) {
|
||||
schema['description'] = options.description;
|
||||
}
|
||||
if (options.summary) {
|
||||
schema['summary'] = options.summary;
|
||||
}
|
||||
if (options.tags) {
|
||||
schema['tags'] = options.tags;
|
||||
}
|
||||
if (options.operationId) {
|
||||
schema['operationId'] = options.operationId;
|
||||
}
|
||||
if (options.consumes) {
|
||||
schema['consumes'] = options.consumes;
|
||||
}
|
||||
|
||||
return {
|
||||
method: options.method,
|
||||
path: toFastifyPath(options.path),
|
||||
options: { preHandler, schema },
|
||||
handler,
|
||||
};
|
||||
}
|
||||
158
packages/server-v2/src/middleware/schema.ts
Normal file
158
packages/server-v2/src/middleware/schema.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Zod → JSON Schema conversion helpers for Fastify `@fastify/swagger`.
|
||||
*
|
||||
* All server REST responses are wrapped in a uniform envelope
|
||||
* `{ code, msg, data, request_id }`. The helpers here let route modules
|
||||
* declare their OpenAPI schema by re-using the same Zod schemas that
|
||||
* already drive runtime validation — no second source of truth.
|
||||
*/
|
||||
|
||||
import { envelopeSchema } from '@moonshot-ai/protocol';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Convert a Zod schema to a plain JSON Schema object suitable for
|
||||
* Fastify's `schema` option.
|
||||
*
|
||||
* We drop the top-level `$schema` key because Fastify/OpenAPI inline
|
||||
* schemas don't need it.
|
||||
*/
|
||||
export function jsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
|
||||
return jsonSchemaForTarget(schema, 'input', 'draft-7');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Zod schema to a response-side Fastify JSON Schema object.
|
||||
*/
|
||||
export function outputJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
|
||||
return jsonSchemaForTarget(schema, 'output', 'draft-7');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Zod schema directly to an OpenAPI 3 schema object.
|
||||
*
|
||||
* Fastify route schemas use draft-7 because Fastify validates/serializes with
|
||||
* AJV; `@fastify/swagger` converts those schemas to OpenAPI. Post-processing
|
||||
* hooks run after that conversion, so schemas inserted there must already use
|
||||
* OpenAPI 3 semantics.
|
||||
*/
|
||||
export function openApiDocumentJsonSchema(
|
||||
schema: z.ZodTypeAny,
|
||||
io: 'input' | 'output' = 'input',
|
||||
): Record<string, unknown> {
|
||||
return jsonSchemaForTarget(schema, io, 'openapi-3.0');
|
||||
}
|
||||
|
||||
function jsonSchemaForTarget(
|
||||
schema: z.ZodTypeAny,
|
||||
io: 'input' | 'output',
|
||||
target: 'draft-7' | 'openapi-3.0',
|
||||
): Record<string, unknown> {
|
||||
const converted = z.toJSONSchema(schema, {
|
||||
target,
|
||||
io,
|
||||
unrepresentable: 'any',
|
||||
}) as Record<string, unknown>;
|
||||
if (converted['$schema'] !== undefined) {
|
||||
delete converted['$schema'];
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a data Zod schema in the server's envelope shape and return its
|
||||
* JSON Schema representation.
|
||||
*/
|
||||
export function envelopeJsonSchema(
|
||||
dataSchema: z.ZodTypeAny,
|
||||
): Record<string, unknown> {
|
||||
return outputJsonSchema(envelopeSchema(dataSchema));
|
||||
}
|
||||
|
||||
export function openApiDocumentEnvelopeJsonSchema(
|
||||
dataSchema: z.ZodTypeAny,
|
||||
): Record<string, unknown> {
|
||||
return openApiDocumentJsonSchema(envelopeSchema(dataSchema), 'output');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fastify route-schema bag from Zod schemas + metadata.
|
||||
*
|
||||
* All Zod fields are automatically converted via `jsonSchema()`.
|
||||
* The `response` map values are also wrapped in envelopes unless you
|
||||
* pass an explicit `rawResponse` option.
|
||||
*/
|
||||
export interface RouteSchemaOptions {
|
||||
/** Request body Zod schema. */
|
||||
body?: z.ZodTypeAny;
|
||||
/** Query-string Zod schema. */
|
||||
querystring?: z.ZodTypeAny;
|
||||
/** Route params Zod schema. */
|
||||
params?: z.ZodTypeAny;
|
||||
/**
|
||||
* Response schema map: status code → Zod schema.
|
||||
* Each schema is automatically wrapped in the envelope.
|
||||
* Use `rawResponse` if you need an unwrapped schema (e.g. binary
|
||||
* download success path).
|
||||
*/
|
||||
response?: Record<number, z.ZodTypeAny>;
|
||||
/**
|
||||
* Response schema map that is NOT envelope-wrapped.
|
||||
* Useful for the `200` on binary-stream endpoints.
|
||||
*/
|
||||
rawResponse?: Record<number, Record<string, unknown>>;
|
||||
description?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
operationId?: string;
|
||||
consumes?: string[];
|
||||
produces?: string[];
|
||||
}
|
||||
|
||||
export function buildRouteSchema(options: RouteSchemaOptions): Record<string, unknown> {
|
||||
const schema: Record<string, unknown> = {};
|
||||
|
||||
if (options.body) {
|
||||
schema['body'] = jsonSchema(options.body);
|
||||
}
|
||||
if (options.querystring) {
|
||||
schema['querystring'] = jsonSchema(options.querystring);
|
||||
}
|
||||
if (options.params) {
|
||||
schema['params'] = jsonSchema(options.params);
|
||||
}
|
||||
if (options.response || options.rawResponse) {
|
||||
const responses: Record<string, unknown> = {};
|
||||
if (options.response) {
|
||||
for (const [code, zodSchema] of Object.entries(options.response)) {
|
||||
responses[String(code)] = envelopeJsonSchema(zodSchema);
|
||||
}
|
||||
}
|
||||
if (options.rawResponse) {
|
||||
for (const [code, rawSchema] of Object.entries(options.rawResponse)) {
|
||||
responses[String(code)] = rawSchema;
|
||||
}
|
||||
}
|
||||
schema['response'] = responses;
|
||||
}
|
||||
if (options.description) {
|
||||
schema['description'] = options.description;
|
||||
}
|
||||
if (options.summary) {
|
||||
schema['summary'] = options.summary;
|
||||
}
|
||||
if (options.tags) {
|
||||
schema['tags'] = options.tags;
|
||||
}
|
||||
if (options.operationId) {
|
||||
schema['operationId'] = options.operationId;
|
||||
}
|
||||
if (options.consumes) {
|
||||
schema['consumes'] = options.consumes;
|
||||
}
|
||||
if (options.produces) {
|
||||
schema['produces'] = options.produces;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
132
packages/server-v2/src/middleware/validate.ts
Normal file
132
packages/server-v2/src/middleware/validate.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Generic Zod body / query / params validators for Fastify routes.
|
||||
*
|
||||
* On validation success: the parsed value replaces the raw input on the
|
||||
* request object so handlers operate on a `T`-typed payload (no need to
|
||||
* re-parse). On failure: we send a `40001 validation.failed` envelope with a
|
||||
* `details` array of `{path, message}` issues (REST.md §1.4) and return early
|
||||
* — the handler does not run.
|
||||
*
|
||||
* The envelope shape matches `errEnvelope(40001, ...)` with the extra
|
||||
* `details` field at top level. REST.md §1.4 specifies `details` as a free
|
||||
* field per code; for `40001` clients expect `Array<{path, message}>` so they
|
||||
* can highlight the offending field. We extend `errEnvelope`'s output rather
|
||||
* than reshape it — this is the single error code that carries structured
|
||||
* details so a one-off shape is acceptable.
|
||||
*
|
||||
* Note: route-level Zod failures land here at the preHandler stage and never
|
||||
* reach the generic error hook, which only emits 50001 for unknown exceptions.
|
||||
*/
|
||||
|
||||
import { ErrorCode } from '@moonshot-ai/protocol';
|
||||
import type { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Minimal Fastify request/reply shapes — keep our hook independent of the
|
||||
* concrete generic parameters so it works against the server's pino-typed
|
||||
* variant without TS friction.
|
||||
*/
|
||||
interface ValidationRequest {
|
||||
id: string;
|
||||
body?: unknown;
|
||||
query?: unknown;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface ValidationReply {
|
||||
send(payload: unknown): unknown;
|
||||
}
|
||||
|
||||
type PreHandlerHook = (
|
||||
req: ValidationRequest,
|
||||
reply: ValidationReply,
|
||||
done: (err?: Error) => void,
|
||||
) => void;
|
||||
|
||||
interface ValidationDetailItem {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function zodIssuesToDetails(error: z.ZodError): ValidationDetailItem[] {
|
||||
return error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildValidationEnvelope(
|
||||
details: ValidationDetailItem[],
|
||||
requestId: string,
|
||||
): {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: null;
|
||||
request_id: string;
|
||||
details: ValidationDetailItem[];
|
||||
} {
|
||||
const first = details[0];
|
||||
const msg = first === undefined
|
||||
? 'validation failed'
|
||||
: first.path === ''
|
||||
? first.message
|
||||
: `${first.path}: ${first.message}`;
|
||||
return {
|
||||
code: ErrorCode.VALIDATION_FAILED,
|
||||
msg,
|
||||
data: null,
|
||||
request_id: requestId,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fastify `preHandler` that parses `req.body` against `schema`.
|
||||
* On success, replaces `req.body` with the parsed value.
|
||||
*/
|
||||
export function validateBody<T>(schema: z.ZodType<T>): PreHandlerHook {
|
||||
return (req, reply, done) => {
|
||||
const result = schema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
|
||||
return;
|
||||
}
|
||||
req.body = result.data;
|
||||
done();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fastify `preHandler` that parses `req.query` against `schema`.
|
||||
* On success, replaces `req.query` with the parsed value.
|
||||
*
|
||||
* Fastify deserializes query strings as `Record<string, string>` — so numeric
|
||||
* fields arrive as strings. The schema is responsible for coercing
|
||||
* (`z.coerce.number()` etc.) when needed; we don't pre-coerce here.
|
||||
*/
|
||||
export function validateQuery<T>(schema: z.ZodType<T>): PreHandlerHook {
|
||||
return (req, reply, done) => {
|
||||
const result = schema.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
|
||||
return;
|
||||
}
|
||||
req.query = result.data;
|
||||
done();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fastify `preHandler` that parses `req.params` against `schema`.
|
||||
*/
|
||||
export function validateParams<T>(schema: z.ZodType<T>): PreHandlerHook {
|
||||
return (req, reply, done) => {
|
||||
const result = schema.safeParse(req.params);
|
||||
if (!result.success) {
|
||||
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
|
||||
return;
|
||||
}
|
||||
req.params = result.data;
|
||||
done();
|
||||
};
|
||||
}
|
||||
21
packages/server-v2/src/request-id.ts
Normal file
21
packages/server-v2/src/request-id.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* `request_id` resolution at the server's REST boundary.
|
||||
*
|
||||
* Delegates to `parseOrGenerateRequestId` from `@moonshot-ai/protocol`, which:
|
||||
* - returns a bare 26-char ULID (no `req_` prefix);
|
||||
* - validates client-supplied `X-Request-Id` is a real ULID and regenerates
|
||||
* a fresh one on malformed input so operator logs do not carry
|
||||
* attacker-controlled strings verbatim.
|
||||
*/
|
||||
|
||||
import { parseOrGenerateRequestId } from '@moonshot-ai/protocol';
|
||||
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
export function resolveRequestId(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): string {
|
||||
const raw = headers[REQUEST_ID_HEADER];
|
||||
const supplied = Array.isArray(raw) ? raw[0] : raw;
|
||||
return parseOrGenerateRequestId(supplied);
|
||||
}
|
||||
62
packages/server-v2/src/routes/auth.ts
Normal file
62
packages/server-v2/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* `GET /auth` — readiness probe.
|
||||
*
|
||||
* Single readiness signal that web/IDE clients hit on first paint to decide
|
||||
* between onboarding vs. chat UI. Returns 200 + envelope regardless of provider
|
||||
* state.
|
||||
*
|
||||
* v2's `IAuthSummaryService.summarize()` returns a per-provider `AuthStatus[]`
|
||||
* (`{ loggedIn, provider? }`), which is a simpler model than the v1
|
||||
* `AuthSummary` wire shape (`{ ready, providers_count, default_model,
|
||||
* managed_provider }`). This handler projects the v2 snapshot onto the v1 wire
|
||||
* shape: `ready` reflects any authenticated provider, `providers_count` counts
|
||||
* the snapshot entries, `default_model` is `null` (v2 has no model catalog
|
||||
* yet), and `managed_provider` surfaces the authenticated provider when present.
|
||||
*/
|
||||
|
||||
import { IAuthSummaryService, type Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import { authSummarySchema } from '@moonshot-ai/protocol';
|
||||
import type { AuthSummary } from '@moonshot-ai/protocol';
|
||||
|
||||
import { okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
|
||||
interface RouteHost {
|
||||
get(
|
||||
path: string,
|
||||
options: { schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): void },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export function registerAuthRoute(app: RouteHost, core: Scope): void {
|
||||
const route = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/auth',
|
||||
success: { data: authSummarySchema },
|
||||
description: 'Get server auth readiness snapshot',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const statuses = await core.accessor.get(IAuthSummaryService).summarize();
|
||||
const authenticated = statuses.find((s) => s.loggedIn);
|
||||
const firstNamed = statuses.find((s) => s.provider !== undefined);
|
||||
const summary: AuthSummary = {
|
||||
ready: authenticated !== undefined,
|
||||
providers_count: statuses.length,
|
||||
default_model: null,
|
||||
managed_provider: authenticated?.provider !== undefined
|
||||
? { name: authenticated.provider, status: 'authenticated' }
|
||||
: firstNamed?.provider !== undefined
|
||||
? { name: firstNamed.provider, status: 'unauthenticated' }
|
||||
: null,
|
||||
};
|
||||
reply.send(okEnvelope(summary, req.id));
|
||||
},
|
||||
);
|
||||
app.get(route.path, route.options, route.handler as Parameters<RouteHost['get']>[2]);
|
||||
}
|
||||
69
packages/server-v2/src/routes/meta.ts
Normal file
69
packages/server-v2/src/routes/meta.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* `GET /meta` route handler.
|
||||
*
|
||||
* Returns `server_version`, the declared `capabilities` map, a per-process
|
||||
* `server_id` (ULID minted at boot), and `started_at`.
|
||||
*
|
||||
* **Capabilities**: the wire schema (`metaCapabilitiesSchema`) only permits the
|
||||
* literal `true` for each capability, so this mirrors the v1 response exactly to
|
||||
* keep the interface unchanged. server-v2 v0.1 does not yet back every
|
||||
* capability (no WebSocket / file upload / fs query / mcp / terminal); clients
|
||||
* must treat unbacked capabilities as not-yet-available until the corresponding
|
||||
* routes are wired.
|
||||
*
|
||||
* **No DI**: pure server-self info; the payload is frozen at registration time.
|
||||
*/
|
||||
|
||||
import { metaResponseSchema } from '@moonshot-ai/protocol';
|
||||
import type { MetaResponse } from '@moonshot-ai/protocol';
|
||||
|
||||
import { okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
|
||||
interface RouteHost {
|
||||
get(
|
||||
path: string,
|
||||
options: { schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): void },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export interface MetaRouteOptions {
|
||||
readonly serverVersion: string;
|
||||
readonly serverId: string;
|
||||
readonly startedAt: string;
|
||||
}
|
||||
|
||||
export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void {
|
||||
const data: MetaResponse = Object.freeze({
|
||||
server_version: opts.serverVersion,
|
||||
capabilities: Object.freeze({
|
||||
websocket: true as const,
|
||||
file_upload: true as const,
|
||||
fs_query: true as const,
|
||||
mcp: true as const,
|
||||
background_tasks: true as const,
|
||||
terminal: true as const,
|
||||
}),
|
||||
server_id: opts.serverId,
|
||||
started_at: opts.startedAt,
|
||||
open_in_apps: [],
|
||||
});
|
||||
|
||||
const route = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/meta',
|
||||
success: { data: metaResponseSchema },
|
||||
description: 'Get server metadata',
|
||||
tags: ['meta'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
reply.send(okEnvelope(data, req.id));
|
||||
},
|
||||
);
|
||||
app.get(route.path, route.options, route.handler as Parameters<RouteHost['get']>[2]);
|
||||
}
|
||||
145
packages/server-v2/src/routes/oauth.ts
Normal file
145
packages/server-v2/src/routes/oauth.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* `/oauth/*` REST routes.
|
||||
*
|
||||
* POST /oauth/login start a device-code flow → OAuthFlowStart
|
||||
* GET /oauth/login poll current flow state → OAuthFlowSnapshot | null
|
||||
* DELETE /oauth/login cancel pending flow → { cancelled, status }
|
||||
* POST /oauth/logout logout → { logged_out, provider }
|
||||
*
|
||||
* Backed by the v2 `IOAuthService` (Core scope), which already returns the
|
||||
* protocol wire types, so the handlers only swap the v1 accessor
|
||||
* (`ix.invokeFunction`) for the v2 one (`core.accessor.get`).
|
||||
*/
|
||||
|
||||
import { IOAuthService, type Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
oauthFlowSnapshotSchema,
|
||||
oauthFlowStartSchema,
|
||||
oauthLoginCancelResponseSchema,
|
||||
oauthLoginQuerySchema,
|
||||
oauthLoginStartRequestSchema,
|
||||
oauthLogoutRequestSchema,
|
||||
oauthLogoutResponseSchema,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
|
||||
interface RouteHost {
|
||||
get(
|
||||
path: string,
|
||||
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string; query: unknown },
|
||||
reply: { send(payload: unknown): void },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
post(
|
||||
path: string,
|
||||
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string; body: unknown },
|
||||
reply: { send(payload: unknown): void },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
delete(
|
||||
path: string,
|
||||
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string; query: unknown },
|
||||
reply: { send(payload: unknown): void },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
const oauthFlowSnapshotOrNullSchema = z.union([
|
||||
oauthFlowSnapshotSchema,
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
export function registerOAuthRoutes(app: RouteHost, core: Scope): void {
|
||||
// POST /oauth/login — start device flow ----------------------------------
|
||||
const loginStartRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/oauth/login',
|
||||
body: oauthLoginStartRequestSchema,
|
||||
success: { data: oauthFlowStartSchema },
|
||||
description: 'Start an OAuth device-code flow',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const result = await core.accessor.get(IOAuthService).startLogin(req.body.provider);
|
||||
reply.send(okEnvelope(result, req.id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
loginStartRoute.path,
|
||||
loginStartRoute.options,
|
||||
loginStartRoute.handler as Parameters<RouteHost['post']>[2],
|
||||
);
|
||||
|
||||
// GET /oauth/login — poll current flow state -----------------------------
|
||||
const loginPollRoute = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/oauth/login',
|
||||
querystring: oauthLoginQuerySchema,
|
||||
success: { data: oauthFlowSnapshotOrNullSchema },
|
||||
description: 'Poll the current OAuth device-code flow',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const snapshot = core.accessor.get(IOAuthService).getFlow(req.query.provider);
|
||||
reply.send(okEnvelope(snapshot ?? null, req.id));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
loginPollRoute.path,
|
||||
loginPollRoute.options,
|
||||
loginPollRoute.handler as Parameters<RouteHost['get']>[2],
|
||||
);
|
||||
|
||||
// DELETE /oauth/login — cancel pending flow ------------------------------
|
||||
const loginCancelRoute = defineRoute(
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/oauth/login',
|
||||
querystring: oauthLoginQuerySchema,
|
||||
success: { data: oauthLoginCancelResponseSchema },
|
||||
description: 'Cancel the current OAuth device-code flow',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const result = await core.accessor.get(IOAuthService).cancelLogin(req.query.provider);
|
||||
reply.send(okEnvelope(result, req.id));
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
loginCancelRoute.path,
|
||||
loginCancelRoute.options,
|
||||
loginCancelRoute.handler as Parameters<RouteHost['delete']>[2],
|
||||
);
|
||||
|
||||
// POST /oauth/logout -----------------------------------------------------
|
||||
const logoutRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/oauth/logout',
|
||||
body: oauthLogoutRequestSchema,
|
||||
success: { data: oauthLogoutResponseSchema },
|
||||
description: 'Logout the managed OAuth provider',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const result = await core.accessor.get(IOAuthService).logout(req.body.provider);
|
||||
reply.send(okEnvelope(result, req.id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
logoutRoute.path,
|
||||
logoutRoute.options,
|
||||
logoutRoute.handler as Parameters<RouteHost['post']>[2],
|
||||
);
|
||||
}
|
||||
87
packages/server-v2/src/routes/registerApiV1Routes.ts
Normal file
87
packages/server-v2/src/routes/registerApiV1Routes.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* `/api/v1` route registration.
|
||||
*
|
||||
* Mirrors the v1 server's prefixing and per-module delegation, but resolves
|
||||
* services from the `agent-core-v2` Core `Scope` instead of the v1 flat
|
||||
* `IInstantiationService`. v0.1 mounts the subset of routes that v2 can serve
|
||||
* end-to-end today (health, meta, auth readiness, OAuth device flow, shutdown).
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import { ulid } from 'ulid';
|
||||
|
||||
import { okEnvelope } from '../envelope';
|
||||
import { registerAuthRoute } from './auth';
|
||||
import { registerMetaRoute } from './meta';
|
||||
import { registerOAuthRoutes } from './oauth';
|
||||
import { registerShutdownRoutes } from './shutdown';
|
||||
|
||||
interface ApiV1AppHost {
|
||||
register(
|
||||
plugin: (apiV1: ApiV1RouteHost) => Promise<void> | void,
|
||||
opts: { prefix: string },
|
||||
): unknown;
|
||||
}
|
||||
|
||||
interface ApiV1RouteHost {
|
||||
get(
|
||||
path: string,
|
||||
options: { schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => unknown,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export interface RegisterApiV1RoutesOptions {
|
||||
readonly serverVersion: string;
|
||||
readonly debugEndpoints?: boolean;
|
||||
readonly onShutdown: () => void;
|
||||
}
|
||||
|
||||
export async function registerApiV1Routes(
|
||||
app: ApiV1AppHost,
|
||||
core: Scope,
|
||||
opts: RegisterApiV1RoutesOptions,
|
||||
): Promise<void> {
|
||||
await app.register(async (apiV1) => {
|
||||
registerHealthRoute(apiV1);
|
||||
|
||||
registerMetaRoute(apiV1, {
|
||||
serverVersion: opts.serverVersion,
|
||||
serverId: ulid(),
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
|
||||
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
|
||||
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
|
||||
onShutdown: opts.onShutdown,
|
||||
});
|
||||
}, { prefix: '/api/v1' });
|
||||
}
|
||||
|
||||
function registerHealthRoute(apiV1: ApiV1RouteHost): void {
|
||||
apiV1.get('/healthz', {
|
||||
schema: {
|
||||
description: 'Health check',
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'number' },
|
||||
msg: { type: 'string' },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean' } },
|
||||
},
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
return reply.send(okEnvelope({ ok: true }, req.id));
|
||||
});
|
||||
}
|
||||
53
packages/server-v2/src/routes/shutdown.ts
Normal file
53
packages/server-v2/src/routes/shutdown.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* `POST /shutdown` route handler.
|
||||
*
|
||||
* Gracefully terminates the server. The actual `close()` + scope disposal is
|
||||
* supplied by `start.ts` via `onShutdown` so it stays next to the bootstrap
|
||||
* (and remains overridable in tests). The handler replies before triggering
|
||||
* shutdown so callers receive a clean 200 instead of a dropped connection.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
|
||||
interface ShutdownRouteHost {
|
||||
post(
|
||||
path: string,
|
||||
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
export interface ShutdownRouteOptions {
|
||||
readonly onShutdown: () => void;
|
||||
}
|
||||
|
||||
export function registerShutdownRoutes(
|
||||
app: ShutdownRouteHost,
|
||||
opts: ShutdownRouteOptions,
|
||||
): void {
|
||||
const route = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/shutdown',
|
||||
success: { data: z.object({ ok: z.literal(true) }) },
|
||||
description: 'Gracefully shut down the server',
|
||||
tags: ['meta'],
|
||||
},
|
||||
(req, reply) => {
|
||||
reply.send(okEnvelope({ ok: true }, req.id));
|
||||
// Let the response flush before tearing the server down.
|
||||
setImmediate(() => opts.onShutdown());
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
route.path,
|
||||
route.options,
|
||||
route.handler as Parameters<ShutdownRouteHost['post']>[2],
|
||||
);
|
||||
}
|
||||
42
packages/server-v2/src/services/pinoLoggerService.ts
Normal file
42
packages/server-v2/src/services/pinoLoggerService.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Pino logger factory for the server process.
|
||||
*
|
||||
* Produces the `ServerLogger` handed to Fastify via `loggerInstance`. Unlike the
|
||||
* v1 server, server-v2 does not adapt this logger into the engine's
|
||||
* `ILogService` — `agent-core-v2` registers its own `ILogService` at Core scope,
|
||||
* so the HTTP-layer logger stays a plain pino instance.
|
||||
*/
|
||||
|
||||
import { pino, type Logger, type LoggerOptions } from 'pino';
|
||||
import prettyStream from 'pino-pretty';
|
||||
|
||||
export type ServerLogger = Logger;
|
||||
|
||||
export type ServerLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
|
||||
|
||||
export interface CreateLoggerOptions {
|
||||
level: ServerLogLevel;
|
||||
pretty?: boolean;
|
||||
}
|
||||
|
||||
export function createServerLogger(opts: CreateLoggerOptions): ServerLogger {
|
||||
const pretty = opts.pretty ?? process.stdout.isTTY === true;
|
||||
const base: LoggerOptions = {
|
||||
level: opts.level,
|
||||
base: { name: 'kimi-server-v2' },
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
};
|
||||
if (pretty) {
|
||||
return pino(
|
||||
base,
|
||||
prettyStream({
|
||||
colorize: true,
|
||||
translateTime: 'SYS:HH:MM:ss.l o',
|
||||
ignore: 'pid,hostname',
|
||||
singleLine: false,
|
||||
destination: process.stdout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return pino(base);
|
||||
}
|
||||
83
packages/server-v2/src/start.ts
Normal file
83
packages/server-v2/src/start.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Server bootstrap — wires `@moonshot-ai/agent-core-v2` (DI × Scope engine) into
|
||||
* a Fastify HTTP server that speaks the same `/api/v1` interface as the v1
|
||||
* server.
|
||||
*
|
||||
* Composition root: `bootstrap()` builds the Core `Scope`; route handlers resolve
|
||||
* Core-scoped services through `core.accessor.get(IXxx)`.
|
||||
*/
|
||||
|
||||
import { bootstrap, resolveConfigPath, resolveKimiHome, type Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
|
||||
import { installErrorHandler } from './error-handler';
|
||||
import { registerApiV1Routes } from './routes/registerApiV1Routes';
|
||||
import { resolveRequestId } from './request-id';
|
||||
import {
|
||||
createServerLogger,
|
||||
type ServerLogger,
|
||||
type ServerLogLevel,
|
||||
} from './services/pinoLoggerService';
|
||||
import { getServerVersion } from './version';
|
||||
|
||||
export interface ServerStartOptions {
|
||||
readonly host?: string;
|
||||
readonly port?: number;
|
||||
readonly homeDir?: string;
|
||||
readonly configPath?: string;
|
||||
readonly logLevel?: ServerLogLevel;
|
||||
readonly logger?: ServerLogger;
|
||||
readonly debugEndpoints?: boolean;
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
readonly app: FastifyInstance;
|
||||
readonly core: Scope;
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_PORT = 58627;
|
||||
|
||||
export async function startServer(opts: ServerStartOptions = {}): Promise<RunningServer> {
|
||||
const homeDir = resolveKimiHome(opts.homeDir);
|
||||
const configPath = resolveConfigPath({ homeDir, configPath: opts.configPath });
|
||||
const { core } = bootstrap({ homeDir, configPath });
|
||||
|
||||
const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
|
||||
|
||||
const app = Fastify({
|
||||
loggerInstance: logger,
|
||||
disableRequestLogging: false,
|
||||
genReqId: (req) => resolveRequestId(req.headers),
|
||||
}) as unknown as FastifyInstance;
|
||||
// Validation is performed by the route-level Zod preHandlers (defineRoute),
|
||||
// not by Fastify's AJV layer — keep both compilers as pass-throughs.
|
||||
app.setValidatorCompiler(() => () => true);
|
||||
app.setSerializerCompiler(() => (data) => JSON.stringify(data));
|
||||
installErrorHandler(app);
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await app.close();
|
||||
core.dispose();
|
||||
};
|
||||
|
||||
await registerApiV1Routes(app, core, {
|
||||
serverVersion: getServerVersion(),
|
||||
debugEndpoints: opts.debugEndpoints,
|
||||
onShutdown: () => {
|
||||
void close();
|
||||
},
|
||||
});
|
||||
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
await app.listen({ host, port });
|
||||
|
||||
const address = app.server.address();
|
||||
const boundPort = typeof address === 'object' && address !== null ? address.port : port;
|
||||
|
||||
return { app, core, host, port: boundPort, close };
|
||||
}
|
||||
17
packages/server-v2/src/version.ts
Normal file
17
packages/server-v2/src/version.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
let cached: string | undefined;
|
||||
|
||||
export function getServerVersion(): string {
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url);
|
||||
const raw = readFileSync(fileURLToPath(pkgUrl), 'utf-8');
|
||||
const pkg = JSON.parse(raw) as { version?: unknown };
|
||||
cached = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
||||
} catch {
|
||||
cached = '0.0.0';
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
75
packages/server-v2/test/boot.test.ts
Normal file
75
packages/server-v2/test/boot.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type RunningServer, startServer } from '../src/start';
|
||||
|
||||
describe('server-v2 boot', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
const base = `http://127.0.0.1:${server.port}`;
|
||||
|
||||
const healthz = await fetch(`${base}/api/v1/healthz`);
|
||||
expect(healthz.status).toBe(200);
|
||||
const healthBody = await healthz.json() as {
|
||||
code: number;
|
||||
data: { ok: boolean };
|
||||
request_id: string;
|
||||
};
|
||||
expect(healthBody.code).toBe(0);
|
||||
expect(healthBody.data.ok).toBe(true);
|
||||
expect(typeof healthBody.request_id).toBe('string');
|
||||
|
||||
const meta = await fetch(`${base}/api/v1/meta`);
|
||||
expect(meta.status).toBe(200);
|
||||
const metaBody = await meta.json() as {
|
||||
code: number;
|
||||
data: { server_id: string; server_version: string; capabilities: Record<string, boolean> };
|
||||
};
|
||||
expect(metaBody.code).toBe(0);
|
||||
expect(typeof metaBody.data.server_id).toBe('string');
|
||||
expect(typeof metaBody.data.server_version).toBe('string');
|
||||
expect(metaBody.data.capabilities).toBeDefined();
|
||||
|
||||
const auth = await fetch(`${base}/api/v1/auth`);
|
||||
expect(auth.status).toBe(200);
|
||||
const authBody = await auth.json() as {
|
||||
code: number;
|
||||
data: { ready: boolean; providers_count: number; default_model: string | null };
|
||||
};
|
||||
expect(authBody.code).toBe(0);
|
||||
expect(typeof authBody.data.ready).toBe('boolean');
|
||||
expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Poll with no flow in flight → null payload; exercises the v2 IOAuthService
|
||||
// wiring without starting a real (networked) device-code flow.
|
||||
const oauthPoll = await fetch(`${base}/api/v1/oauth/login`);
|
||||
expect(oauthPoll.status).toBe(200);
|
||||
const oauthBody = await oauthPoll.json() as { code: number; data: null };
|
||||
expect(oauthBody.code).toBe(0);
|
||||
expect(oauthBody.data).toBeNull();
|
||||
});
|
||||
});
|
||||
13
packages/server-v2/tsconfig.dev.json
Normal file
13
packages/server-v2/tsconfig.dev.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"test",
|
||||
"../../packages/*/src/**/*.ts",
|
||||
"../../packages/*/src/**/*.tsx",
|
||||
"../../packages/*/test/**/*.ts"
|
||||
]
|
||||
}
|
||||
7
packages/server-v2/tsconfig.json
Normal file
7
packages/server-v2/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
9
packages/server-v2/tsdown.config.ts
Normal file
9
packages/server-v2/tsdown.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['./src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
});
|
||||
53
packages/server-v2/vitest.config.ts
Normal file
53
packages/server-v2/vitest.config.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { defineConfig, type Plugin } from 'vitest/config';
|
||||
|
||||
import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
|
||||
|
||||
function findPackageRoot(importer: string | undefined): string | undefined {
|
||||
if (!importer) return undefined;
|
||||
let dir = dirname(importer.split('?')[0] ?? importer);
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, 'package.json'))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `#/` subpath imports the way Node's package.json `imports` field does,
|
||||
* scoped to the importer's owning package. server-v2 inlines
|
||||
* `@moonshot-ai/agent-core-v2` source (and transitively kosong/kaos), whose
|
||||
* internal `#/foo` imports must resolve against each package's own `src/`.
|
||||
*
|
||||
* Mirrors `packages/agent-core-v2/vitest.config.ts`.
|
||||
*/
|
||||
function hashImportsPlugin(): Plugin {
|
||||
return {
|
||||
name: 'resolve-hash-imports',
|
||||
enforce: 'pre',
|
||||
resolveId(id, importer) {
|
||||
if (!id.startsWith('#/')) return null;
|
||||
const pkgRoot = findPackageRoot(importer);
|
||||
if (!pkgRoot) return null;
|
||||
const sub = id.slice(2);
|
||||
for (const candidate of [`src/${sub}.ts`, `src/${sub}/index.ts`]) {
|
||||
const full = join(pkgRoot, candidate);
|
||||
if (existsSync(full)) return full;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// `rawTextPlugin` is required because server-v2 pulls in agent-core-v2's full
|
||||
// barrel, which imports `*.md?raw` prompt templates.
|
||||
plugins: [rawTextPlugin(), hashImportsPlugin()],
|
||||
test: {
|
||||
name: 'server-v2',
|
||||
include: ['test/**/*.{test,e2e}.ts'],
|
||||
},
|
||||
});
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
|
|
@ -658,6 +658,34 @@ importers:
|
|||
specifier: ^8.18.0
|
||||
version: 8.18.1
|
||||
|
||||
packages/server-v2:
|
||||
dependencies:
|
||||
'@moonshot-ai/agent-core-v2':
|
||||
specifier: workspace:^
|
||||
version: link:../agent-core-v2
|
||||
'@moonshot-ai/protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../protocol
|
||||
fastify:
|
||||
specifier: ^5.1.0
|
||||
version: 5.8.5
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
pino-pretty:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.3
|
||||
ulid:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.2
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
||||
packages/telemetry: {}
|
||||
|
||||
packages:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue