From 4e7209394c1eb33801ad7b22eebd3244821688ce Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 28 Jun 2026 17:26:39 +0800 Subject: [PATCH] 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. --- build/hash-imports-loader.mjs | 79 +++++ build/register-hash-imports-loader.mjs | 8 + flake.nix | 2 + package.json | 2 +- packages/agent-core-v2/src/event/event.ts | 23 ++ .../agent-core-v2/src/event/eventService.ts | 35 ++ packages/agent-core-v2/src/event/index.ts | 8 + .../src/gateway/gatewayService.ts | 6 +- packages/agent-core-v2/src/index.ts | 1 + .../src/wireRecord/wireRecordService.ts | 6 +- .../test/tooldedup/tooldedup.test.ts | 4 +- packages/server-v2/package.json | 32 ++ packages/server-v2/src/env.d.ts | 9 + packages/server-v2/src/envelope.ts | 12 + packages/server-v2/src/error-handler.ts | 51 +++ packages/server-v2/src/index.ts | 15 + packages/server-v2/src/main.ts | 94 ++++++ .../server-v2/src/middleware/defineRoute.ts | 314 ++++++++++++++++++ packages/server-v2/src/middleware/schema.ts | 158 +++++++++ packages/server-v2/src/middleware/validate.ts | 132 ++++++++ packages/server-v2/src/request-id.ts | 21 ++ packages/server-v2/src/routes/auth.ts | 62 ++++ packages/server-v2/src/routes/meta.ts | 69 ++++ packages/server-v2/src/routes/oauth.ts | 145 ++++++++ .../src/routes/registerApiV1Routes.ts | 87 +++++ packages/server-v2/src/routes/shutdown.ts | 53 +++ .../src/services/pinoLoggerService.ts | 42 +++ packages/server-v2/src/start.ts | 83 +++++ packages/server-v2/src/version.ts | 17 + packages/server-v2/test/boot.test.ts | 75 +++++ packages/server-v2/tsconfig.dev.json | 13 + packages/server-v2/tsconfig.json | 7 + packages/server-v2/tsdown.config.ts | 9 + packages/server-v2/vitest.config.ts | 53 +++ pnpm-lock.yaml | 28 ++ 35 files changed, 1748 insertions(+), 7 deletions(-) create mode 100644 build/hash-imports-loader.mjs create mode 100644 build/register-hash-imports-loader.mjs create mode 100644 packages/agent-core-v2/src/event/event.ts create mode 100644 packages/agent-core-v2/src/event/eventService.ts create mode 100644 packages/agent-core-v2/src/event/index.ts create mode 100644 packages/server-v2/package.json create mode 100644 packages/server-v2/src/env.d.ts create mode 100644 packages/server-v2/src/envelope.ts create mode 100644 packages/server-v2/src/error-handler.ts create mode 100644 packages/server-v2/src/index.ts create mode 100644 packages/server-v2/src/main.ts create mode 100644 packages/server-v2/src/middleware/defineRoute.ts create mode 100644 packages/server-v2/src/middleware/schema.ts create mode 100644 packages/server-v2/src/middleware/validate.ts create mode 100644 packages/server-v2/src/request-id.ts create mode 100644 packages/server-v2/src/routes/auth.ts create mode 100644 packages/server-v2/src/routes/meta.ts create mode 100644 packages/server-v2/src/routes/oauth.ts create mode 100644 packages/server-v2/src/routes/registerApiV1Routes.ts create mode 100644 packages/server-v2/src/routes/shutdown.ts create mode 100644 packages/server-v2/src/services/pinoLoggerService.ts create mode 100644 packages/server-v2/src/start.ts create mode 100644 packages/server-v2/src/version.ts create mode 100644 packages/server-v2/test/boot.test.ts create mode 100644 packages/server-v2/tsconfig.dev.json create mode 100644 packages/server-v2/tsconfig.json create mode 100644 packages/server-v2/tsdown.config.ts create mode 100644 packages/server-v2/vitest.config.ts diff --git a/build/hash-imports-loader.mjs b/build/hash-imports-loader.mjs new file mode 100644 index 000000000..2e50caefd --- /dev/null +++ b/build/hash-imports-loader.mjs @@ -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//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); +} diff --git a/build/register-hash-imports-loader.mjs b/build/register-hash-imports-loader.mjs new file mode 100644 index 000000000..f06a77719 --- /dev/null +++ b/build/register-hash-imports-loader.mjs @@ -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); diff --git a/flake.nix b/flake.nix index 4a00e0b83..e7d53c515 100644 --- a/flake.nix +++ b/flake.nix @@ -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" diff --git a/package.json b/package.json index d28b86060..15cc63285 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/agent-core-v2/src/event/event.ts b/packages/agent-core-v2/src/event/event.ts new file mode 100644 index 000000000..02fb24c27 --- /dev/null +++ b/packages/agent-core-v2/src/event/event.ts @@ -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 = + createDecorator('eventService'); diff --git a/packages/agent-core-v2/src/event/eventService.ts b/packages/agent-core-v2/src/event/eventService.ts new file mode 100644 index 000000000..b6e1f9da9 --- /dev/null +++ b/packages/agent-core-v2/src/event/eventService.ts @@ -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()); + + 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', +); diff --git a/packages/agent-core-v2/src/event/index.ts b/packages/agent-core-v2/src/event/index.ts new file mode 100644 index 000000000..63b4fdc2f --- /dev/null +++ b/packages/agent-core-v2/src/event/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/gateway/gatewayService.ts b/packages/agent-core-v2/src/gateway/gatewayService.ts index 7393371a1..4fcc232c4 100644 --- a/packages/agent-core-v2/src/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/gateway/gatewayService.ts @@ -96,8 +96,10 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic constructor(@IEventSink event: IEventSink) { super(); - event.subscribe(() => { - }); + this._register( + event.on(() => { + }), + ); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 50507cacf..2087091d8 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/wireRecord/wireRecordService.ts b/packages/agent-core-v2/src/wireRecord/wireRecordService.ts index 9a58d7c14..112a7facd 100644 --- a/packages/agent-core-v2/src/wireRecord/wireRecordService.ts +++ b/packages/agent-core-v2/src/wireRecord/wireRecordService.ts @@ -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); } } diff --git a/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts b/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts index 8e92b34b9..bfdf15441 100644 --- a/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts +++ b/packages/agent-core-v2/test/tooldedup/tooldedup.test.ts @@ -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'; diff --git a/packages/server-v2/package.json b/packages/server-v2/package.json new file mode 100644 index 000000000..e97ba06ae --- /dev/null +++ b/packages/server-v2/package.json @@ -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" + } +} diff --git a/packages/server-v2/src/env.d.ts b/packages/server-v2/src/env.d.ts new file mode 100644 index 000000000..1e3ef6698 --- /dev/null +++ b/packages/server-v2/src/env.d.ts @@ -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; +} diff --git a/packages/server-v2/src/envelope.ts b/packages/server-v2/src/envelope.ts new file mode 100644 index 000000000..e1982c4a2 --- /dev/null +++ b/packages/server-v2/src/envelope.ts @@ -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'; diff --git a/packages/server-v2/src/error-handler.ts b/packages/server-v2/src/error-handler.ts new file mode 100644 index 000000000..b48083a6e --- /dev/null +++ b/packages/server-v2/src/error-handler.ts @@ -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, + ), + ); + }); +} diff --git a/packages/server-v2/src/index.ts b/packages/server-v2/src/index.ts new file mode 100644 index 000000000..9f887b5e4 --- /dev/null +++ b/packages/server-v2/src/index.ts @@ -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'; diff --git a/packages/server-v2/src/main.ts b/packages/server-v2/src/main.ts new file mode 100644 index 000000000..60d07b088 --- /dev/null +++ b/packages/server-v2/src/main.ts @@ -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 ` (default 58627), `--host ` (default 127.0.0.1), + * `--log-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 { + 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 => { + 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(() => { + // 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); +}); diff --git a/packages/server-v2/src/middleware/defineRoute.ts b/packages/server-v2/src/middleware/defineRoute.ts new file mode 100644 index 000000000..3918afe2d --- /dev/null +++ b/packages/server-v2/src/middleware/defineRoute.ts @@ -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, +): Record { + // 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[] = []; + + // 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 + ? z.infer + : 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; + /** + * Raw response schemas that are NOT envelope-wrapped. + * Useful for binary-stream endpoints (e.g. file download). + */ + rawResponse?: Record>; + /** 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; + }; + handler: ( + req: { + id: string; + body: InferZod; + params: InferZod; + headers: Record; + } & (TQuery extends z.ZodTypeAny ? { query: InferZod } : {}), + reply: { send(payload: unknown): unknown }, + ) => Promise | 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, + handler: RouteDefinition['handler'], +): RouteDefinition { + // -- 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 = {}; + + 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 = {}; + + 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, + }; +} diff --git a/packages/server-v2/src/middleware/schema.ts b/packages/server-v2/src/middleware/schema.ts new file mode 100644 index 000000000..76081eff5 --- /dev/null +++ b/packages/server-v2/src/middleware/schema.ts @@ -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 { + 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 { + 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 { + return jsonSchemaForTarget(schema, io, 'openapi-3.0'); +} + +function jsonSchemaForTarget( + schema: z.ZodTypeAny, + io: 'input' | 'output', + target: 'draft-7' | 'openapi-3.0', +): Record { + const converted = z.toJSONSchema(schema, { + target, + io, + unrepresentable: 'any', + }) as Record; + 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 { + return outputJsonSchema(envelopeSchema(dataSchema)); +} + +export function openApiDocumentEnvelopeJsonSchema( + dataSchema: z.ZodTypeAny, +): Record { + 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; + /** + * Response schema map that is NOT envelope-wrapped. + * Useful for the `200` on binary-stream endpoints. + */ + rawResponse?: Record>; + description?: string; + summary?: string; + tags?: string[]; + operationId?: string; + consumes?: string[]; + produces?: string[]; +} + +export function buildRouteSchema(options: RouteSchemaOptions): Record { + const schema: Record = {}; + + 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 = {}; + 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; +} diff --git a/packages/server-v2/src/middleware/validate.ts b/packages/server-v2/src/middleware/validate.ts new file mode 100644 index 000000000..8439f3688 --- /dev/null +++ b/packages/server-v2/src/middleware/validate.ts @@ -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(schema: z.ZodType): 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` — 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(schema: z.ZodType): 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(schema: z.ZodType): 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(); + }; +} diff --git a/packages/server-v2/src/request-id.ts b/packages/server-v2/src/request-id.ts new file mode 100644 index 000000000..48b4e3a24 --- /dev/null +++ b/packages/server-v2/src/request-id.ts @@ -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 { + const raw = headers[REQUEST_ID_HEADER]; + const supplied = Array.isArray(raw) ? raw[0] : raw; + return parseOrGenerateRequestId(supplied); +} diff --git a/packages/server-v2/src/routes/auth.ts b/packages/server-v2/src/routes/auth.ts new file mode 100644 index 000000000..28559676e --- /dev/null +++ b/packages/server-v2/src/routes/auth.ts @@ -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 }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | 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[2]); +} diff --git a/packages/server-v2/src/routes/meta.ts b/packages/server-v2/src/routes/meta.ts new file mode 100644 index 000000000..6d27189fa --- /dev/null +++ b/packages/server-v2/src/routes/meta.ts @@ -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 }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | 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[2]); +} diff --git a/packages/server-v2/src/routes/oauth.ts b/packages/server-v2/src/routes/oauth.ts new file mode 100644 index 000000000..097aecd0d --- /dev/null +++ b/packages/server-v2/src/routes/oauth.ts @@ -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 }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | 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[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[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[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[2], + ); +} diff --git a/packages/server-v2/src/routes/registerApiV1Routes.ts b/packages/server-v2/src/routes/registerApiV1Routes.ts new file mode 100644 index 000000000..e0f2cfcaa --- /dev/null +++ b/packages/server-v2/src/routes/registerApiV1Routes.ts @@ -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, + opts: { prefix: string }, + ): unknown; +} + +interface ApiV1RouteHost { + get( + path: string, + options: { schema?: Record }, + 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 { + await app.register(async (apiV1) => { + registerHealthRoute(apiV1); + + registerMetaRoute(apiV1, { + serverVersion: opts.serverVersion, + serverId: ulid(), + startedAt: new Date().toISOString(), + }); + + registerAuthRoute(apiV1 as unknown as Parameters[0], core); + registerOAuthRoutes(apiV1 as unknown as Parameters[0], core); + registerShutdownRoutes(apiV1 as unknown as Parameters[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)); + }); +} diff --git a/packages/server-v2/src/routes/shutdown.ts b/packages/server-v2/src/routes/shutdown.ts new file mode 100644 index 000000000..b63c75016 --- /dev/null +++ b/packages/server-v2/src/routes/shutdown.ts @@ -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 }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): unknown }, + ) => Promise | 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[2], + ); +} diff --git a/packages/server-v2/src/services/pinoLoggerService.ts b/packages/server-v2/src/services/pinoLoggerService.ts new file mode 100644 index 000000000..43114d03c --- /dev/null +++ b/packages/server-v2/src/services/pinoLoggerService.ts @@ -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); +} diff --git a/packages/server-v2/src/start.ts b/packages/server-v2/src/start.ts new file mode 100644 index 000000000..61beaceb5 --- /dev/null +++ b/packages/server-v2/src/start.ts @@ -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; +} + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 58627; + +export async function startServer(opts: ServerStartOptions = {}): Promise { + 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 => { + 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 }; +} diff --git a/packages/server-v2/src/version.ts b/packages/server-v2/src/version.ts new file mode 100644 index 000000000..6395495be --- /dev/null +++ b/packages/server-v2/src/version.ts @@ -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; +} diff --git a/packages/server-v2/test/boot.test.ts b/packages/server-v2/test/boot.test.ts new file mode 100644 index 000000000..fa16de21b --- /dev/null +++ b/packages/server-v2/test/boot.test.ts @@ -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 }; + }; + 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(); + }); +}); diff --git a/packages/server-v2/tsconfig.dev.json b/packages/server-v2/tsconfig.dev.json new file mode 100644 index 000000000..4157e7ee9 --- /dev/null +++ b/packages/server-v2/tsconfig.dev.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts" + ] +} diff --git a/packages/server-v2/tsconfig.json b/packages/server-v2/tsconfig.json new file mode 100644 index 000000000..760fb974a --- /dev/null +++ b/packages/server-v2/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": ["src", "test"] +} diff --git a/packages/server-v2/tsdown.config.ts b/packages/server-v2/tsdown.config.ts new file mode 100644 index 000000000..cb99d9ffb --- /dev/null +++ b/packages/server-v2/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, +}); diff --git a/packages/server-v2/vitest.config.ts b/packages/server-v2/vitest.config.ts new file mode 100644 index 000000000..e248edd42 --- /dev/null +++ b/packages/server-v2/vitest.config.ts @@ -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'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf58ebb1..9dd46166b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: