mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(server-v2): add RPC and WebSocket transport layer
This commit is contained in:
parent
f9df159c49
commit
6173c6a97a
24 changed files with 3059 additions and 37 deletions
|
|
@ -34,6 +34,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s
|
|||
- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the file-header comment convention. Read before touching business code.
|
||||
- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding.
|
||||
- Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on.
|
||||
- Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade.
|
||||
- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook.
|
||||
- Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules.
|
||||
- Topic: [Config](config.md) — the section-registry model, Core vs Session split, owning a config section, the TOML format, and the env overlay.
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ Red lines:
|
|||
|
||||
> Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one.
|
||||
|
||||
> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream.
|
||||
|
||||
## 6. New-Service checklist
|
||||
|
||||
1. **What does it remember, and what is the state's identity?** → pick the scope (§2).
|
||||
|
|
|
|||
180
.agents/skills/agent-core-dev/edge-exposure.md
Normal file
180
.agents/skills/agent-core-dev/edge-exposure.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# Edge exposure — `resource:action` + WS events
|
||||
|
||||
How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream.
|
||||
|
||||
The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it.
|
||||
|
||||
## 1. The edge model
|
||||
|
||||
Three scopes, three URL shapes, one dispatcher:
|
||||
|
||||
```text
|
||||
GET|POST /api/v2/:sa Core
|
||||
GET|POST /api/v2/session/:session_id/:sa Session
|
||||
GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
|
||||
```
|
||||
|
||||
`:sa` is a single path segment of the form `<resource>:<action>` (e.g.
|
||||
`sessions:list`, `session:read`, `profile:getModel`).
|
||||
|
||||
- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`).
|
||||
- `:action` is the method. `GET` for reads, `POST` for writes.
|
||||
- Body = the method's single argument (JSON), omitted for no-arg.
|
||||
- Response = the project envelope `{ code, msg, data, request_id, details? }`.
|
||||
- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result.
|
||||
|
||||
```ts
|
||||
// actionMap — the allowlist; hides internal domain names.
|
||||
const actionMap = {
|
||||
core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... },
|
||||
session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... },
|
||||
agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... },
|
||||
};
|
||||
```
|
||||
|
||||
The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`.
|
||||
|
||||
## 2. What may be exposed directly
|
||||
|
||||
A Service method is directly exposable iff **all** hold:
|
||||
|
||||
1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns).
|
||||
2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`).
|
||||
3. Errors are `KimiError` (coded).
|
||||
4. It is a command/query, not a factory, stream, byte-store, or sink.
|
||||
|
||||
If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one.
|
||||
|
||||
## 3. Per-scope `resource:action` map
|
||||
|
||||
Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`.
|
||||
|
||||
### Core (`/api/v2/:resource:action`)
|
||||
|
||||
| resource | action | Service.method | verb |
|
||||
|---|---|---|---|
|
||||
| `sessions` | `list` | ISessionIndex.list | GET |
|
||||
| `sessions` | `get` | ISessionIndex.get | GET |
|
||||
| `sessions` | `countActive` | ISessionIndex.countActive | GET |
|
||||
| `workspaces` | `list` | IWorkspaceRegistry.list | GET |
|
||||
| `workspaces` | `get` | IWorkspaceRegistry.get | GET |
|
||||
| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST |
|
||||
| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST |
|
||||
| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET |
|
||||
| `config` | `set` / `replace` / `reload` | IConfigService.* | POST |
|
||||
| `providers` | `list` / `get` | IProviderService.* | GET |
|
||||
| `providers` | `set` / `delete` | IProviderService.* | POST |
|
||||
| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST |
|
||||
| `oauth` | `getFlow` / `status` | IOAuthService.* | GET |
|
||||
| `auth` | `summarize` | IAuthSummaryService.summarize | GET |
|
||||
| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST |
|
||||
| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET |
|
||||
| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET |
|
||||
| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET |
|
||||
|
||||
### Session (`/api/v2/session/:sid/:resource:action`)
|
||||
|
||||
| resource | action | Service.method | verb |
|
||||
|---|---|---|---|
|
||||
| `session` | `read` | ISessionMetadata.read | GET |
|
||||
| `session` | `update` | ISessionMetadata.update | POST |
|
||||
| `session` | `setTitle` | ISessionMetadata.setTitle | POST |
|
||||
| `session` | `setArchived` | ISessionMetadata.setArchived | POST |
|
||||
| `session` | `status` | ISessionActivity.status | GET |
|
||||
| `session` | `isIdle` | ISessionActivity.isIdle | GET |
|
||||
| `session` | `archive` | ISessionService.archive | POST |
|
||||
| `approvals` | `listPending` | IApprovalService.listPending | GET |
|
||||
| `approvals` | `decide` | IApprovalService.decide | POST |
|
||||
| `questions` | `listPending` | IQuestionService.listPending | GET |
|
||||
| `questions` | `answer` | IQuestionService.answer | POST |
|
||||
| `interactions` | `listPending` | IInteractionService.listPending | GET |
|
||||
| `interactions` | `respond` | IInteractionService.respond | POST |
|
||||
| `workspace` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST |
|
||||
|
||||
### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`)
|
||||
|
||||
| resource | action | Service.method | verb |
|
||||
|---|---|---|---|
|
||||
| `goal` | `get` | IGoalService.getGoal | GET |
|
||||
| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST |
|
||||
| `plan` | `status` | IPlanService.status | GET |
|
||||
| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST |
|
||||
| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET |
|
||||
| `tasks` | `stop` / `detach` | IBackgroundService.* | POST |
|
||||
| `usage` | `status` | IUsageService.status | GET |
|
||||
| `context` | `status` | IContextSizeService.getStatus | GET |
|
||||
| `swarm` | `isActive` | ISwarmService.isActive | GET |
|
||||
| `swarm` | `enter` / `exit` | ISwarmService.* | POST |
|
||||
| `permission` | `getMode` | IPermissionModeService.mode | GET |
|
||||
| `permission` | `setMode` | IPermissionModeService.setMode | POST |
|
||||
| `permissionRules` | `list` | IPermissionRulesService.rules | GET |
|
||||
| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST |
|
||||
| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET |
|
||||
| `profile` | `setModel` / `setThinking` | IProfileService.* | POST |
|
||||
| `messages` | `list` | IContextMemory.get | GET |
|
||||
| `messages` | `splice` | IContextMemory.splice | POST |
|
||||
| `toolStore` | `get` / `data` | IToolStoreService.* | GET |
|
||||
| `toolStore` | `set` | IToolStoreService.set | POST |
|
||||
| `mcp` | `list` | IMcpService.list | GET |
|
||||
| `mcp` | `reconnect` | IMcpService.reconnect | POST |
|
||||
| `tools` | `list` | IToolRegistry.list | GET |
|
||||
|
||||
## 4. Facade-needed (wrap before exposing)
|
||||
|
||||
These fail §2 and must be wrapped in a facade that takes ids and returns data:
|
||||
|
||||
| Service | Why not direct | Facade shape |
|
||||
|---|---|---|
|
||||
| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session |
|
||||
| IPromptService / ITurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` |
|
||||
| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC |
|
||||
| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info |
|
||||
| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) |
|
||||
| Storage (IStorageService / IAppendLogStore / IAtomicDocumentStore) | bytes / streams | not for RPC |
|
||||
| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes |
|
||||
| IExternalHooksService | server-side outbound | not exposed |
|
||||
| IWireRecord | write-ahead log | internal |
|
||||
|
||||
## 5. WS events
|
||||
|
||||
A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6):
|
||||
|
||||
```text
|
||||
WS /api/v2/ws
|
||||
```
|
||||
|
||||
Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`.
|
||||
Server → client: `ready`, `result`, `error`, `event`, `ping`.
|
||||
|
||||
`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event<T>` source and forwards each emission as an `event` message, keyed by the client-chosen `id`.
|
||||
|
||||
The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`):
|
||||
|
||||
| Scope | event | Source |
|
||||
|---|---|---|
|
||||
| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) |
|
||||
| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) |
|
||||
|
||||
Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer.
|
||||
|
||||
Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`):
|
||||
|
||||
- request ids + active-request table — `cancel` / `unlisten` disposes them;
|
||||
- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`;
|
||||
- schema validation — invalid frames are dropped, not fatal;
|
||||
- graceful close — dispose listeners, cancel pending, reject in-flight calls;
|
||||
- no stack traces over the wire;
|
||||
- non-serializable event payloads are dropped, never fatal.
|
||||
|
||||
Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation.
|
||||
|
||||
## 6. Red lines (edge exposure)
|
||||
|
||||
- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`.
|
||||
- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade.
|
||||
- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade.
|
||||
- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes.
|
||||
- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`.
|
||||
- Events stream over WS (`listen`), never RPC (`call`).
|
||||
- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface.
|
||||
- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters.
|
||||
|
|
@ -10,6 +10,7 @@ export * from './log/index';
|
|||
export * from './telemetry/index';
|
||||
export * from './bootstrap/index';
|
||||
export * from './hostFs/index';
|
||||
export { IEventService, type DomainEvent } from './event/index';
|
||||
export * from './kosong/index';
|
||||
|
||||
export * from './session-index/index';
|
||||
|
|
@ -41,6 +42,7 @@ export * from './session/index';
|
|||
|
||||
export * from './eventSink/index';
|
||||
import './approval/index';
|
||||
export { IApprovalService } from './approval/index';
|
||||
export * from './question/index';
|
||||
export * from './gateway/index';
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ export * from './todoList/index';
|
|||
export * from './tool/index';
|
||||
export * from './toolExecutor/index';
|
||||
import './toolRegistry/index';
|
||||
export { IToolRegistry } from './toolRegistry/index';
|
||||
export * from './toolStore/index';
|
||||
export * from './userTool/index';
|
||||
export * from './wireRecord/index';
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@
|
|||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"ulid": "^3.0.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.18.0",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
packages/server-v2/src/routes/action-suffix.ts
Normal file
86
packages/server-v2/src/routes/action-suffix.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* `parseActionSuffix` — shared helper for the `:action` URL convention
|
||||
* (REST.md §1.6) introduced because Fastify's path syntax cannot disambiguate
|
||||
* `:resource_id` from `:resource_id:action` on the same path prefix.
|
||||
*
|
||||
* Pattern: routes register a path `/...prefix/:tail` and pass the captured
|
||||
* `tail` segment to this helper along with the list of allowed actions. The
|
||||
* helper returns one of:
|
||||
* - `{kind: 'bare', id: tail}` — no action suffix, action defaulted
|
||||
* - `{kind: 'action', id, action}` — `id:action` parse
|
||||
* - `{kind: 'invalid', reason}` — unknown action or empty id
|
||||
*
|
||||
* Callers emit a `40001 VALIDATION_FAILED` envelope on `invalid`. The default
|
||||
* action (when the caller's resource has a bare form) is encoded by the
|
||||
* `defaultAction` parameter — pass `undefined` if the route disallows the
|
||||
* bare form.
|
||||
*
|
||||
* Examples (allowedActions = ['dismiss'], defaultAction = 'resolve'):
|
||||
* 'q123' → {kind:'bare', id:'q123'} → resolve
|
||||
* 'q123:dismiss' → {kind:'action', id:'q123', action:'dismiss'}
|
||||
* 'q123:foo' → {kind:'invalid', reason:'unsupported action: q123:foo'}
|
||||
* ':dismiss' → {kind:'invalid', reason:'invalid <resource>_id in path'}
|
||||
*
|
||||
* **Why `lastIndexOf(':')`**: resource ids may CONTAIN a colon (e.g. mcp tool
|
||||
* qualified names like `mcp:lark:search` if ever used in path position). We
|
||||
* only treat the FINAL `:` as the action separator so internal colons survive.
|
||||
*
|
||||
* Ported verbatim from `packages/server/src/routes/action-suffix.ts`.
|
||||
*/
|
||||
|
||||
export type ActionSuffixParse<TAction extends string> =
|
||||
| { readonly kind: 'bare'; readonly id: string }
|
||||
| { readonly kind: 'action'; readonly id: string; readonly action: TAction }
|
||||
| { readonly kind: 'invalid'; readonly reason: string };
|
||||
|
||||
export interface ParseActionSuffixOptions<TAction extends string> {
|
||||
readonly tail: string;
|
||||
readonly allowedActions: readonly TAction[];
|
||||
/**
|
||||
* When set, a bare `<id>` (no action suffix) is accepted and reported as
|
||||
* `{kind:'bare'}`. When `undefined`, bare ids are rejected with
|
||||
* `unsupported action: <tail>` — appropriate for resources where every
|
||||
* REST action is an explicit `:verb` (e.g. `/sessions/{sid}/prompts/`).
|
||||
*/
|
||||
readonly defaultAction?: TAction;
|
||||
/**
|
||||
* Resource label used in the error message for empty-id failures, e.g.
|
||||
* `'question'` → `"invalid question_id in path"`. Defaults to `'resource'`.
|
||||
*/
|
||||
readonly resourceLabel?: string;
|
||||
}
|
||||
|
||||
export function parseActionSuffix<TAction extends string>(
|
||||
opts: ParseActionSuffixOptions<TAction>,
|
||||
): ActionSuffixParse<TAction> {
|
||||
const { tail, allowedActions, defaultAction, resourceLabel = 'resource' } = opts;
|
||||
const idx = tail.lastIndexOf(':');
|
||||
// No colon → bare id (allowed iff defaultAction is set).
|
||||
if (idx <= 0) {
|
||||
if (tail.length === 0) {
|
||||
return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` };
|
||||
}
|
||||
if (defaultAction !== undefined) {
|
||||
return { kind: 'bare', id: tail };
|
||||
}
|
||||
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
|
||||
}
|
||||
const id = tail.slice(0, idx);
|
||||
const suffix = tail.slice(idx + 1);
|
||||
// Trailing colon with empty suffix.
|
||||
if (suffix === '') {
|
||||
if (defaultAction !== undefined) {
|
||||
return { kind: 'bare', id: tail };
|
||||
}
|
||||
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
|
||||
}
|
||||
if (id.length === 0) {
|
||||
return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` };
|
||||
}
|
||||
// Type narrowing: only allow declared actions through.
|
||||
const matched = (allowedActions as readonly string[]).find((a) => a === suffix);
|
||||
if (matched === undefined) {
|
||||
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
|
||||
}
|
||||
return { kind: 'action', id, action: matched as TAction };
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { okEnvelope } from '../envelope';
|
|||
import { registerAuthRoute } from './auth';
|
||||
import { registerMetaRoute } from './meta';
|
||||
import { registerOAuthRoutes } from './oauth';
|
||||
import { registerSessionsRoutes } from './sessions';
|
||||
import { registerShutdownRoutes } from './shutdown';
|
||||
|
||||
interface ApiV1AppHost {
|
||||
|
|
@ -27,10 +28,7 @@ interface ApiV1RouteHost {
|
|||
get(
|
||||
path: string,
|
||||
options: { schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => unknown,
|
||||
handler: (req: { id: string }, reply: { send(payload: unknown): unknown }) => unknown,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
|
|
@ -45,43 +43,54 @@ export async function registerApiV1Routes(
|
|||
core: Scope,
|
||||
opts: RegisterApiV1RoutesOptions,
|
||||
): Promise<void> {
|
||||
await app.register(async (apiV1) => {
|
||||
registerHealthRoute(apiV1);
|
||||
await app.register(
|
||||
async (apiV1) => {
|
||||
registerHealthRoute(apiV1);
|
||||
|
||||
registerMetaRoute(apiV1, {
|
||||
serverVersion: opts.serverVersion,
|
||||
serverId: ulid(),
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
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' });
|
||||
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
|
||||
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
|
||||
registerSessionsRoutes(
|
||||
apiV1 as unknown as Parameters<typeof registerSessionsRoutes>[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' } },
|
||||
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' },
|
||||
},
|
||||
request_id: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
return reply.send(okEnvelope({ ok: true }, req.id));
|
||||
});
|
||||
async (req, reply) => {
|
||||
return reply.send(okEnvelope({ ok: true }, req.id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
534
packages/server-v2/src/routes/sessions.ts
Normal file
534
packages/server-v2/src/routes/sessions.ts
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
/**
|
||||
* `/sessions` route handlers — server-v2 port.
|
||||
*
|
||||
* Implements the v1 `/api/v1/sessions` wire contract on top of
|
||||
* `agent-core-v2` services. Only the endpoints v2 can back today are
|
||||
* registered:
|
||||
* POST /sessions create
|
||||
* GET /sessions list
|
||||
* GET /sessions/{session_id} get
|
||||
* GET /sessions/{session_id}/profile
|
||||
* POST /sessions/{session_id}/profile update title (partial)
|
||||
* POST /sessions/{tail} ::archive action
|
||||
* GET /sessions/{session_id}/status best-effort
|
||||
*
|
||||
* The remaining v1 actions (fork / compact / undo / abort / btw / children /
|
||||
* warnings) are not registered because `agent-core-v2` does not yet expose
|
||||
* the backing capabilities — see the server-v2 sessions gap list (G1–G10).
|
||||
*
|
||||
* **Wire fidelity**: mirrors v1's `toProtocolSession`
|
||||
* (`packages/agent-core/src/services/session/session.ts`), which populates
|
||||
* only the index/metadata fields and returns placeholders for the heavy ones
|
||||
* (`agent_config:{model:''}`, `usage:zeros`, `permission_rules:[]`,
|
||||
* `message_count:0`, `last_seq:0`, hardcoded `status:'idle'`). v2 produces the
|
||||
* same placeholder shape from `ISessionIndex` + `IWorkspaceRegistry`.
|
||||
*
|
||||
* **cwd resolution (gap G3)**: v2 does not store the original work dir on the
|
||||
* session; we recover `metadata.cwd` from `IWorkspaceRegistry`
|
||||
* (`workspaceId → root`). Sessions whose workspace is not registered cannot be
|
||||
* represented and are filtered from list / 404 on get.
|
||||
*/
|
||||
|
||||
import {
|
||||
ISessionActivity,
|
||||
ISessionContext,
|
||||
ISessionIndex,
|
||||
ISessionLifecycleService,
|
||||
ISessionMetadata,
|
||||
IWorkspaceRegistry,
|
||||
type Scope,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
ErrorCode,
|
||||
archiveSessionResponseSchema,
|
||||
createSessionRequestSchema,
|
||||
emptySessionUsage,
|
||||
pageResponseSchema,
|
||||
sessionSchema,
|
||||
sessionStatusResponseSchema,
|
||||
sessionStatusSchema,
|
||||
updateSessionProfileRequestSchema,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import type { Session } from '@moonshot-ai/protocol';
|
||||
import { ulid } from 'ulid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errEnvelope, okEnvelope } from '../envelope';
|
||||
import { defineRoute } from '../middleware/defineRoute';
|
||||
import { parseActionSuffix } from './action-suffix';
|
||||
|
||||
interface SessionRouteHost {
|
||||
post(
|
||||
path: string,
|
||||
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
|
||||
handler: (
|
||||
req: { id: string; body: unknown; params: unknown; headers: Record<string, unknown> },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
get(
|
||||
path: string,
|
||||
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
|
||||
handler: (
|
||||
req: { id: string; query: unknown; params: unknown },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
) => Promise<void> | void,
|
||||
): unknown;
|
||||
}
|
||||
|
||||
const booleanQueryParam = z.preprocess((value) => {
|
||||
if (value === 'true' || value === '1' || value === 1 || value === true) return true;
|
||||
if (value === 'false' || value === '0' || value === 0 || value === false) return false;
|
||||
return value;
|
||||
}, z.boolean().optional());
|
||||
|
||||
// NOTE: `status` filtering and the `before_id`/`after_id` id-cursors are
|
||||
// accepted for wire compatibility but not applied — `ISessionIndex` does not
|
||||
// support them (gap G5). `page_size` maps to `limit`; `include_archive` maps
|
||||
// to `includeArchived`; `workspace_id` maps to `workspaceId`.
|
||||
const sessionsListQueryCoercion = z
|
||||
.object({
|
||||
before_id: z.string().min(1).optional(),
|
||||
after_id: z.string().min(1).optional(),
|
||||
page_size: z.coerce.number().int().min(1).max(100).optional(),
|
||||
status: sessionStatusSchema.optional(),
|
||||
include_archive: booleanQueryParam,
|
||||
workspace_id: z.string().min(1).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.before_id !== undefined && value.after_id !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'before_id and after_id are mutually exclusive',
|
||||
path: ['before_id'],
|
||||
params: { code: ErrorCode.VALIDATION_FAILED },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sessionIdParamSchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
});
|
||||
|
||||
const sessionActionTailParamSchema = z.object({
|
||||
tail: z.string().min(1),
|
||||
});
|
||||
|
||||
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
|
||||
|
||||
export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void {
|
||||
const createRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions',
|
||||
body: createSessionRequestSchema,
|
||||
success: { data: sessionSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Create a new session',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = req.body;
|
||||
const callerCwd = typeof body.metadata?.cwd === 'string' ? body.metadata.cwd : undefined;
|
||||
const workspaceId = body.workspace_id;
|
||||
if (workspaceId === undefined && callerCwd === undefined) {
|
||||
reply.send(
|
||||
buildValidationEnvelope(
|
||||
[{ path: 'metadata.cwd', message: 'either workspace_id or metadata.cwd is required' }],
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = core.accessor.get(IWorkspaceRegistry);
|
||||
let workDir: string;
|
||||
if (workspaceId !== undefined) {
|
||||
const workspace = await registry.get(workspaceId);
|
||||
if (workspace === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(
|
||||
ErrorCode.WORKSPACE_NOT_FOUND,
|
||||
`workspace ${workspaceId} does not exist`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (callerCwd !== undefined && callerCwd !== workspace.root) {
|
||||
reply.send(
|
||||
buildValidationEnvelope(
|
||||
[
|
||||
{
|
||||
path: 'metadata.cwd',
|
||||
message: `metadata.cwd (${callerCwd}) must equal workspace root (${workspace.root})`,
|
||||
},
|
||||
],
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
workDir = workspace.root;
|
||||
} else {
|
||||
workDir = callerCwd as string;
|
||||
}
|
||||
|
||||
// Ensure the workspace is registered so `metadata.cwd` is resolvable on
|
||||
// read (gap G3 — v2 does not store workDir on the session).
|
||||
const touched = await registry.createOrTouch(workDir);
|
||||
|
||||
const handle = await core.accessor.get(ISessionLifecycleService).create({
|
||||
sessionId: ulid(),
|
||||
workDir,
|
||||
});
|
||||
if (typeof body.title === 'string') {
|
||||
await handle.accessor.get(ISessionMetadata).setTitle(body.title);
|
||||
}
|
||||
const meta = await handle.accessor.get(ISessionMetadata).read();
|
||||
reply.send(
|
||||
okEnvelope(toWireSession({ ...meta, workspaceId: touched.id }, touched.root), req.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
createRoute.path,
|
||||
createRoute.options,
|
||||
createRoute.handler as Parameters<SessionRouteHost['post']>[2],
|
||||
);
|
||||
|
||||
const listRoute = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions',
|
||||
querystring: sessionsListQueryCoercion,
|
||||
success: { data: pageResponseSchema(sessionSchema) },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
},
|
||||
description: 'List sessions',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const raw = req.query;
|
||||
const limit = raw.page_size;
|
||||
const page = await core.accessor.get(ISessionIndex).list({
|
||||
workspaceId: raw.workspace_id,
|
||||
includeArchived: raw.include_archive,
|
||||
// Fetch one extra to detect `has_more` (FileSessionIndex does not
|
||||
// expose a cursor today).
|
||||
limit: limit !== undefined ? limit + 1 : undefined,
|
||||
});
|
||||
|
||||
let hasMore = false;
|
||||
let summaries = page.items;
|
||||
if (limit !== undefined && summaries.length > limit) {
|
||||
summaries = summaries.slice(0, limit);
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
const workspaces = await core.accessor.get(IWorkspaceRegistry).list();
|
||||
const roots = new Map(workspaces.map((w) => [w.id, w.root]));
|
||||
const items: Session[] = [];
|
||||
for (const summary of summaries) {
|
||||
const cwd = roots.get(summary.workspaceId);
|
||||
if (cwd === undefined) continue; // gap G3: cannot represent cwd
|
||||
items.push(toWireSession(summary, cwd));
|
||||
}
|
||||
reply.send(okEnvelope({ items, has_more: hasMore }, req.id));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
listRoute.path,
|
||||
listRoute.options,
|
||||
listRoute.handler as Parameters<SessionRouteHost['get']>[2],
|
||||
);
|
||||
|
||||
const getRoute = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions/{session_id}',
|
||||
params: sessionIdParamSchema,
|
||||
success: { data: sessionSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Get a session by ID',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { session_id } = req.params;
|
||||
const summary = await core.accessor.get(ISessionIndex).get(session_id);
|
||||
if (summary === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const workspace = await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId);
|
||||
if (workspace === undefined) {
|
||||
// gap G3: persisted session whose workspace is not registered → cwd
|
||||
// cannot be recovered.
|
||||
reply.send(
|
||||
errEnvelope(
|
||||
ErrorCode.SESSION_NOT_FOUND,
|
||||
`session ${session_id} workspace missing`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reply.send(okEnvelope(toWireSession(summary, workspace.root), req.id));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
getRoute.path,
|
||||
getRoute.options,
|
||||
getRoute.handler as Parameters<SessionRouteHost['get']>[2],
|
||||
);
|
||||
|
||||
const getProfileRoute = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions/{session_id}/profile',
|
||||
params: sessionIdParamSchema,
|
||||
success: { data: sessionSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Get session profile',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { session_id } = req.params;
|
||||
const summary = await core.accessor.get(ISessionIndex).get(session_id);
|
||||
if (summary === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const workspace = await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId);
|
||||
if (workspace === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(
|
||||
ErrorCode.SESSION_NOT_FOUND,
|
||||
`session ${session_id} workspace missing`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reply.send(okEnvelope(toWireSession(summary, workspace.root), req.id));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
getProfileRoute.path,
|
||||
getProfileRoute.options,
|
||||
getProfileRoute.handler as Parameters<SessionRouteHost['get']>[2],
|
||||
);
|
||||
|
||||
const updateProfileRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions/{session_id}/profile',
|
||||
params: sessionIdParamSchema,
|
||||
body: updateSessionProfileRequestSchema,
|
||||
success: { data: sessionSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Update session profile (title only in this slice)',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { session_id } = req.params;
|
||||
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
|
||||
if (handle === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const title = req.body.title;
|
||||
if (typeof title === 'string') {
|
||||
await handle.accessor.get(ISessionMetadata).setTitle(title);
|
||||
}
|
||||
const meta = await handle.accessor.get(ISessionMetadata).read();
|
||||
const workspaceId = handle.accessor.get(ISessionContext).workspaceId;
|
||||
const workspace = await core.accessor.get(IWorkspaceRegistry).get(workspaceId);
|
||||
if (workspace === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(
|
||||
ErrorCode.SESSION_NOT_FOUND,
|
||||
`session ${session_id} workspace missing`,
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reply.send(okEnvelope(toWireSession({ ...meta, workspaceId }, workspace.root), req.id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
updateProfileRoute.path,
|
||||
updateProfileRoute.options,
|
||||
updateProfileRoute.handler as Parameters<SessionRouteHost['post']>[2],
|
||||
);
|
||||
|
||||
const sessionActionRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions/{tail}',
|
||||
params: sessionActionTailParamSchema,
|
||||
success: { data: archiveSessionResponseSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Run a session action (only ::archive is supported in this slice)',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { tail } = req.params;
|
||||
const parsed = parseActionSuffix({
|
||||
tail,
|
||||
allowedActions: ['archive'] as const,
|
||||
resourceLabel: 'session',
|
||||
});
|
||||
if (parsed.kind !== 'action') {
|
||||
const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`;
|
||||
reply.send(buildValidationEnvelope([{ path: 'session_id', message }], req.id));
|
||||
return;
|
||||
}
|
||||
const handle = core.accessor.get(ISessionLifecycleService).get(parsed.id);
|
||||
if (handle === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await core.accessor.get(ISessionLifecycleService).archive(parsed.id);
|
||||
reply.send(okEnvelope({ archived: true as const }, req.id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
sessionActionRoute.path,
|
||||
sessionActionRoute.options,
|
||||
sessionActionRoute.handler as Parameters<SessionRouteHost['post']>[2],
|
||||
);
|
||||
|
||||
const statusRoute = defineRoute(
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sessions/{session_id}/status',
|
||||
params: sessionIdParamSchema,
|
||||
success: { data: sessionStatusResponseSchema },
|
||||
errors: {
|
||||
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
},
|
||||
description: 'Get realtime session status (best-effort in this slice)',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { session_id } = req.params;
|
||||
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
|
||||
if (handle === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const status = handle.accessor.get(ISessionActivity).status();
|
||||
// Rich fields (model / thinking_level / permission / plan_mode /
|
||||
// swarm_mode / context_*) require the main agent's scope; not wired yet
|
||||
// (gap G10). Return safe defaults for the first slice.
|
||||
reply.send(
|
||||
okEnvelope(
|
||||
{
|
||||
status,
|
||||
thinking_level: '',
|
||||
permission: '',
|
||||
plan_mode: false,
|
||||
swarm_mode: false,
|
||||
context_tokens: 0,
|
||||
max_context_tokens: 0,
|
||||
context_usage: 0,
|
||||
},
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
statusRoute.path,
|
||||
statusRoute.options,
|
||||
statusRoute.handler as Parameters<SessionRouteHost['get']>[2],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API body wrapper — pure field projection from a service return value to the
|
||||
// wire `Session` shape. No service calls, no control flow: handlers pull data
|
||||
// through `ServiceAccessor.get` and pass it straight here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SessionWireFields {
|
||||
readonly id: string;
|
||||
readonly workspaceId: string;
|
||||
readonly title?: string;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
readonly archived: boolean;
|
||||
}
|
||||
|
||||
function toWireSession(fields: SessionWireFields, cwd: string): Session {
|
||||
return {
|
||||
id: fields.id,
|
||||
workspace_id: fields.workspaceId,
|
||||
title: fields.title ?? '',
|
||||
created_at: new Date(fields.createdAt).toISOString(),
|
||||
updated_at: new Date(fields.updatedAt).toISOString(),
|
||||
status: 'idle',
|
||||
archived: fields.archived,
|
||||
metadata: { cwd },
|
||||
agent_config: { model: '' },
|
||||
usage: emptySessionUsage(),
|
||||
permission_rules: [],
|
||||
message_count: 0,
|
||||
last_seq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildValidationEnvelope(
|
||||
details: { path: string; message: string }[],
|
||||
requestId: string,
|
||||
): {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: null;
|
||||
request_id: string;
|
||||
details: { path: string; message: string }[];
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -7,17 +7,31 @@
|
|||
* Core-scoped services through `core.accessor.get(IXxx)`.
|
||||
*/
|
||||
|
||||
import { bootstrap, resolveConfigPath, resolveKimiHome, type Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import {
|
||||
bootstrap,
|
||||
FileStorageService,
|
||||
IAppendLogStorage,
|
||||
IAtomicDocumentStorage,
|
||||
logSeed,
|
||||
resolveConfigPath,
|
||||
resolveKimiHome,
|
||||
resolveLoggingConfig,
|
||||
type Scope,
|
||||
type ScopeSeed,
|
||||
type ServiceIdentifier,
|
||||
} 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 { registerApiV1Routes } from './routes/registerApiV1Routes';
|
||||
import {
|
||||
createServerLogger,
|
||||
type ServerLogger,
|
||||
type ServerLogLevel,
|
||||
} from './services/pinoLoggerService';
|
||||
import { registerRpcRoutes } from './transport/registerRpcRoutes';
|
||||
import { registerWs } from './transport/ws/registerWs';
|
||||
import { getServerVersion } from './version';
|
||||
|
||||
export interface ServerStartOptions {
|
||||
|
|
@ -28,6 +42,8 @@ export interface ServerStartOptions {
|
|||
readonly logLevel?: ServerLogLevel;
|
||||
readonly logger?: ServerLogger;
|
||||
readonly debugEndpoints?: boolean;
|
||||
/** When set, require `Authorization: Bearer <rpcToken>` on `/api/v2`. */
|
||||
readonly rpcToken?: string;
|
||||
}
|
||||
|
||||
export interface RunningServer {
|
||||
|
|
@ -41,10 +57,27 @@ export interface RunningServer {
|
|||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_PORT = 58627;
|
||||
|
||||
function durableStorageSeeds(homeDir: string): ScopeSeed {
|
||||
return [
|
||||
[IAtomicDocumentStorage as ServiceIdentifier<unknown>, new FileStorageService(homeDir)],
|
||||
[IAppendLogStorage as ServiceIdentifier<unknown>, new FileStorageService(homeDir)],
|
||||
];
|
||||
}
|
||||
|
||||
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 });
|
||||
// `ILogOptions` (logSeed) is required by the Session-scoped log writer; any
|
||||
// route that creates a session (e.g. POST /sessions) would otherwise fail to
|
||||
// instantiate the Session scope. Resolve it from env + homeDir like the CLI.
|
||||
const logging = resolveLoggingConfig({ homeDir, env: process.env });
|
||||
// `IAtomicDocumentStorage` / `IAppendLogStorage` default to in-memory; seed
|
||||
// file-backed stores rooted at homeDir so session metadata (and later wire
|
||||
// records) persist to disk where `FileSessionIndex` reads them.
|
||||
const { core } = bootstrap({ homeDir, configPath }, [
|
||||
...logSeed(logging),
|
||||
...durableStorageSeeds(homeDir),
|
||||
]);
|
||||
|
||||
const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
|
||||
|
||||
|
|
@ -72,6 +105,9 @@ export async function startServer(opts: ServerStartOptions = {}): Promise<Runnin
|
|||
},
|
||||
});
|
||||
|
||||
registerRpcRoutes(app, core, { token: opts.rpcToken });
|
||||
registerWs(app, core, { token: opts.rpcToken });
|
||||
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
await app.listen({ host, port });
|
||||
|
|
|
|||
210
packages/server-v2/src/transport/actionMap.ts
Normal file
210
packages/server-v2/src/transport/actionMap.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* `/api/v2` action map — the single allowlist of exposed Services.
|
||||
*
|
||||
* This is the server-side map from a public `resource:action` segment to the
|
||||
* internal `ServiceIdentifier` + method. Domain names (`ISessionMetadata`,
|
||||
* `IProfileService` …) never appear in the URL; this table is the only place
|
||||
* that binds a public action to an internal Service.
|
||||
*
|
||||
* It also doubles as the **cheatsheet** of every directly-exposed endpoint.
|
||||
* `readonly: true` means the action is exposed on `GET` as well as `POST`.
|
||||
*
|
||||
* URL shapes:
|
||||
* /api/v2/:sa Core
|
||||
* /api/v2/session/:session_id/:sa Session
|
||||
* /api/v2/session/:session_id/agent/:agent_id/:sa Agent
|
||||
* where `:sa` = `<resource>:<action>`.
|
||||
*
|
||||
* Only Services that are data/command (JSON in/out, `KimiError`) are listed
|
||||
* here. Handle/stream/byte-store/sink Services are intentionally omitted —
|
||||
* they need a wrapper (see `edge-exposure.md` §4) and are not part of this
|
||||
* direct-exposure surface.
|
||||
*/
|
||||
|
||||
import {
|
||||
IApprovalService,
|
||||
IAuthSummaryService,
|
||||
IBackgroundService,
|
||||
IBootstrapService,
|
||||
IConfigService,
|
||||
IContextMemory,
|
||||
IContextSizeService,
|
||||
IFlagService,
|
||||
IGoalService,
|
||||
IHostFolderBrowser,
|
||||
IInteractionService,
|
||||
IMcpService,
|
||||
IOAuthService,
|
||||
IPermissionModeService,
|
||||
IPermissionRulesService,
|
||||
IPlanService,
|
||||
IProfileService,
|
||||
IProviderService,
|
||||
IQuestionService,
|
||||
ISessionActivity,
|
||||
ISessionIndex,
|
||||
ISessionMetadata,
|
||||
ISessionService,
|
||||
ISwarmService,
|
||||
IToolRegistry,
|
||||
IToolStoreService,
|
||||
IUsageService,
|
||||
IWorkspaceContext,
|
||||
IWorkspaceRegistry,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import type { ActionTarget, ScopeKind } from './channel';
|
||||
|
||||
export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
|
||||
// -------------------------------------------------------------------------
|
||||
// Core (/api/v2/:resource:action)
|
||||
// -------------------------------------------------------------------------
|
||||
core: {
|
||||
'sessions:list': { service: ISessionIndex, method: 'list', readonly: true },
|
||||
'sessions:get': { service: ISessionIndex, method: 'get', readonly: true },
|
||||
'sessions:countActive': { service: ISessionIndex, method: 'countActive', readonly: true },
|
||||
|
||||
'workspaces:list': { service: IWorkspaceRegistry, method: 'list', readonly: true },
|
||||
'workspaces:get': { service: IWorkspaceRegistry, method: 'get', readonly: true },
|
||||
'workspaces:createOrTouch': { service: IWorkspaceRegistry, method: 'createOrTouch' },
|
||||
'workspaces:delete': { service: IWorkspaceRegistry, method: 'delete' },
|
||||
|
||||
'config:get': { service: IConfigService, method: 'get', readonly: true },
|
||||
'config:getAll': { service: IConfigService, method: 'getAll', readonly: true },
|
||||
'config:inspect': { service: IConfigService, method: 'inspect', readonly: true },
|
||||
'config:diagnostics': { service: IConfigService, method: 'diagnostics', readonly: true },
|
||||
'config:set': { service: IConfigService, method: 'set' },
|
||||
'config:replace': { service: IConfigService, method: 'replace' },
|
||||
'config:reload': { service: IConfigService, method: 'reload' },
|
||||
|
||||
'providers:list': { service: IProviderService, method: 'list', readonly: true },
|
||||
'providers:get': { service: IProviderService, method: 'get', readonly: true },
|
||||
'providers:set': { service: IProviderService, method: 'set' },
|
||||
'providers:delete': { service: IProviderService, method: 'delete' },
|
||||
|
||||
'oauth:startLogin': { service: IOAuthService, method: 'startLogin' },
|
||||
'oauth:getFlow': { service: IOAuthService, method: 'getFlow', readonly: true },
|
||||
'oauth:cancelLogin': { service: IOAuthService, method: 'cancelLogin' },
|
||||
'oauth:logout': { service: IOAuthService, method: 'logout' },
|
||||
'oauth:status': { service: IOAuthService, method: 'status', readonly: true },
|
||||
|
||||
'auth:summarize': { service: IAuthSummaryService, method: 'summarize', readonly: true },
|
||||
'auth:ensureReady': { service: IAuthSummaryService, method: 'ensureReady' },
|
||||
|
||||
'flags:snapshot': { service: IFlagService, method: 'snapshot', readonly: true },
|
||||
'flags:enabled': { service: IFlagService, method: 'enabled', readonly: true },
|
||||
'flags:enabledIds': { service: IFlagService, method: 'enabledIds', readonly: true },
|
||||
'flags:explain': { service: IFlagService, method: 'explain', readonly: true },
|
||||
'flags:explainAll': { service: IFlagService, method: 'explainAll', readonly: true },
|
||||
|
||||
'fs:browse': { service: IHostFolderBrowser, method: 'browse', readonly: true },
|
||||
'fs:home': { service: IHostFolderBrowser, method: 'home', readonly: true },
|
||||
|
||||
'meta:getEnv': { service: IBootstrapService, method: 'getEnv', readonly: true },
|
||||
'meta:detect': { service: IBootstrapService, method: 'detect', readonly: true },
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session (/api/v2/session/:session_id/:resource:action)
|
||||
// -------------------------------------------------------------------------
|
||||
session: {
|
||||
'session:read': { service: ISessionMetadata, method: 'read', readonly: true },
|
||||
'session:update': { service: ISessionMetadata, method: 'update' },
|
||||
'session:setTitle': { service: ISessionMetadata, method: 'setTitle' },
|
||||
'session:setArchived': { service: ISessionMetadata, method: 'setArchived' },
|
||||
'session:status': { service: ISessionActivity, method: 'status', readonly: true },
|
||||
'session:isIdle': { service: ISessionActivity, method: 'isIdle', readonly: true },
|
||||
'session:archive': { service: ISessionService, method: 'archive' },
|
||||
|
||||
'approvals:listPending': { service: IApprovalService, method: 'listPending', readonly: true },
|
||||
'approvals:decide': { service: IApprovalService, method: 'decide' },
|
||||
|
||||
'questions:listPending': { service: IQuestionService, method: 'listPending', readonly: true },
|
||||
'questions:answer': { service: IQuestionService, method: 'answer' },
|
||||
|
||||
'interactions:listPending': {
|
||||
service: IInteractionService,
|
||||
method: 'listPending',
|
||||
readonly: true,
|
||||
},
|
||||
'interactions:respond': { service: IInteractionService, method: 'respond' },
|
||||
|
||||
'workspace:resolve': { service: IWorkspaceContext, method: 'resolve', readonly: true },
|
||||
'workspace:isWithin': { service: IWorkspaceContext, method: 'isWithin', readonly: true },
|
||||
'workspace:setWorkDir': { service: IWorkspaceContext, method: 'setWorkDir' },
|
||||
'workspace:addAdditionalDir': { service: IWorkspaceContext, method: 'addAdditionalDir' },
|
||||
'workspace:removeAdditionalDir': { service: IWorkspaceContext, method: 'removeAdditionalDir' },
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Agent (/api/v2/session/:session_id/agent/:agent_id/:resource:action)
|
||||
// -------------------------------------------------------------------------
|
||||
agent: {
|
||||
'goal:get': { service: IGoalService, method: 'getGoal', readonly: true },
|
||||
'goal:create': { service: IGoalService, method: 'createGoal' },
|
||||
'goal:pause': { service: IGoalService, method: 'pauseGoal' },
|
||||
'goal:resume': { service: IGoalService, method: 'resumeGoal' },
|
||||
'goal:cancel': { service: IGoalService, method: 'cancelGoal' },
|
||||
|
||||
'plan:status': { service: IPlanService, method: 'status', readonly: true },
|
||||
'plan:enter': { service: IPlanService, method: 'enter' },
|
||||
'plan:exit': { service: IPlanService, method: 'exit' },
|
||||
'plan:cancel': { service: IPlanService, method: 'cancel' },
|
||||
'plan:clear': { service: IPlanService, method: 'clear' },
|
||||
|
||||
'tasks:list': { service: IBackgroundService, method: 'list', readonly: true },
|
||||
'tasks:get': { service: IBackgroundService, method: 'getTask', readonly: true },
|
||||
'tasks:readOutput': { service: IBackgroundService, method: 'readOutput', readonly: true },
|
||||
'tasks:stop': { service: IBackgroundService, method: 'stop' },
|
||||
'tasks:detach': { service: IBackgroundService, method: 'detach' },
|
||||
|
||||
'usage:status': { service: IUsageService, method: 'status', readonly: true },
|
||||
|
||||
'context:status': { service: IContextSizeService, method: 'getStatus', readonly: true },
|
||||
|
||||
'swarm:isActive': { service: ISwarmService, method: 'isActive', readonly: true },
|
||||
'swarm:enter': { service: ISwarmService, method: 'enter' },
|
||||
'swarm:exit': { service: ISwarmService, method: 'exit' },
|
||||
|
||||
'permission:getMode': { service: IPermissionModeService, method: 'mode', readonly: true },
|
||||
'permission:setMode': { service: IPermissionModeService, method: 'setMode' },
|
||||
|
||||
'permissionRules:list': { service: IPermissionRulesService, method: 'rules', readonly: true },
|
||||
'permissionRules:addRules': { service: IPermissionRulesService, method: 'addRules' },
|
||||
|
||||
'profile:get': { service: IProfileService, method: 'data', readonly: true },
|
||||
'profile:getModel': { service: IProfileService, method: 'getModel', readonly: true },
|
||||
'profile:getSystemPrompt': {
|
||||
service: IProfileService,
|
||||
method: 'getSystemPrompt',
|
||||
readonly: true,
|
||||
},
|
||||
'profile:getActiveToolNames': {
|
||||
service: IProfileService,
|
||||
method: 'getActiveToolNames',
|
||||
readonly: true,
|
||||
},
|
||||
'profile:setModel': { service: IProfileService, method: 'setModel' },
|
||||
'profile:setThinking': { service: IProfileService, method: 'setThinking' },
|
||||
|
||||
'messages:list': { service: IContextMemory, method: 'get', readonly: true },
|
||||
'messages:splice': { service: IContextMemory, method: 'splice' },
|
||||
|
||||
'toolStore:get': { service: IToolStoreService, method: 'get', readonly: true },
|
||||
'toolStore:data': { service: IToolStoreService, method: 'data', readonly: true },
|
||||
'toolStore:set': { service: IToolStoreService, method: 'set' },
|
||||
|
||||
'mcp:list': { service: IMcpService, method: 'list', readonly: true },
|
||||
'mcp:reconnect': { service: IMcpService, method: 'reconnect' },
|
||||
|
||||
'tools:list': { service: IToolRegistry, method: 'list', readonly: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Look up an action target within a scope. */
|
||||
export function resolveAction(
|
||||
scopeKind: ScopeKind,
|
||||
sa: { resource: string; action: string },
|
||||
): ActionTarget | undefined {
|
||||
return actionMap[scopeKind][`${sa.resource}:${sa.action}`];
|
||||
}
|
||||
57
packages/server-v2/src/transport/channel.ts
Normal file
57
packages/server-v2/src/transport/channel.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* `/api/v2` channel transport — shared primitives.
|
||||
*
|
||||
* The transport exposes `agent-core-v2` Services directly (no facade): a URL
|
||||
* carries the scope path + a `resource:action` segment, the dispatcher resolves
|
||||
* the scope + Service + method, and the result is wrapped in the project
|
||||
* envelope. This file holds the small, transport-agnostic pieces shared by the
|
||||
* server dispatcher and the client.
|
||||
*/
|
||||
|
||||
import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
/** Which scope a route resolves before dispatching. */
|
||||
export type ScopeKind = 'core' | 'session' | 'agent';
|
||||
|
||||
/**
|
||||
* One entry in the action map: the Service to resolve and the method to call.
|
||||
* `method` is a string so the map can point at any method without a typed
|
||||
* reference; the dispatcher checks it is a function before calling.
|
||||
*/
|
||||
export interface ActionTarget {
|
||||
readonly service: ServiceIdentifier<unknown>;
|
||||
readonly method: string;
|
||||
/** Read-only methods are exposed on `GET` as well as `POST`. */
|
||||
readonly readonly?: boolean;
|
||||
}
|
||||
|
||||
/** The client-facing channel contract (request/response + future events). */
|
||||
export interface IChannel {
|
||||
call<T>(command: string, arg?: unknown): Promise<T>;
|
||||
listen(event: string, arg?: unknown): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `resource:action` segment. `resource` is the public resource name,
|
||||
* `action` the method name. Service names never contain a colon, so splitting
|
||||
* at the last colon is unambiguous.
|
||||
*/
|
||||
export interface ServiceAction {
|
||||
readonly resource: string;
|
||||
readonly action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `resource:action` segment. Returns `undefined` when the segment has
|
||||
* no colon, an empty resource, or an empty action.
|
||||
*/
|
||||
export function parseServiceAction(sa: string): ServiceAction | undefined {
|
||||
const idx = sa.lastIndexOf(':');
|
||||
if (idx <= 0 || idx === sa.length - 1) return undefined;
|
||||
return { resource: sa.slice(0, idx), action: sa.slice(idx + 1) };
|
||||
}
|
||||
|
||||
/** Build a `resource:action` segment (inverse of {@link parseServiceAction}). */
|
||||
export function formatServiceAction(resource: string, action: string): string {
|
||||
return `${resource}:${action}`;
|
||||
}
|
||||
98
packages/server-v2/src/transport/dispatcher.ts
Normal file
98
packages/server-v2/src/transport/dispatcher.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* `/api/v2` dispatcher — resolves the scope + Service + method from a request
|
||||
* and calls it. No facade: Services are reached directly through the scope
|
||||
* tree, and the `actionMap` is the only thing binding a public action to an
|
||||
* internal Service.
|
||||
*/
|
||||
|
||||
import {
|
||||
ErrorCodes,
|
||||
IAgentLifecycleService,
|
||||
ISessionLifecycleService,
|
||||
KimiError,
|
||||
type IScopeHandle,
|
||||
type Scope,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import { resolveAction } from './actionMap';
|
||||
import type { ActionTarget, ScopeKind, ServiceAction } from './channel';
|
||||
import { assertSerializable } from './errors';
|
||||
|
||||
/**
|
||||
* Resolve the scope a request targets. Returns `undefined` when the referenced
|
||||
* session / agent does not exist (caller maps to `40401`).
|
||||
*/
|
||||
export function resolveScope(
|
||||
core: Scope,
|
||||
scopeKind: ScopeKind,
|
||||
params: Record<string, string>,
|
||||
): Scope | IScopeHandle | undefined {
|
||||
switch (scopeKind) {
|
||||
case 'core':
|
||||
return core;
|
||||
case 'session': {
|
||||
const sessionId = params['session_id'] ?? '';
|
||||
return core.accessor.get(ISessionLifecycleService).get(sessionId);
|
||||
}
|
||||
case 'agent': {
|
||||
const sessionId = params['session_id'] ?? '';
|
||||
const agentId = params['agent_id'] ?? '';
|
||||
const session = core.accessor.get(ISessionLifecycleService).get(sessionId);
|
||||
if (session === undefined) return undefined;
|
||||
return session.accessor.get(IAgentLifecycleService).getHandle(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one call. Throws `KimiError` for expected failures (unknown action,
|
||||
* scope not found, service not in scope, method missing); the route maps them
|
||||
* to the envelope. Unexpected errors propagate and become `50001`.
|
||||
*/
|
||||
export async function dispatch(
|
||||
core: Scope,
|
||||
scopeKind: ScopeKind,
|
||||
params: Record<string, string>,
|
||||
sa: ServiceAction,
|
||||
arg: unknown,
|
||||
): Promise<unknown> {
|
||||
const scope = resolveScope(core, scopeKind, params);
|
||||
if (scope === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.SESSION_NOT_FOUND,
|
||||
`session ${params['session_id'] ?? ''} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const target: ActionTarget | undefined = resolveAction(scopeKind, sa);
|
||||
if (target === undefined) {
|
||||
throw new KimiError(ErrorCodes.REQUEST_INVALID, `unknown action: ${sa.resource}:${sa.action}`);
|
||||
}
|
||||
|
||||
let service: unknown;
|
||||
try {
|
||||
service = scope.accessor.get(target.service);
|
||||
} catch {
|
||||
throw new KimiError(
|
||||
ErrorCodes.REQUEST_INVALID,
|
||||
`service not available in ${scopeKind} scope: ${sa.resource}`,
|
||||
);
|
||||
}
|
||||
|
||||
const member = (service as Record<string, unknown>)[target.method];
|
||||
if (member === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.REQUEST_INVALID,
|
||||
`method not found: ${sa.resource}:${sa.action}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Property read (e.g. `mode`, `rules`, `isActive`) — return as-is.
|
||||
if (typeof member !== 'function') {
|
||||
return assertSerializable(member);
|
||||
}
|
||||
|
||||
const args = Array.isArray(arg) ? arg : arg === undefined ? [] : [arg];
|
||||
const result = await (member as (...a: unknown[]) => unknown).apply(service, args);
|
||||
return assertSerializable(result);
|
||||
}
|
||||
115
packages/server-v2/src/transport/errors.ts
Normal file
115
packages/server-v2/src/transport/errors.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* `/api/v2` transport error handling — map internal errors onto the project
|
||||
* envelope, guard serialization, time-box calls, and gate access.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { ErrorCodes, KimiError } from '@moonshot-ai/agent-core-v2';
|
||||
import { ErrorCode, errEnvelope } from '@moonshot-ai/protocol';
|
||||
|
||||
/** Thrown by {@link withTimeout} when a call exceeds its deadline. */
|
||||
export class TimeoutError extends Error {
|
||||
constructor(readonly ms: number) {
|
||||
super(`call timed out after ${ms}ms`);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Race a promise against a deadline. */
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
if (ms <= 0) return promise;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
|
||||
timer.unref?.();
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
|
||||
[ErrorCodes.SESSION_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND,
|
||||
[ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED,
|
||||
[ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR,
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an internal error to the project envelope. `KimiError` keeps its coded
|
||||
* mapping; everything else becomes `50001`. Stack traces are intentionally not
|
||||
* surfaced.
|
||||
*/
|
||||
export function mapError(err: unknown, requestId: string): ReturnType<typeof errEnvelope> {
|
||||
if (err instanceof KimiError) {
|
||||
const code = KIMI_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR;
|
||||
return errEnvelope(code, err.message, requestId);
|
||||
}
|
||||
if (err instanceof TimeoutError) {
|
||||
return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId);
|
||||
}
|
||||
return errEnvelope(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a `40001` envelope with structured details. */
|
||||
export function validationEnvelope(
|
||||
details: { path: string; message: string }[],
|
||||
requestId: string,
|
||||
): {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: null;
|
||||
request_id: string;
|
||||
details: { path: string; message: string }[];
|
||||
} {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a value survives a JSON round-trip (catches circular refs, `BigInt`,
|
||||
* functions). Returns the value unchanged; throws `KimiError` on failure so the
|
||||
* caller maps it to `50001` with a clear message.
|
||||
*/
|
||||
export function assertSerializable(value: unknown): unknown {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
JSON.stringify(value);
|
||||
} catch (error) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.INTERNAL,
|
||||
`result not serializable: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing-safe bearer-token check against the `Authorization` header. Query
|
||||
* `?tkn=` is intentionally not supported on the wire to avoid leaking tokens
|
||||
* into logs; use the header.
|
||||
*/
|
||||
export function isAuthorized(headers: Record<string, unknown>, token: string): boolean {
|
||||
const auth = headers['authorization'];
|
||||
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false;
|
||||
const presented = auth.slice('Bearer '.length);
|
||||
const a = Buffer.from(presented);
|
||||
const b = Buffer.from(token);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
137
packages/server-v2/src/transport/registerRpcRoutes.ts
Normal file
137
packages/server-v2/src/transport/registerRpcRoutes.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* `/api/v2` route registration — mounts the channel dispatcher on Fastify.
|
||||
*
|
||||
* Three routes mirror the scope tree; all share one handler. `:sa` is the
|
||||
* `resource:action` segment. Reads use `GET`, writes use `POST`.
|
||||
*
|
||||
* GET|POST /api/v2/:sa
|
||||
* GET|POST /api/v2/session/:session_id/:sa
|
||||
* GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa
|
||||
*
|
||||
* Body (POST) or `?arg=<json>` (GET) is the method's single argument.
|
||||
* Responses are always the project envelope (HTTP 200; business outcome in
|
||||
* `code`). Body size, connection timeout, and graceful close are Fastify's.
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import { ErrorCode, errEnvelope, okEnvelope } from '@moonshot-ai/protocol';
|
||||
|
||||
import { actionMap } from './actionMap';
|
||||
import type { ScopeKind } from './channel';
|
||||
import { parseServiceAction } from './channel';
|
||||
import { dispatch } from './dispatcher';
|
||||
import { isAuthorized, mapError, validationEnvelope, withTimeout } from './errors';
|
||||
|
||||
interface RpcRequest {
|
||||
readonly id: string;
|
||||
readonly method: string;
|
||||
readonly body: unknown;
|
||||
readonly query: unknown;
|
||||
readonly params: unknown;
|
||||
readonly headers: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RpcReply {
|
||||
status(code: number): { send(payload: unknown): unknown };
|
||||
send(payload: unknown): unknown;
|
||||
}
|
||||
|
||||
interface RouteHost {
|
||||
get(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
|
||||
post(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface RegisterRpcRoutesOptions {
|
||||
/** When set, require `Authorization: Bearer <token>` on every call. */
|
||||
readonly token?: string;
|
||||
/** Per-call deadline in ms. Default 30s. */
|
||||
readonly callTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const SCOPE_ROUTES: { path: string; scopeKind: ScopeKind }[] = [
|
||||
{ path: '/api/v2/:sa', scopeKind: 'core' },
|
||||
{ path: '/api/v2/session/:session_id/:sa', scopeKind: 'session' },
|
||||
{ path: '/api/v2/session/:session_id/agent/:agent_id/:sa', scopeKind: 'agent' },
|
||||
];
|
||||
|
||||
export function registerRpcRoutes(
|
||||
app: RouteHost,
|
||||
core: Scope,
|
||||
opts: RegisterRpcRoutesOptions = {},
|
||||
): void {
|
||||
for (const { path, scopeKind } of SCOPE_ROUTES) {
|
||||
const handler = makeHandler(core, scopeKind, opts);
|
||||
app.get(path, handler);
|
||||
app.post(path, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function makeHandler(
|
||||
core: Scope,
|
||||
scopeKind: ScopeKind,
|
||||
opts: RegisterRpcRoutesOptions,
|
||||
): (req: RpcRequest, reply: RpcReply) => Promise<unknown> {
|
||||
return async (req, reply) => {
|
||||
const requestId = req.id;
|
||||
|
||||
// Auth.
|
||||
if (opts.token !== undefined && !isAuthorized(req.headers, opts.token)) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send(errEnvelope(ErrorCode.AUTH_TOKEN_UNAUTHORIZED, 'unauthorized', requestId));
|
||||
}
|
||||
|
||||
// Parse `resource:action`.
|
||||
const { sa } = req.params as { sa: string };
|
||||
const parsed = parseServiceAction(sa);
|
||||
if (parsed === undefined) {
|
||||
return reply.send(
|
||||
validationEnvelope(
|
||||
[{ path: 'action', message: `expected <resource>:<action>, got '${sa}'` }],
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Read vs write gate.
|
||||
const target = actionMap[scopeKind][`${parsed.resource}:${parsed.action}`];
|
||||
const isGet = req.method.toUpperCase() === 'GET';
|
||||
if (isGet && target !== undefined && target.readonly !== true) {
|
||||
return reply.send(
|
||||
validationEnvelope(
|
||||
[{ path: 'action', message: `'${sa}' is not a read action` }],
|
||||
requestId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse argument.
|
||||
let arg: unknown;
|
||||
try {
|
||||
arg = isGet ? parseArgFromQuery(req.query) : req.body;
|
||||
} catch {
|
||||
return reply.send(
|
||||
validationEnvelope([{ path: 'arg', message: 'invalid JSON in ?arg=' }], requestId),
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch + timeout + envelope.
|
||||
try {
|
||||
const result = await withTimeout(
|
||||
dispatch(core, scopeKind, req.params as Record<string, string>, parsed, arg),
|
||||
opts.callTimeoutMs ?? 30_000,
|
||||
);
|
||||
return reply.send(okEnvelope(result, requestId));
|
||||
} catch (error) {
|
||||
return reply.send(mapError(error, requestId));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgFromQuery(query: unknown): unknown {
|
||||
const q = query as Record<string, unknown> | undefined;
|
||||
const raw = q?.['arg'];
|
||||
if (raw === undefined) return undefined;
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
return JSON.parse(raw) as unknown;
|
||||
}
|
||||
154
packages/server-v2/src/transport/rpcClient.ts
Normal file
154
packages/server-v2/src/transport/rpcClient.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* `/api/v2` typed client — `fetch`-based channel + scoped typed proxies.
|
||||
*
|
||||
* const client = new RpcClient({ url: 'http://127.0.0.1:58627' });
|
||||
* await client.core<ISessionIndex>('sessions').list(query);
|
||||
* await client.session('abc').service<ISessionMetadata>('session').read();
|
||||
* await client.session('abc').agent('xyz').service<IProfileService>('profile').getModel();
|
||||
*
|
||||
* The client always `POST`s (the server accepts `GET` too, but the typed client
|
||||
* does not need it). The envelope is unwrapped here: a non-zero `code` throws
|
||||
* {@link RpcError}; otherwise `data` is returned.
|
||||
*/
|
||||
|
||||
import type { IChannel } from './channel';
|
||||
import { formatServiceAction } from './channel';
|
||||
|
||||
interface Envelope<T> {
|
||||
readonly code: number;
|
||||
readonly msg: string;
|
||||
readonly data: T;
|
||||
readonly request_id: string;
|
||||
readonly details?: unknown;
|
||||
}
|
||||
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RpcError';
|
||||
}
|
||||
}
|
||||
|
||||
interface HttpChannelOptions {
|
||||
readonly baseUrl: string;
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
/** A single `resource` channel bound to a scope URL. */
|
||||
class HttpChannel implements IChannel {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token?: string;
|
||||
|
||||
constructor(opts: HttpChannelOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.token = opts.token;
|
||||
}
|
||||
|
||||
async call<T>(command: string, arg?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
if (arg !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
body = JSON.stringify(arg);
|
||||
}
|
||||
if (this.token !== undefined) {
|
||||
headers['authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}/${command}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const envelope = (await res.json()) as Envelope<T>;
|
||||
if (envelope.code !== 0) {
|
||||
throw new RpcError(envelope.code, envelope.msg, envelope.details);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
listen<T>(_event: string, _arg?: unknown): T {
|
||||
throw new Error('events not supported over HTTP; use the WS transport');
|
||||
}
|
||||
}
|
||||
|
||||
function makeProxy<T extends object>(channel: IChannel, resource: string): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
return (arg?: unknown) => channel.call(formatServiceAction(resource, prop), arg);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RpcClientOptions {
|
||||
/** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
|
||||
readonly url: string;
|
||||
/** Optional bearer token. */
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
export class RpcClient {
|
||||
private readonly url: string;
|
||||
private readonly token?: string;
|
||||
|
||||
constructor(opts: RpcClientOptions) {
|
||||
this.url = opts.url.replace(/\/$/, '');
|
||||
this.token = opts.token;
|
||||
}
|
||||
|
||||
/** Core-scoped resource, e.g. `client.core<ISessionIndex>('sessions')`. */
|
||||
core<T extends object>(resource: string): T {
|
||||
return makeProxy<T>(
|
||||
new HttpChannel({ baseUrl: `${this.url}/api/v2`, token: this.token }),
|
||||
resource,
|
||||
);
|
||||
}
|
||||
|
||||
/** Session scope entry point. */
|
||||
session(sessionId: string): SessionRpcClient {
|
||||
return new SessionRpcClient(this.url, this.token, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionRpcClient {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token: string | undefined,
|
||||
sessionId: string,
|
||||
) {
|
||||
this.baseUrl = `${url.replace(/\/$/, '')}/api/v2/session/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
/** Session-scoped resource, e.g. `.service<ISessionMetadata>('session')`. */
|
||||
service<T extends object>(resource: string): T {
|
||||
return makeProxy<T>(new HttpChannel({ baseUrl: this.baseUrl, token: this.token }), resource);
|
||||
}
|
||||
|
||||
/** Agent scope entry point. */
|
||||
agent(agentId: string): AgentRpcClient {
|
||||
return new AgentRpcClient(this.baseUrl, this.token, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentRpcClient {
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
sessionBaseUrl: string,
|
||||
private readonly token: string | undefined,
|
||||
agentId: string,
|
||||
) {
|
||||
this.baseUrl = `${sessionBaseUrl}/agent/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
|
||||
/** Agent-scoped resource, e.g. `.service<IProfileService>('profile')`. */
|
||||
service<T extends object>(resource: string): T {
|
||||
return makeProxy<T>(new HttpChannel({ baseUrl: this.baseUrl, token: this.token }), resource);
|
||||
}
|
||||
}
|
||||
49
packages/server-v2/src/transport/ws/eventMap.ts
Normal file
49
packages/server-v2/src/transport/ws/eventMap.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* `/api/v2` WS event map — binds a public event name to the scope's `Event`
|
||||
* source. Mirrors the `actionMap` (which binds `resource:action` → method); this
|
||||
* binds `event` → an `Event` subscription that yields an `IDisposable`.
|
||||
*
|
||||
* Only the high-value streams are exposed for now:
|
||||
* Core `events` — process-wide `DomainEvent` bus (`IEventService`)
|
||||
* Agent `events` — per-agent `AgentEvent` stream (`IEventSink`)
|
||||
*/
|
||||
|
||||
import {
|
||||
IEventService,
|
||||
IEventSink,
|
||||
type DomainEvent,
|
||||
type IDisposable,
|
||||
type IScopeHandle,
|
||||
type Scope,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
import type { ScopeKind } from '../channel';
|
||||
|
||||
type Accessor = Scope | IScopeHandle;
|
||||
|
||||
export interface EventSource {
|
||||
subscribe(scope: Accessor, listener: (data: unknown) => void): IDisposable;
|
||||
}
|
||||
|
||||
export const eventMap: Record<ScopeKind, Record<string, EventSource>> = {
|
||||
core: {
|
||||
events: {
|
||||
subscribe: (scope, listener) =>
|
||||
scope.accessor.get(IEventService).subscribe(listener as (event: DomainEvent) => void),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
// Future: `metadata` (ISessionMetadata.onDidChange), `interactions`
|
||||
// (IInteractionService.onDidChange). These carry no payload today, so they
|
||||
// are not exposed until there is a concrete consumer.
|
||||
},
|
||||
agent: {
|
||||
events: {
|
||||
subscribe: (scope, listener) => scope.accessor.get(IEventSink).on(listener),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveEventSource(scopeKind: ScopeKind, event: string): EventSource | undefined {
|
||||
return eventMap[scopeKind][event];
|
||||
}
|
||||
59
packages/server-v2/src/transport/ws/registerWs.ts
Normal file
59
packages/server-v2/src/transport/ws/registerWs.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* `/api/v2/ws` — mounts the WebSocket endpoint on Fastify's underlying HTTP
|
||||
* server. Uses `ws` in `noServer` mode and handles the `upgrade` event for the
|
||||
* `/api/v2/ws` path only; other upgrade requests are destroyed.
|
||||
*
|
||||
* Lifecycle / cleanup:
|
||||
* - each connection is a {@link WsConnection}, tracked in a set;
|
||||
* - on `app.close()` every connection is closed and the WS server is shut down;
|
||||
* - per-connection heartbeat / cleanup lives in {@link WsConnection}.
|
||||
*/
|
||||
|
||||
import type { Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { WsConnection } from './wsConnection';
|
||||
|
||||
export interface RegisterWsOptions {
|
||||
readonly token?: string;
|
||||
readonly pingIntervalMs?: number;
|
||||
readonly pongTimeoutMs?: number;
|
||||
readonly callTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const WS_PATH = '/api/v2/ws';
|
||||
|
||||
export function registerWs(app: FastifyInstance, core: Scope, opts: RegisterWsOptions = {}): void {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const connections = new Set<WsConnection>();
|
||||
|
||||
app.server.on('upgrade', (req, socket, head) => {
|
||||
const url = req.url ?? '';
|
||||
if (url === WS_PATH || url.startsWith(`${WS_PATH}?`)) {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (socket) => {
|
||||
const conn = new WsConnection({
|
||||
socket,
|
||||
core,
|
||||
token: opts.token,
|
||||
pingIntervalMs: opts.pingIntervalMs,
|
||||
pongTimeoutMs: opts.pongTimeoutMs,
|
||||
callTimeoutMs: opts.callTimeoutMs,
|
||||
});
|
||||
connections.add(conn);
|
||||
socket.on('close', () => connections.delete(conn));
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
for (const conn of connections) conn.close();
|
||||
wss.close();
|
||||
});
|
||||
}
|
||||
177
packages/server-v2/src/transport/ws/wsClient.ts
Normal file
177
packages/server-v2/src/transport/ws/wsClient.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* `/api/v2` WebSocket client — test/consumer-side counterpart of
|
||||
* {@link WsConnection}. Speaks the same JSON protocol:
|
||||
* - `call(scope, sa, arg)` → Promise<data> (rejects on `error`)
|
||||
* - `listen(scope, event)` → AsyncIterable<data> + `cancel()`
|
||||
* - answers `ping` with `pong` (heartbeat)
|
||||
*/
|
||||
|
||||
import { ulid } from 'ulid';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import type { ScopeKind } from '../channel';
|
||||
|
||||
interface PendingCall {
|
||||
readonly resolve: (data: unknown) => void;
|
||||
readonly reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface WsClientOptions {
|
||||
readonly url: string;
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
interface ServerMsg {
|
||||
readonly type: string;
|
||||
readonly id?: string;
|
||||
readonly data?: unknown;
|
||||
readonly code?: number;
|
||||
readonly msg?: string;
|
||||
}
|
||||
|
||||
export class RpcWsError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RpcWsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class WsClient {
|
||||
private readonly ws: WebSocket;
|
||||
private readonly ready: Promise<void>;
|
||||
private resolveReady!: () => void;
|
||||
private rejectReady!: (err: Error) => void;
|
||||
private readonly pending = new Map<string, PendingCall>();
|
||||
private readonly listeners = new Map<string, (data: unknown) => void>();
|
||||
|
||||
constructor(opts: WsClientOptions) {
|
||||
this.ws = new WebSocket(opts.url);
|
||||
this.ready = new Promise<void>((resolve, reject) => {
|
||||
this.resolveReady = resolve;
|
||||
this.rejectReady = reject;
|
||||
});
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.ws.send(JSON.stringify({ type: 'hello', token: opts.token }));
|
||||
});
|
||||
this.ws.on('message', (data) => this.onMessage(data.toString()));
|
||||
this.ws.on('error', (err) => {
|
||||
this.rejectReady(err);
|
||||
for (const p of this.pending.values()) p.reject(err);
|
||||
this.pending.clear();
|
||||
});
|
||||
this.ws.on('close', () => {
|
||||
const err = new Error('ws closed');
|
||||
for (const p of this.pending.values()) p.reject(err);
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
async call<T>(
|
||||
scope: ScopeKind,
|
||||
sa: string,
|
||||
arg?: unknown,
|
||||
scopeIds?: { sessionId?: string; agentId?: string },
|
||||
): Promise<T> {
|
||||
await this.ready;
|
||||
const id = ulid();
|
||||
this.ws.send(JSON.stringify({ type: 'call', id, scope, sa, arg, ...scopeIds }));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: resolve as (d: unknown) => void, reject });
|
||||
});
|
||||
}
|
||||
|
||||
listen<T>(
|
||||
scope: ScopeKind,
|
||||
event: string,
|
||||
scopeIds?: { sessionId?: string; agentId?: string },
|
||||
): { iterator: AsyncIterable<T>; cancel: () => void } {
|
||||
const id = ulid();
|
||||
const queue: T[] = [];
|
||||
let wake: (() => void) | undefined;
|
||||
let done = false;
|
||||
|
||||
this.listeners.set(id, (data) => {
|
||||
queue.push(data as T);
|
||||
wake?.();
|
||||
});
|
||||
|
||||
void this.ready.then(() => {
|
||||
if (this.ws.readyState === this.ws.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'listen', id, scope, event, ...scopeIds }));
|
||||
}
|
||||
});
|
||||
|
||||
const iterator: AsyncIterableIterator<T> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
next: async (): Promise<IteratorResult<T>> => {
|
||||
if (queue.length > 0) return { value: queue.shift() as T, done: false };
|
||||
if (done) return { value: undefined as unknown as T, done: true };
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
if (queue.length > 0) return { value: queue.shift() as T, done: false };
|
||||
return { value: undefined as unknown as T, done: true };
|
||||
},
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
wake?.();
|
||||
this.listeners.delete(id);
|
||||
if (this.ws.readyState === this.ws.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'unlisten', id }));
|
||||
}
|
||||
};
|
||||
|
||||
return { iterator, cancel };
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
private onMessage(raw: string): void {
|
||||
let msg: ServerMsg;
|
||||
try {
|
||||
msg = JSON.parse(raw) as ServerMsg;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
this.resolveReady();
|
||||
return;
|
||||
case 'ping':
|
||||
this.ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
case 'result': {
|
||||
const p = this.pending.get(msg.id ?? '');
|
||||
if (p !== undefined) {
|
||||
this.pending.delete(msg.id ?? '');
|
||||
p.resolve(msg.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
const p = this.pending.get(msg.id ?? '');
|
||||
if (p !== undefined) {
|
||||
this.pending.delete(msg.id ?? '');
|
||||
p.reject(new RpcWsError(msg.code ?? 0, msg.msg ?? 'error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'event': {
|
||||
const listener = this.listeners.get(msg.id ?? '');
|
||||
if (listener !== undefined) listener(msg.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
337
packages/server-v2/src/transport/ws/wsConnection.ts
Normal file
337
packages/server-v2/src/transport/ws/wsConnection.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* `/api/v2` WebSocket connection — per-connection lifecycle.
|
||||
*
|
||||
* Multiplexes RPC `call`s and event `listen`s over one socket, carrying the
|
||||
* safety features from v1's `WsConnection` and VSCode's `ChannelServer`:
|
||||
* - request ids + active-request table (cancel / unlisten disposes them)
|
||||
* - heartbeat (ping / pong timeout → terminate)
|
||||
* - schema validation (invalid frames are dropped, not fatal)
|
||||
* - graceful cleanup on close (dispose listeners, cancel pending)
|
||||
* - no stack traces over the wire
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { ErrorCodes, KimiError, type IDisposable, type Scope } from '@moonshot-ai/agent-core-v2';
|
||||
import { ulid } from 'ulid';
|
||||
import type { RawData, WebSocket } from 'ws';
|
||||
|
||||
import type { ScopeKind } from '../channel';
|
||||
import { parseServiceAction } from '../channel';
|
||||
import { dispatch, resolveScope } from '../dispatcher';
|
||||
import { assertSerializable, mapError } from '../errors';
|
||||
import { resolveEventSource } from './eventMap';
|
||||
import type { CallMessage, ListenMessage, ServerMessage } from './wsProtocol';
|
||||
import { clientMessageSchema } from './wsProtocol';
|
||||
|
||||
const DEFAULT_PING_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_PONG_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface PendingEntry {
|
||||
/** Dispose the listener / drop the call result. */
|
||||
readonly cancel: () => void;
|
||||
}
|
||||
|
||||
export interface WsConnectionOptions {
|
||||
readonly socket: WebSocket;
|
||||
readonly core: Scope;
|
||||
readonly token?: string;
|
||||
readonly pingIntervalMs?: number;
|
||||
readonly pongTimeoutMs?: number;
|
||||
readonly callTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class WsConnection {
|
||||
readonly id: string;
|
||||
private readonly socket: WebSocket;
|
||||
private readonly core: Scope;
|
||||
private readonly token?: string;
|
||||
private readonly pingIntervalMs: number;
|
||||
private readonly pongTimeoutMs: number;
|
||||
private readonly callTimeoutMs: number;
|
||||
|
||||
private closed = false;
|
||||
private gotHello = false;
|
||||
private readonly pending = new Map<string, PendingEntry>();
|
||||
private pingTimer?: ReturnType<typeof setInterval>;
|
||||
private pongTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(opts: WsConnectionOptions) {
|
||||
this.id = `conn_${ulid()}`;
|
||||
this.socket = opts.socket;
|
||||
this.core = opts.core;
|
||||
this.token = opts.token;
|
||||
this.pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
|
||||
this.pongTimeoutMs = opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
|
||||
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
||||
|
||||
this.socket.on('message', (data: RawData) => this.onMessage(data));
|
||||
this.socket.on('close', () => this.onClose());
|
||||
this.socket.on('error', () => this.onClose());
|
||||
|
||||
this.startHeartbeat();
|
||||
this.send({ type: 'ready', heartbeatMs: this.pingIntervalMs });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inbound
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private onMessage(data: RawData): void {
|
||||
if (this.closed) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(data));
|
||||
} catch {
|
||||
return; // non-JSON frame — drop
|
||||
}
|
||||
|
||||
const result = clientMessageSchema.safeParse(parsed);
|
||||
if (!result.success) return; // invalid frame — drop, don't tear down
|
||||
|
||||
const msg = result.data;
|
||||
switch (msg.type) {
|
||||
case 'hello':
|
||||
this.onHello(msg.token);
|
||||
return;
|
||||
case 'pong':
|
||||
this.onPong();
|
||||
return;
|
||||
case 'call':
|
||||
void this.onCall(msg);
|
||||
return;
|
||||
case 'cancel':
|
||||
this.cancel(msg.id);
|
||||
return;
|
||||
case 'listen':
|
||||
this.onListen(msg);
|
||||
return;
|
||||
case 'unlisten':
|
||||
this.cancel(msg.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private onHello(token: string | undefined): void {
|
||||
if (this.token !== undefined) {
|
||||
const ok =
|
||||
token !== undefined &&
|
||||
Buffer.from(token).length === Buffer.from(this.token).length &&
|
||||
timingSafeEqual(Buffer.from(token), Buffer.from(this.token));
|
||||
if (!ok) {
|
||||
this.send({ type: 'error', id: '', code: 40112, msg: 'unauthorized' });
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.gotHello = true;
|
||||
}
|
||||
|
||||
private async onCall(msg: CallMessage): Promise<void> {
|
||||
if (!this.gotHello) {
|
||||
this.send({ type: 'error', id: msg.id, code: 40112, msg: 'hello required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseServiceAction(msg.sa);
|
||||
if (parsed === undefined) {
|
||||
this.send({
|
||||
type: 'error',
|
||||
id: msg.id,
|
||||
code: 40001,
|
||||
msg: `expected <resource>:<action>, got '${msg.sa}'`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Track so `cancel` can drop the result.
|
||||
let settled = false;
|
||||
const entry: PendingEntry = {
|
||||
cancel: () => {
|
||||
settled = true;
|
||||
this.pending.delete(msg.id);
|
||||
},
|
||||
};
|
||||
this.pending.set(msg.id, entry);
|
||||
|
||||
try {
|
||||
const data = await withTimeoutWs(
|
||||
dispatch(this.core, msg.scope as ScopeKind, scopeParams(msg), parsed, msg.arg),
|
||||
this.callTimeoutMs,
|
||||
);
|
||||
if (settled || this.closed) return;
|
||||
this.pending.delete(msg.id);
|
||||
this.send({ type: 'result', id: msg.id, data });
|
||||
} catch (error) {
|
||||
if (settled || this.closed) return;
|
||||
this.pending.delete(msg.id);
|
||||
const env = mapError(error, msg.id);
|
||||
this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg });
|
||||
}
|
||||
}
|
||||
|
||||
private onListen(msg: ListenMessage): void {
|
||||
if (!this.gotHello) {
|
||||
this.send({ type: 'error', id: msg.id, code: 40112, msg: 'hello required' });
|
||||
return;
|
||||
}
|
||||
if (this.pending.has(msg.id)) {
|
||||
this.send({ type: 'error', id: msg.id, code: 40001, msg: `id already in use: ${msg.id}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const source = resolveEventSource(msg.scope as ScopeKind, msg.event);
|
||||
if (source === undefined) {
|
||||
this.send({ type: 'error', id: msg.id, code: 40001, msg: `unknown event: ${msg.event}` });
|
||||
return;
|
||||
}
|
||||
|
||||
let scope;
|
||||
try {
|
||||
scope = resolveScope(this.core, msg.scope as ScopeKind, scopeParams(msg));
|
||||
} catch {
|
||||
scope = undefined;
|
||||
}
|
||||
if (scope === undefined) {
|
||||
this.send({
|
||||
type: 'error',
|
||||
id: msg.id,
|
||||
code: 40401,
|
||||
msg: `session ${msg.sessionId ?? ''} not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let disposable: IDisposable;
|
||||
try {
|
||||
disposable = source.subscribe(scope, (data) => this.sendEvent(msg.id, data));
|
||||
} catch (error) {
|
||||
const env = mapError(error, msg.id);
|
||||
this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg });
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.set(msg.id, {
|
||||
cancel: () => {
|
||||
disposable.dispose();
|
||||
this.pending.delete(msg.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private cancel(id: string): void {
|
||||
const entry = this.pending.get(id);
|
||||
if (entry !== undefined) entry.cancel();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Outbound
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private sendEvent(id: string, data: unknown): void {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
const wire = assertSerializable(data);
|
||||
this.send({ type: 'event', id, data: wire });
|
||||
} catch {
|
||||
// Non-serializable event payload — drop, don't tear down the connection.
|
||||
}
|
||||
}
|
||||
|
||||
private send(msg: ServerMessage): void {
|
||||
if (this.closed || this.socket.readyState !== this.socket.OPEN) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Heartbeat
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.closed) return;
|
||||
this.send({ type: 'ping' });
|
||||
if (this.pongTimer !== undefined) clearTimeout(this.pongTimer);
|
||||
this.pongTimer = setTimeout(() => {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
this.socket.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, this.pongTimeoutMs);
|
||||
this.pongTimer.unref?.();
|
||||
}, this.pingIntervalMs);
|
||||
this.pingTimer.unref?.();
|
||||
}
|
||||
|
||||
private onPong(): void {
|
||||
if (this.pongTimer !== undefined) {
|
||||
clearTimeout(this.pongTimer);
|
||||
this.pongTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Close
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
this.socket.close(1000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private onClose(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (this.pingTimer !== undefined) clearInterval(this.pingTimer);
|
||||
if (this.pongTimer !== undefined) clearTimeout(this.pongTimer);
|
||||
for (const entry of this.pending.values()) {
|
||||
try {
|
||||
entry.cancel();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function scopeParams(msg: { sessionId?: string; agentId?: string }): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
if (msg.sessionId !== undefined) params['session_id'] = msg.sessionId;
|
||||
if (msg.agentId !== undefined) params['agent_id'] = msg.agentId;
|
||||
return params;
|
||||
}
|
||||
|
||||
function rawDataToString(data: RawData): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString('utf8');
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
|
||||
return Buffer.from(data as ArrayBuffer).toString('utf8');
|
||||
}
|
||||
|
||||
async function withTimeoutWs<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
if (ms <= 0) return promise;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new KimiError(ErrorCodes.INTERNAL, `call timed out after ${ms}ms`)),
|
||||
ms,
|
||||
);
|
||||
timer.unref?.();
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}) as Promise<T>;
|
||||
}
|
||||
101
packages/server-v2/src/transport/ws/wsProtocol.ts
Normal file
101
packages/server-v2/src/transport/ws/wsProtocol.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* `/api/v2` WebSocket protocol — message shapes and validators.
|
||||
*
|
||||
* A single WS connection multiplexes RPC `call`s and event `listen`s over a
|
||||
* JSON message protocol. Every request carries a client-chosen `id`; the server
|
||||
* correlates responses / events by that id. This is the lean counterpart of
|
||||
* VSCode's framed `IMessagePassingProtocol`, carrying the same safety features
|
||||
* (request ids, cancellation, heartbeats, schema validation, cleanup).
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const scopeKindSchema = z.enum(['core', 'session', 'agent']);
|
||||
|
||||
const helloMessageSchema = z.object({
|
||||
type: z.literal('hello'),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
const callMessageSchema = z.object({
|
||||
type: z.literal('call'),
|
||||
id: z.string().min(1),
|
||||
scope: scopeKindSchema,
|
||||
sessionId: z.string().min(1).optional(),
|
||||
agentId: z.string().min(1).optional(),
|
||||
sa: z.string().min(1),
|
||||
arg: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const cancelMessageSchema = z.object({
|
||||
type: z.literal('cancel'),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const listenMessageSchema = z.object({
|
||||
type: z.literal('listen'),
|
||||
id: z.string().min(1),
|
||||
scope: scopeKindSchema,
|
||||
sessionId: z.string().min(1).optional(),
|
||||
agentId: z.string().min(1).optional(),
|
||||
event: z.string().min(1),
|
||||
});
|
||||
|
||||
const unlistenMessageSchema = z.object({
|
||||
type: z.literal('unlisten'),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const pongMessageSchema = z.object({
|
||||
type: z.literal('pong'),
|
||||
});
|
||||
|
||||
export const clientMessageSchema = z.discriminatedUnion('type', [
|
||||
helloMessageSchema,
|
||||
callMessageSchema,
|
||||
cancelMessageSchema,
|
||||
listenMessageSchema,
|
||||
unlistenMessageSchema,
|
||||
pongMessageSchema,
|
||||
]);
|
||||
|
||||
export type ClientMessage = z.infer<typeof clientMessageSchema>;
|
||||
export type CallMessage = z.infer<typeof callMessageSchema>;
|
||||
export type ListenMessage = z.infer<typeof listenMessageSchema>;
|
||||
|
||||
// Server → client messages (built directly; not validated).
|
||||
|
||||
export interface ReadyMessage {
|
||||
readonly type: 'ready';
|
||||
readonly heartbeatMs: number;
|
||||
}
|
||||
|
||||
export interface ResultMessage {
|
||||
readonly type: 'result';
|
||||
readonly id: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
readonly type: 'error';
|
||||
readonly id: string;
|
||||
readonly code: number;
|
||||
readonly msg: string;
|
||||
}
|
||||
|
||||
export interface EventMessage {
|
||||
readonly type: 'event';
|
||||
readonly id: string;
|
||||
readonly data: unknown;
|
||||
}
|
||||
|
||||
export interface PingMessage {
|
||||
readonly type: 'ping';
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| ReadyMessage
|
||||
| ResultMessage
|
||||
| ErrorMessage
|
||||
| EventMessage
|
||||
| PingMessage;
|
||||
285
packages/server-v2/test/rpc.test.ts
Normal file
285
packages/server-v2/test/rpc.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ISessionIndex, ISessionMetadata } from '@moonshot-ai/agent-core-v2';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type RunningServer, startServer } from '../src/start';
|
||||
import { RpcClient, RpcError } from '../src/transport/rpcClient';
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
request_id: string;
|
||||
details?: { path: string; message: string }[];
|
||||
}
|
||||
|
||||
interface SessionMetaWire {
|
||||
id: string;
|
||||
title?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
describe('server-v2 /api/v2 RPC', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-'));
|
||||
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function call<T>(
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
arg?: unknown,
|
||||
token?: string,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const headers: Record<string, string> = {};
|
||||
let url = `${base}${path}`;
|
||||
const init: { method: string; headers: Record<string, string>; body?: string } = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
if (method === 'GET') {
|
||||
if (arg !== undefined) url += `?arg=${encodeURIComponent(JSON.stringify(arg))}`;
|
||||
} else if (arg !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
init.body = JSON.stringify(arg);
|
||||
}
|
||||
if (token !== undefined) headers['authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(url, init);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
async function createSession(cwd: string): Promise<string> {
|
||||
const res = await fetch(`${base}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd } }),
|
||||
});
|
||||
const body = (await res.json()) as Envelope<{ id: string }>;
|
||||
expect(body.code).toBe(0);
|
||||
return body.data.id;
|
||||
}
|
||||
|
||||
// --- Core scope -----------------------------------------------------------
|
||||
|
||||
it('lists sessions via GET (readonly)', async () => {
|
||||
const { body } = await call<{ items: unknown[]; has_more: boolean }>(
|
||||
'GET',
|
||||
'/api/v2/sessions:list',
|
||||
{},
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
expect(Array.isArray(body.data.items)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a workspace and reads it back', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await call<{ id: string; root: string }>(
|
||||
'POST',
|
||||
'/api/v2/workspaces:createOrTouch',
|
||||
cwd,
|
||||
);
|
||||
expect(created.body.code).toBe(0);
|
||||
expect(created.body.data.root).toBe(cwd);
|
||||
|
||||
const got = await call<{ id: string; root: string }>(
|
||||
'GET',
|
||||
'/api/v2/workspaces:get',
|
||||
created.body.data.id,
|
||||
);
|
||||
expect(got.body.code).toBe(0);
|
||||
expect(got.body.data.root).toBe(cwd);
|
||||
});
|
||||
|
||||
it('counts active sessions', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await call<{ id: string }>('POST', '/api/v2/workspaces:createOrTouch', cwd);
|
||||
await createSession(cwd);
|
||||
const { body } = await call<number>(
|
||||
'POST',
|
||||
'/api/v2/sessions:countActive',
|
||||
created.body.data.id,
|
||||
);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// --- Session scope --------------------------------------------------------
|
||||
|
||||
it('reads and updates session metadata', async () => {
|
||||
const id = await createSession(home as string);
|
||||
|
||||
const read = await call<SessionMetaWire>('POST', `/api/v2/session/${id}/session:read`);
|
||||
expect(read.body.code).toBe(0);
|
||||
expect(read.body.data.id).toBe(id);
|
||||
|
||||
const set = await call<null>('POST', `/api/v2/session/${id}/session:setTitle`, 'renamed');
|
||||
expect(set.body.code).toBe(0);
|
||||
|
||||
const read2 = await call<SessionMetaWire>('POST', `/api/v2/session/${id}/session:read`);
|
||||
expect(read2.body.data.title).toBe('renamed');
|
||||
});
|
||||
|
||||
it('returns session status', async () => {
|
||||
const id = await createSession(home as string);
|
||||
const { body } = await call<string>('POST', `/api/v2/session/${id}/session:status`);
|
||||
expect(body.code).toBe(0);
|
||||
expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(body.data);
|
||||
});
|
||||
|
||||
it('archives a session', async () => {
|
||||
const id = await createSession(home as string);
|
||||
const { body } = await call<null>('POST', `/api/v2/session/${id}/session:archive`);
|
||||
expect(body.code).toBe(0);
|
||||
});
|
||||
|
||||
// --- typed client ---------------------------------------------------------
|
||||
|
||||
it('works through the typed RpcClient', async () => {
|
||||
const cwd = home as string;
|
||||
await createSession(cwd);
|
||||
const client = new RpcClient({ url: base });
|
||||
|
||||
const sessions = client.core<ISessionIndex>('sessions');
|
||||
const page = await sessions.list({});
|
||||
expect(page.items.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const id = page.items[0]!.id;
|
||||
const meta = client.session(id).service<ISessionMetadata>('session');
|
||||
const read = await meta.read();
|
||||
expect(read.id).toBe(id);
|
||||
});
|
||||
|
||||
it('throws RpcError on unknown action', async () => {
|
||||
const client = new RpcClient({ url: base });
|
||||
const sessions = client.core<ISessionIndex>('sessions');
|
||||
// @ts-expect-error — intentionally calling a non-existent method
|
||||
await expect(sessions.nope()).rejects.toBeInstanceOf(RpcError);
|
||||
});
|
||||
|
||||
// --- NFR ------------------------------------------------------------------
|
||||
|
||||
it('rejects unknown action (40001)', async () => {
|
||||
const { body } = await call<null>('POST', '/api/v2/sessions:nope');
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('rejects malformed segment without colon (40001)', async () => {
|
||||
const { body } = await call<null>('POST', '/api/v2/sessions');
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('rejects unknown session (40401)', async () => {
|
||||
const { body } = await call<null>('POST', '/api/v2/session/nope/session:read');
|
||||
expect(body.code).toBe(40401);
|
||||
});
|
||||
|
||||
it('rejects GET on a write action (40001)', async () => {
|
||||
const id = await createSession(home as string);
|
||||
const { body } = await call<null>('GET', `/api/v2/session/${id}/session:archive`);
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('rejects oversized body', async () => {
|
||||
// Fastify's default bodyLimit is 1MB; a larger body is rejected. Fastify's
|
||||
// body-parser throws a 413 which our global error handler currently wraps
|
||||
// as `50001` (HTTP 200) — either way the request is rejected, not served.
|
||||
const huge = 'x'.repeat(2 * 1024 * 1024);
|
||||
let rejected = false;
|
||||
let code: number | undefined;
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v2/sessions:list`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ big: huge }),
|
||||
});
|
||||
const body = (await res.json()) as Envelope<null>;
|
||||
rejected = res.status === 413 || body.code !== 0;
|
||||
code = body.code;
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
expect(rejected).toBe(true);
|
||||
expect(code).not.toBe(0);
|
||||
});
|
||||
|
||||
it('does not leak stack traces on error', async () => {
|
||||
const { body } = await call<null>('POST', '/api/v2/session/nope/session:read');
|
||||
expect(JSON.stringify(body)).not.toContain('stack');
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v2 RPC auth', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
const token = 'test-secret-token';
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-auth-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
rpcToken: token,
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects calls without a token (40112)', async () => {
|
||||
const res = await fetch(`${base}/api/v2/sessions:list`, { method: 'POST' });
|
||||
const body = (await res.json()) as Envelope<null>;
|
||||
expect(body.code).toBe(40112);
|
||||
});
|
||||
|
||||
it('accepts calls with the correct token', async () => {
|
||||
const res = await fetch(`${base}/api/v2/sessions:list`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const body = (await res.json()) as Envelope<{ items: unknown[] }>;
|
||||
expect(body.code).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a wrong token (40112)', async () => {
|
||||
const res = await fetch(`${base}/api/v2/sessions:list`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: 'Bearer wrong' },
|
||||
});
|
||||
const body = (await res.json()) as Envelope<null>;
|
||||
expect(body.code).toBe(40112);
|
||||
});
|
||||
});
|
||||
216
packages/server-v2/test/sessions.test.ts
Normal file
216
packages/server-v2/test/sessions.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type RunningServer, startServer } from '../src/start';
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
request_id: string;
|
||||
details?: { path: string; message: string }[];
|
||||
}
|
||||
|
||||
interface SessionWire {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: string;
|
||||
archived?: boolean;
|
||||
metadata: { cwd: string } & Record<string, unknown>;
|
||||
agent_config: { model: string };
|
||||
usage: { input_tokens: number };
|
||||
permission_rules: unknown[];
|
||||
message_count: number;
|
||||
last_seq: number;
|
||||
}
|
||||
|
||||
interface PageWire {
|
||||
items: SessionWire[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
describe('server-v2 /api/v1/sessions', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function postJson<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const hasBody = body !== undefined;
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method: 'POST',
|
||||
headers: hasBody ? { 'content-type': 'application/json' } : undefined,
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
|
||||
const res = await fetch(`${base}${path}`);
|
||||
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
||||
}
|
||||
|
||||
it('creates a session from metadata.cwd', async () => {
|
||||
const cwd = home as string;
|
||||
const { status, body } = await postJson<SessionWire>('/api/v1/sessions', {
|
||||
title: 'hello',
|
||||
metadata: { cwd },
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
expect(typeof body.data.id).toBe('string');
|
||||
expect(typeof body.data.workspace_id).toBe('string');
|
||||
expect(body.data.title).toBe('hello');
|
||||
expect(body.data.metadata.cwd).toBe(cwd);
|
||||
expect(body.data.status).toBe('idle');
|
||||
expect(body.data.agent_config).toEqual({ model: '' });
|
||||
expect(body.data.permission_rules).toEqual([]);
|
||||
expect(body.data.message_count).toBe(0);
|
||||
expect(body.data.last_seq).toBe(0);
|
||||
expect(Number.isNaN(Date.parse(body.data.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects create without cwd or workspace_id (40001)', async () => {
|
||||
const { body } = await postJson<null>('/api/v1/sessions', { title: 'no cwd' });
|
||||
expect(body.code).toBe(40001);
|
||||
expect(body.details?.[0]?.path).toBe('metadata.cwd');
|
||||
});
|
||||
|
||||
it('rejects create with unknown workspace_id (40410)', async () => {
|
||||
const { body } = await postJson<null>('/api/v1/sessions', {
|
||||
workspace_id: 'wd_missing_000000000000',
|
||||
metadata: { cwd: '/x' },
|
||||
});
|
||||
expect(body.code).toBe(40410);
|
||||
});
|
||||
|
||||
it('creates a second session via workspace_id resolved from a prior cwd create', async () => {
|
||||
const cwd = home as string;
|
||||
const first = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
expect(first.body.code).toBe(0);
|
||||
|
||||
const second = await postJson<SessionWire>('/api/v1/sessions', {
|
||||
workspace_id: first.body.data.workspace_id,
|
||||
metadata: { cwd },
|
||||
});
|
||||
expect(second.body.code).toBe(0);
|
||||
expect(second.body.data.workspace_id).toBe(first.body.data.workspace_id);
|
||||
expect(second.body.data.id).not.toBe(first.body.data.id);
|
||||
});
|
||||
|
||||
it('rejects create when cwd mismatches workspace root (40001)', async () => {
|
||||
const cwd = home as string;
|
||||
const first = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const { body } = await postJson<null>('/api/v1/sessions', {
|
||||
workspace_id: first.body.data.workspace_id,
|
||||
metadata: { cwd: '/definitely/elsewhere' },
|
||||
});
|
||||
expect(body.code).toBe(40001);
|
||||
expect(body.details?.[0]?.path).toBe('metadata.cwd');
|
||||
});
|
||||
|
||||
it('lists created sessions', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const { body } = await getJson<PageWire>('/api/v1/sessions');
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.items.some((s) => s.id === created.body.data.id)).toBe(true);
|
||||
expect(typeof body.data.has_more).toBe('boolean');
|
||||
});
|
||||
|
||||
it('gets a session by id and 404s for unknown', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
|
||||
const got = await getJson<SessionWire>(`/api/v1/sessions/${created.body.data.id}`);
|
||||
expect(got.body.code).toBe(0);
|
||||
expect(got.body.data.id).toBe(created.body.data.id);
|
||||
|
||||
const missing = await getJson<null>('/api/v1/sessions/nope');
|
||||
expect(missing.body.code).toBe(40401);
|
||||
});
|
||||
|
||||
it('updates the session title via profile', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const id = created.body.data.id;
|
||||
|
||||
const updated = await postJson<SessionWire>(`/api/v1/sessions/${id}/profile`, {
|
||||
title: 'renamed',
|
||||
});
|
||||
expect(updated.body.code).toBe(0);
|
||||
expect(updated.body.data.title).toBe('renamed');
|
||||
|
||||
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
|
||||
expect(got.body.data.title).toBe('renamed');
|
||||
});
|
||||
|
||||
it('returns best-effort status for a live session', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const { body } = await getJson<{
|
||||
status: string;
|
||||
thinking_level: string;
|
||||
plan_mode: boolean;
|
||||
context_tokens: number;
|
||||
}>(`/api/v1/sessions/${created.body.data.id}/status`);
|
||||
expect(body.code).toBe(0);
|
||||
expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(
|
||||
body.data.status,
|
||||
);
|
||||
expect(typeof body.data.thinking_level).toBe('string');
|
||||
expect(typeof body.data.plan_mode).toBe('boolean');
|
||||
expect(body.data.context_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it('archives a session via :archive and reflects archived flag on get', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const id = created.body.data.id;
|
||||
|
||||
const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`);
|
||||
expect(archived.body.code).toBe(0);
|
||||
expect(archived.body.data).toEqual({ archived: true });
|
||||
|
||||
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
|
||||
expect(got.body.code).toBe(0);
|
||||
expect(got.body.data.archived).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unsupported action suffix (40001)', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
const { body } = await postJson<null>(`/api/v1/sessions/${created.body.data.id}:fork`);
|
||||
expect(body.code).toBe(40001);
|
||||
});
|
||||
});
|
||||
168
packages/server-v2/test/ws.test.ts
Normal file
168
packages/server-v2/test/ws.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { type RunningServer, startServer } from '../src/start';
|
||||
import { RpcWsError, WsClient } from '../src/transport/ws/wsClient';
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface SessionMetaWire {
|
||||
id: string;
|
||||
title?: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
describe('server-v2 /api/v2/ws', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let base: string;
|
||||
let wsUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-'));
|
||||
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
wsUrl = `ws://127.0.0.1:${server.port}/api/v2/ws`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function createSession(cwd: string): Promise<string> {
|
||||
const res = await fetch(`${base}/api/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: { cwd } }),
|
||||
});
|
||||
const body = (await res.json()) as Envelope<{ id: string }>;
|
||||
expect(body.code).toBe(0);
|
||||
return body.data.id;
|
||||
}
|
||||
|
||||
it('performs a core call over WS', async () => {
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
const page = await client.call<{ items: unknown[] }>('core', 'sessions:list', {});
|
||||
expect(Array.isArray(page.items)).toBe(true);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('performs a session call over WS', async () => {
|
||||
const id = await createSession(home as string);
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
const meta = await client.call<SessionMetaWire>('session', 'session:read', undefined, {
|
||||
sessionId: id,
|
||||
});
|
||||
expect(meta.id).toBe(id);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('returns an error for an unknown action', async () => {
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
await expect(client.call('core', 'sessions:nope')).rejects.toMatchObject({
|
||||
code: 40001,
|
||||
});
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('streams core events via listen', async () => {
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
const { iterator, cancel } = client.listen<{ type: string; payload: unknown }>(
|
||||
'core',
|
||||
'events',
|
||||
);
|
||||
|
||||
// Ensure the subscription is registered before publishing.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const id = await createSession(home as string);
|
||||
await fetch(`${base}/api/v1/sessions/${id}:archive`, { method: 'POST' });
|
||||
|
||||
const iter = iterator[Symbol.asyncIterator]();
|
||||
const next = await Promise.race([
|
||||
iter.next(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('no event')), 2000)),
|
||||
]);
|
||||
expect(next.done).toBe(false);
|
||||
expect(next.value).toMatchObject({
|
||||
type: 'event.session.archived',
|
||||
payload: { sessionId: id },
|
||||
});
|
||||
cancel();
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('cancels a subscription', async () => {
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
const { iterator, cancel } = client.listen('core', 'events');
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
cancel();
|
||||
const iter = iterator[Symbol.asyncIterator]();
|
||||
const next = await iter.next();
|
||||
expect(next.done).toBe(true);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('rejects pending calls on close', async () => {
|
||||
const client = new WsClient({ url: wsUrl });
|
||||
const pending = client.call('core', 'sessions:list', {});
|
||||
client.close();
|
||||
await expect(pending).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('server-v2 /api/v2/ws auth', () => {
|
||||
let server: RunningServer | undefined;
|
||||
let home: string | undefined;
|
||||
let wsUrl: string;
|
||||
const token = 'ws-secret';
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-auth-'));
|
||||
server = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
rpcToken: token,
|
||||
});
|
||||
wsUrl = `ws://127.0.0.1:${server.port}/api/v2/ws`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
}
|
||||
if (home !== undefined) {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
home = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a call with the correct token', async () => {
|
||||
const client = new WsClient({ url: wsUrl, token });
|
||||
const page = await client.call<{ items: unknown[] }>('core', 'sessions:list', {});
|
||||
expect(Array.isArray(page.items)).toBe(true);
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('rejects a wrong token', async () => {
|
||||
const client = new WsClient({ url: wsUrl, token: 'wrong' });
|
||||
await expect(client.call('core', 'sessions:list', {})).rejects.toThrow();
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
|
|
@ -678,10 +678,16 @@ importers:
|
|||
ulid:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.2
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.0
|
||||
version: 8.18.1
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
|
@ -8369,7 +8375,7 @@ snapshots:
|
|||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@4.1.4':
|
||||
dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue