From cc1ecf01adb85d69d7d19d221ae1330bd4affa55 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 5 Jun 2026 12:19:16 +0800 Subject: [PATCH 001/255] feat: bootstrap local daemon with REST/WS gateway, DI, and protocol/services packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash of 50 commits (9954d7f..c50167a) introducing the local daemon stack: - @moonshot-ai/protocol: envelope, error codes, pagination/time/request_id helpers, ws-control schemas, re-export of event/display types from node-sdk. - @moonshot-ai/services: broker interfaces, HarnessBridge core + BridgeClientAPI, defaultServicesModule shell. - agent-core/di: vendored DI subsystem with createDecorator + serviceIds singleton, SyncDescriptor, InstantiationService (singleton-per-container, child scopes, IDisposable lifecycle), graph-based cycle detection, descriptor + @IFoo auto-injection, supportsDelayedInstantiation Proxy + GlobalIdleValue, TestInstantiationService, Trace, registerSingleton registry, README. - @moonshot-ai/daemon: new package providing 'kimi daemon' command with - filesystem lock against concurrent instances, - REST gateway: /v1/meta, sessions CRUD, messages history, prompt submit, approval bridge (WS broadcast + 60s timeout), question bridge (5-kind normalization), tools list, MCP list/restart, background tasks, fs list/read/stat (+ batch), fs search/grep (rg fallback + timeout), fs git_status, fs download streaming, fs WS watch (chokidar, 100-path cap), Files store (multipart upload, streaming download, delete), - WS gateway: handshake, ping/pong heartbeat, subscribe/unsubscribe, DaemonEventBus with per-session seq, ring buffer with replay + resync_required, abort control message, - DI wiring of all services + brokers + HarnessBridge, - migration of all ctors to @IFoo decorator-based DI (P2.1–P2.6) with experimentalDecorators enabled. --- apps/kimi-code/package.json | 1 + apps/kimi-code/src/cli/commands.ts | 2 + apps/kimi-code/src/cli/sub/daemon.ts | 86 ++ packages/agent-core/package.json | 4 + packages/agent-core/src/di/README.md | 319 ++++++ packages/agent-core/src/di/descriptors.ts | 40 + packages/agent-core/src/di/errors.ts | 54 + packages/agent-core/src/di/extensions.ts | 68 ++ packages/agent-core/src/di/graph.ts | 89 ++ packages/agent-core/src/di/index.ts | 36 + packages/agent-core/src/di/instantiation.ts | 240 +++++ .../agent-core/src/di/instantiationService.ts | 821 +++++++++++++++ packages/agent-core/src/di/lifecycle.ts | 59 ++ .../agent-core/src/di/serviceCollection.ts | 57 + packages/agent-core/src/di/test.ts | 15 + .../src/di/testInstantiationService.ts | 134 +++ packages/agent-core/src/di/util/idleValue.ts | 133 +++ packages/agent-core/src/di/util/linkedList.ts | 86 ++ packages/agent-core/src/index.ts | 3 + .../agent-core/test/di/auto-inject.test.ts | 197 ++++ packages/agent-core/test/di/child.test.ts | 300 ++++++ .../agent-core/test/di/collection.test.ts | 73 ++ packages/agent-core/test/di/cyclic.test.ts | 223 ++++ packages/agent-core/test/di/decorator.test.ts | 113 ++ packages/agent-core/test/di/delayed.test.ts | 133 +++ .../agent-core/test/di/descriptor.test.ts | 56 + .../agent-core/test/di/extensions.test.ts | 192 ++++ packages/agent-core/test/di/graph.test.ts | 38 + .../agent-core/test/di/instantiation.test.ts | 162 +++ .../agent-core/test/di/self-register.test.ts | 37 + .../test/di/test-instantiation.test.ts | 98 ++ packages/agent-core/test/di/trace.test.ts | 55 + packages/daemon/package.json | 52 + packages/daemon/src/envelope.ts | 14 + packages/daemon/src/error-handler.ts | 53 + packages/daemon/src/index.ts | 29 + packages/daemon/src/lock.ts | 221 ++++ packages/daemon/src/logger.ts | 37 + packages/daemon/src/middleware/validate.ts | 133 +++ packages/daemon/src/request-id.ts | 31 + packages/daemon/src/routes/action-suffix.ts | 91 ++ packages/daemon/src/routes/approvals.ts | 137 +++ packages/daemon/src/routes/files.ts | 355 +++++++ packages/daemon/src/routes/fs.ts | 674 ++++++++++++ packages/daemon/src/routes/messages.ts | 165 +++ packages/daemon/src/routes/meta.ts | 70 ++ packages/daemon/src/routes/prompts.ts | 190 ++++ packages/daemon/src/routes/questions.ts | 179 ++++ packages/daemon/src/routes/sessions.ts | 233 ++++ packages/daemon/src/routes/tasks.ts | 211 ++++ packages/daemon/src/routes/tools.ts | 143 +++ .../daemon/src/services/approval-broker.ts | 304 ++++++ .../src/services/connection-registry.ts | 93 ++ packages/daemon/src/services/event-bus.ts | 258 +++++ packages/daemon/src/services/file-store.ts | 376 +++++++ packages/daemon/src/services/fs-git.ts | 313 ++++++ .../daemon/src/services/fs-path-safety.ts | 228 ++++ packages/daemon/src/services/fs-search.ts | 835 +++++++++++++++ packages/daemon/src/services/fs-service.ts | 996 ++++++++++++++++++ packages/daemon/src/services/fs-watcher.ts | 786 ++++++++++++++ packages/daemon/src/services/logger.ts | 73 ++ .../daemon/src/services/question-broker.ts | 276 +++++ packages/daemon/src/services/rest-gateway.ts | 72 ++ .../daemon/src/services/session-clients.ts | 112 ++ packages/daemon/src/services/ws-gateway.ts | 193 ++++ packages/daemon/src/start.ts | 711 +++++++++++++ packages/daemon/src/version.ts | 26 + packages/daemon/src/ws/connection.ts | 683 ++++++++++++ packages/daemon/src/ws/protocol.ts | 151 +++ packages/daemon/test/anti-corruption.test.ts | 34 + packages/daemon/test/approval.e2e.test.ts | 411 ++++++++ packages/daemon/test/dispose-order.test.ts | 420 ++++++++ packages/daemon/test/error-handler.test.ts | 158 +++ packages/daemon/test/files.e2e.test.ts | 319 ++++++ packages/daemon/test/fs-basic.e2e.test.ts | 349 ++++++ packages/daemon/test/fs-batch.e2e.test.ts | 307 ++++++ packages/daemon/test/fs-download.e2e.test.ts | 328 ++++++ packages/daemon/test/fs-git.e2e.test.ts | 354 +++++++ packages/daemon/test/fs-path-safety.test.ts | 127 +++ packages/daemon/test/fs-search.e2e.test.ts | 536 ++++++++++ packages/daemon/test/fs-watch.e2e.test.ts | 489 +++++++++ packages/daemon/test/lock.test.ts | 155 +++ packages/daemon/test/messages.e2e.test.ts | 240 +++++ packages/daemon/test/meta.e2e.test.ts | 202 ++++ packages/daemon/test/prompt.e2e.test.ts | 325 ++++++ packages/daemon/test/question.e2e.test.ts | 517 +++++++++ packages/daemon/test/services.test.ts | 477 +++++++++ packages/daemon/test/sessions.e2e.test.ts | 280 +++++ packages/daemon/test/start.test.ts | 137 +++ packages/daemon/test/tasks.e2e.test.ts | 283 +++++ packages/daemon/test/tools.e2e.test.ts | 223 ++++ packages/daemon/test/ws-abort.e2e.test.ts | 370 +++++++ packages/daemon/test/ws-broadcast.e2e.test.ts | 304 ++++++ packages/daemon/test/ws-handshake.e2e.test.ts | 238 +++++ packages/daemon/test/ws-resync.e2e.test.ts | 343 ++++++ packages/daemon/tsconfig.json | 7 + packages/daemon/tsdown.config.ts | 9 + packages/daemon/vitest.config.ts | 40 + packages/services/package.json | 41 + .../services/src/adapter/approval-adapter.ts | 100 ++ .../services/src/adapter/question-adapter.ts | 207 ++++ packages/services/src/adapter/task-adapter.ts | 107 ++ packages/services/src/adapter/tool-adapter.ts | 147 +++ .../services/src/bridge/bridge-client-api.ts | 72 ++ .../services/src/bridge/harness-bridge.ts | 195 ++++ packages/services/src/bridge/lifecycle.ts | 16 + .../services/src/impls/mcp-service-impl.ts | 89 ++ .../src/impls/message-service-impl.ts | 349 ++++++ .../services/src/impls/prompt-service-impl.ts | 365 +++++++ .../src/impls/session-service-impl.ts | 288 +++++ .../services/src/impls/task-service-impl.ts | 114 ++ .../services/src/impls/tool-service-impl.ts | 67 ++ packages/services/src/index.ts | 66 ++ .../src/interfaces/approval-broker.ts | 46 + packages/services/src/interfaces/event-bus.ts | 26 + packages/services/src/interfaces/index.ts | 37 + .../services/src/interfaces/mcp-service.ts | 60 ++ .../src/interfaces/message-service.ts | 76 ++ .../services/src/interfaces/prompt-service.ts | 135 +++ .../src/interfaces/question-broker.ts | 53 + .../src/interfaces/session-service.ts | 97 ++ .../services/src/interfaces/task-service.ts | 85 ++ .../services/src/interfaces/tool-service.ts | 34 + packages/services/src/module.ts | 86 ++ .../services/test/approval-adapter.test.ts | 102 ++ packages/services/test/bridge.test.ts | 253 +++++ packages/services/test/interfaces.test.ts | 210 ++++ .../services/test/message-service.test.ts | 327 ++++++ packages/services/test/prompt-service.test.ts | 358 +++++++ .../services/test/question-adapter.test.ts | 194 ++++ .../services/test/session-service.test.ts | 362 +++++++ packages/services/test/task-service.test.ts | 246 +++++ packages/services/test/tool-service.test.ts | 243 +++++ packages/services/tsconfig.json | 8 + packages/services/tsdown.config.ts | 26 + packages/services/vitest.config.ts | 29 + pnpm-lock.yaml | 445 +++++++- 137 files changed, 27167 insertions(+), 4 deletions(-) create mode 100644 apps/kimi-code/src/cli/sub/daemon.ts create mode 100644 packages/agent-core/src/di/README.md create mode 100644 packages/agent-core/src/di/descriptors.ts create mode 100644 packages/agent-core/src/di/errors.ts create mode 100644 packages/agent-core/src/di/extensions.ts create mode 100644 packages/agent-core/src/di/graph.ts create mode 100644 packages/agent-core/src/di/index.ts create mode 100644 packages/agent-core/src/di/instantiation.ts create mode 100644 packages/agent-core/src/di/instantiationService.ts create mode 100644 packages/agent-core/src/di/lifecycle.ts create mode 100644 packages/agent-core/src/di/serviceCollection.ts create mode 100644 packages/agent-core/src/di/test.ts create mode 100644 packages/agent-core/src/di/testInstantiationService.ts create mode 100644 packages/agent-core/src/di/util/idleValue.ts create mode 100644 packages/agent-core/src/di/util/linkedList.ts create mode 100644 packages/agent-core/test/di/auto-inject.test.ts create mode 100644 packages/agent-core/test/di/child.test.ts create mode 100644 packages/agent-core/test/di/collection.test.ts create mode 100644 packages/agent-core/test/di/cyclic.test.ts create mode 100644 packages/agent-core/test/di/decorator.test.ts create mode 100644 packages/agent-core/test/di/delayed.test.ts create mode 100644 packages/agent-core/test/di/descriptor.test.ts create mode 100644 packages/agent-core/test/di/extensions.test.ts create mode 100644 packages/agent-core/test/di/graph.test.ts create mode 100644 packages/agent-core/test/di/instantiation.test.ts create mode 100644 packages/agent-core/test/di/self-register.test.ts create mode 100644 packages/agent-core/test/di/test-instantiation.test.ts create mode 100644 packages/agent-core/test/di/trace.test.ts create mode 100644 packages/daemon/package.json create mode 100644 packages/daemon/src/envelope.ts create mode 100644 packages/daemon/src/error-handler.ts create mode 100644 packages/daemon/src/index.ts create mode 100644 packages/daemon/src/lock.ts create mode 100644 packages/daemon/src/logger.ts create mode 100644 packages/daemon/src/middleware/validate.ts create mode 100644 packages/daemon/src/request-id.ts create mode 100644 packages/daemon/src/routes/action-suffix.ts create mode 100644 packages/daemon/src/routes/approvals.ts create mode 100644 packages/daemon/src/routes/files.ts create mode 100644 packages/daemon/src/routes/fs.ts create mode 100644 packages/daemon/src/routes/messages.ts create mode 100644 packages/daemon/src/routes/meta.ts create mode 100644 packages/daemon/src/routes/prompts.ts create mode 100644 packages/daemon/src/routes/questions.ts create mode 100644 packages/daemon/src/routes/sessions.ts create mode 100644 packages/daemon/src/routes/tasks.ts create mode 100644 packages/daemon/src/routes/tools.ts create mode 100644 packages/daemon/src/services/approval-broker.ts create mode 100644 packages/daemon/src/services/connection-registry.ts create mode 100644 packages/daemon/src/services/event-bus.ts create mode 100644 packages/daemon/src/services/file-store.ts create mode 100644 packages/daemon/src/services/fs-git.ts create mode 100644 packages/daemon/src/services/fs-path-safety.ts create mode 100644 packages/daemon/src/services/fs-search.ts create mode 100644 packages/daemon/src/services/fs-service.ts create mode 100644 packages/daemon/src/services/fs-watcher.ts create mode 100644 packages/daemon/src/services/logger.ts create mode 100644 packages/daemon/src/services/question-broker.ts create mode 100644 packages/daemon/src/services/rest-gateway.ts create mode 100644 packages/daemon/src/services/session-clients.ts create mode 100644 packages/daemon/src/services/ws-gateway.ts create mode 100644 packages/daemon/src/start.ts create mode 100644 packages/daemon/src/version.ts create mode 100644 packages/daemon/src/ws/connection.ts create mode 100644 packages/daemon/src/ws/protocol.ts create mode 100644 packages/daemon/test/anti-corruption.test.ts create mode 100644 packages/daemon/test/approval.e2e.test.ts create mode 100644 packages/daemon/test/dispose-order.test.ts create mode 100644 packages/daemon/test/error-handler.test.ts create mode 100644 packages/daemon/test/files.e2e.test.ts create mode 100644 packages/daemon/test/fs-basic.e2e.test.ts create mode 100644 packages/daemon/test/fs-batch.e2e.test.ts create mode 100644 packages/daemon/test/fs-download.e2e.test.ts create mode 100644 packages/daemon/test/fs-git.e2e.test.ts create mode 100644 packages/daemon/test/fs-path-safety.test.ts create mode 100644 packages/daemon/test/fs-search.e2e.test.ts create mode 100644 packages/daemon/test/fs-watch.e2e.test.ts create mode 100644 packages/daemon/test/lock.test.ts create mode 100644 packages/daemon/test/messages.e2e.test.ts create mode 100644 packages/daemon/test/meta.e2e.test.ts create mode 100644 packages/daemon/test/prompt.e2e.test.ts create mode 100644 packages/daemon/test/question.e2e.test.ts create mode 100644 packages/daemon/test/services.test.ts create mode 100644 packages/daemon/test/sessions.e2e.test.ts create mode 100644 packages/daemon/test/start.test.ts create mode 100644 packages/daemon/test/tasks.e2e.test.ts create mode 100644 packages/daemon/test/tools.e2e.test.ts create mode 100644 packages/daemon/test/ws-abort.e2e.test.ts create mode 100644 packages/daemon/test/ws-broadcast.e2e.test.ts create mode 100644 packages/daemon/test/ws-handshake.e2e.test.ts create mode 100644 packages/daemon/test/ws-resync.e2e.test.ts create mode 100644 packages/daemon/tsconfig.json create mode 100644 packages/daemon/tsdown.config.ts create mode 100644 packages/daemon/vitest.config.ts create mode 100644 packages/services/package.json create mode 100644 packages/services/src/adapter/approval-adapter.ts create mode 100644 packages/services/src/adapter/question-adapter.ts create mode 100644 packages/services/src/adapter/task-adapter.ts create mode 100644 packages/services/src/adapter/tool-adapter.ts create mode 100644 packages/services/src/bridge/bridge-client-api.ts create mode 100644 packages/services/src/bridge/harness-bridge.ts create mode 100644 packages/services/src/bridge/lifecycle.ts create mode 100644 packages/services/src/impls/mcp-service-impl.ts create mode 100644 packages/services/src/impls/message-service-impl.ts create mode 100644 packages/services/src/impls/prompt-service-impl.ts create mode 100644 packages/services/src/impls/session-service-impl.ts create mode 100644 packages/services/src/impls/task-service-impl.ts create mode 100644 packages/services/src/impls/tool-service-impl.ts create mode 100644 packages/services/src/index.ts create mode 100644 packages/services/src/interfaces/approval-broker.ts create mode 100644 packages/services/src/interfaces/event-bus.ts create mode 100644 packages/services/src/interfaces/index.ts create mode 100644 packages/services/src/interfaces/mcp-service.ts create mode 100644 packages/services/src/interfaces/message-service.ts create mode 100644 packages/services/src/interfaces/prompt-service.ts create mode 100644 packages/services/src/interfaces/question-broker.ts create mode 100644 packages/services/src/interfaces/session-service.ts create mode 100644 packages/services/src/interfaces/task-service.ts create mode 100644 packages/services/src/interfaces/tool-service.ts create mode 100644 packages/services/src/module.ts create mode 100644 packages/services/test/approval-adapter.test.ts create mode 100644 packages/services/test/bridge.test.ts create mode 100644 packages/services/test/interfaces.test.ts create mode 100644 packages/services/test/message-service.test.ts create mode 100644 packages/services/test/prompt-service.test.ts create mode 100644 packages/services/test/question-adapter.test.ts create mode 100644 packages/services/test/session-service.test.ts create mode 100644 packages/services/test/task-service.test.ts create mode 100644 packages/services/test/tool-service.test.ts create mode 100644 packages/services/tsconfig.json create mode 100644 packages/services/tsdown.config.ts create mode 100644 packages/services/vitest.config.ts diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e0ea455a2..e1d692a46 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -81,6 +81,7 @@ "@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", + "@moonshot-ai/daemon": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", "@types/semver": "^7.7.0", diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index faf1e1da8..d01108487 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -5,6 +5,7 @@ import { Command, Option } from 'commander'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; import { registerDoctorCommand } from './sub/doctor'; +import { registerDaemonCommand } from './sub/daemon'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; @@ -78,6 +79,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerDaemonCommand(program); registerLoginCommand(program); registerDoctorCommand(program); registerMigrateCommand(program, onMigrate); diff --git a/apps/kimi-code/src/cli/sub/daemon.ts b/apps/kimi-code/src/cli/sub/daemon.ts new file mode 100644 index 000000000..169548b8b --- /dev/null +++ b/apps/kimi-code/src/cli/sub/daemon.ts @@ -0,0 +1,86 @@ +/** + * `kimi daemon` sub-command. + * + * Boots the local REST + WebSocket daemon. Walking-skeleton scope: only + * `GET /v1/healthz`. SDK / WS / DI wiring lives in later commits. + */ + +import type { Command } from 'commander'; + +import { startDaemon, type DaemonLogLevel } from '@moonshot-ai/daemon'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 7878; +const DEFAULT_LOG_LEVEL: DaemonLogLevel = 'info'; +const VALID_LOG_LEVELS: readonly DaemonLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +interface DaemonCliOptions { + host?: string; + port?: string; + logLevel?: string; +} + +export function registerDaemonCommand(parent: Command): void { + parent + .command('daemon') + .description('Run the local kimi-code daemon (REST + WebSocket).') + .option('--host ', `Bind host (default ${DEFAULT_HOST})`, DEFAULT_HOST) + .option('--port ', `Bind port (default ${DEFAULT_PORT})`, String(DEFAULT_PORT)) + .option( + '--log-level ', + `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, + DEFAULT_LOG_LEVEL, + ) + .action(async (opts: DaemonCliOptions) => { + const host = opts.host ?? DEFAULT_HOST; + const port = parsePort(opts.port); + const logLevel = parseLogLevel(opts.logLevel); + + const running = await startDaemon({ host, port, logLevel }); + + const shutdown = async (signal: NodeJS.Signals): Promise => { + running.logger.info({ signal }, 'daemon shutting down'); + try { + await running.close(); + process.exit(0); + } catch (error) { + running.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'daemon shutdown error', + ); + process.exit(1); + } + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); +} + +function parsePort(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_PORT; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + process.stderr.write(`error: invalid --port value: ${raw}\n`); + process.exit(1); + } + return n; +} + +function parseLogLevel(raw: string | undefined): DaemonLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as DaemonLogLevel; + } + process.stderr.write( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})\n`, + ); + process.exit(1); +} diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index ccec3267e..f844c9dea 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -41,6 +41,10 @@ "types": "./src/agent/records/migration/index.ts", "default": "./src/agent/records/migration/index.ts" }, + "./di/test": { + "types": "./src/di/test.ts", + "default": "./src/di/test.ts" + }, "./session/store": { "types": "./src/session/store/index.ts", "default": "./src/session/store/index.ts" diff --git a/packages/agent-core/src/di/README.md b/packages/agent-core/src/di/README.md new file mode 100644 index 000000000..80f66c7b0 --- /dev/null +++ b/packages/agent-core/src/di/README.md @@ -0,0 +1,319 @@ +# `@moonshot-ai/agent-core` Dependency Injection Container + +A VSCode-style DI container for the agent-core / daemon stack. Provides: + +- **Service identifiers** — branded callable values minted by `createDecorator`, + singleton-per-name. +- **Registration** — `ServiceCollection` (per-container) and `registerSingleton` + (module-global registry). +- **Resolution** — `InstantiationService` with singleton-per-container + semantics, scoped child containers (`createChild`), idempotent + `dispose()`, Graph-based cycle detection, and `@IFoo`-style + constructor-parameter auto-injection. +- **Delayed instantiation** — services flagged + `supportsDelayedInstantiation: true` materialise lazily behind a `Proxy`. +- **Testing** — `TestInstantiationService` (subpath + `@moonshot-ai/agent-core/di/test`) exposes direct `.get` / `.stub` so + test bodies don't have to thread an `invokeFunction` accessor. + +The design intentionally mirrors VSCode's `vs/platform/instantiation` API so +the conceptual model carries over. + +## Why this and not a DI library? + +Two reasons: + +1. **Zero runtime dependencies** — the container is ~600 LoC of plain + TypeScript; pulling in `tsyringe` / `inversify` would dwarf the entire + subsystem. +2. **Familiar shape** — most kimi-code contributors have seen VSCode's + service pattern. Same identifier-as-decorator trick, same `accessor.get` + call site idiom, same `createChild` scope story. + +## Core concepts + +| Term | Lives in | Role | +| ---------------------------- | ------------------------- | ------------------------------------------------------------------- | +| `ServiceIdentifier` | `createDecorator()` | Branded callable + parameter decorator; serves as the `Map` key. | +| `SyncDescriptor` | `descriptors.ts` | Wraps a ctor + static args + `supportsDelayedInstantiation` flag. | +| `ServiceCollection` | `serviceCollection.ts` | Per-container map of id → (descriptor \| instance). | +| `InstantiationService` | `instantiationService.ts` | Runtime container: resolves, caches, scopes, traces, disposes. | +| `IDisposable` / `Disposable` | `lifecycle.ts` | Teardown contract (services with a `dispose` method get called). | +| `registerSingleton` | `extensions.ts` | Module-global registry consulted at bootstrap time. | +| `Graph` | `graph.ts` | Dependency subtree used for cycle detection + leaves-first build. | +| `TestInstantiationService` | `testInstantiationService.ts` | Subpath-only test container with `.stub` / `.get` / `.set`. | + +## Complete bootstrap example + +```ts +import { + createDecorator, + registerSingleton, + getSingletonServiceDescriptors, + InstantiationService, + ServiceCollection, + SyncDescriptor, +} from '@moonshot-ai/agent-core'; + +// 1. Declare a service interface and identifier. +interface ILogger { + readonly _serviceBrand: undefined; // brand required by the container + log(message: string): void; +} +const ILogger = createDecorator('logger'); + +// 2. Implement it. +class ConsoleLogger implements ILogger { + declare readonly _serviceBrand: undefined; + log(message: string): void { + // eslint-disable-next-line no-console + console.log(`[log] ${message}`); + } +} + +// 3. Register at module load time. +registerSingleton(ILogger, ConsoleLogger); + +// 4. At bootstrap, build the root container from the registry. +const services = new ServiceCollection( + ...getSingletonServiceDescriptors().map( + ([id, descriptor]) => [id, descriptor] as const, + ), +); +const ix = new InstantiationService(services); + +// 5. Use it via invokeFunction. +ix.invokeFunction((accessor) => { + const logger = accessor.get(ILogger); + logger.log('hello world'); +}); + +// 6. Teardown — disposes children transitively, in reverse construction order. +ix.dispose(); +``` + +## Service composition (`@IFoo` constructor-arg injection) + +Any constructor parameter decorated with a `ServiceIdentifier` is auto- +resolved from the container at construction time. Static (non-decorated) +parameters come first; service parameters last. `createInstance(Ctor, …)` +infers the leading non-service prefix via `GetLeadingNonServiceArgs` so +callers only pass the static portion. + +```ts +interface IClock { + readonly _serviceBrand: undefined; + now(): number; +} +const IClock = createDecorator('clock'); + +class SystemClock implements IClock { + declare readonly _serviceBrand: undefined; + now(): number { return Date.now(); } +} + +// Foo declares a static `prefix: string` and TWO service dependencies. +// The container injects `logger` + `clock` automatically. +class Foo { + constructor( + public readonly prefix: string, + @ILogger private readonly _logger: ILogger, + @IClock private readonly _clock: IClock, + ) {} + + ping(): void { + this._logger.log(`${this.prefix} @ ${this._clock.now()}`); + } +} +const IFoo = createDecorator('foo'); + +const ix = new InstantiationService(new ServiceCollection( + [ILogger, new SyncDescriptor(ConsoleLogger)], + [IClock, new SyncDescriptor(SystemClock)], + [IFoo, new SyncDescriptor(Foo, ['hello'])], // static prefix is the only arg here +)); + +// Via the container's registry (IFoo registered above): +ix.invokeFunction((a) => a.get(IFoo).ping()); + +// OR via `createInstance` directly — the static prefix is the only argument +// the caller has to provide; `@ILogger` and `@IClock` are auto-injected. +const direct = ix.createInstance(Foo, 'direct'); +direct.ping(); +``` + +### Factory pattern via `@IInstantiationService` + +`IInstantiationService` is itself a registered identifier. Decorating a +ctor param with `@IInstantiationService` receives the OWNING container — +useful for per-request scope factories: + +```ts +class WidgetFactory { + constructor(@IInstantiationService private readonly _ix: IInstantiationService) {} + build(label: string): Widget { + return this._ix.createInstance(Widget, label); + } +} +``` + +A child container resolving `@IInstantiationService` receives the CHILD, +not the parent — so a factory built inside a child sees the scoped +container. + +## Delayed instantiation + +`new SyncDescriptor(Foo, [], true)` (third ctor arg flips +`supportsDelayedInstantiation` on). On first `accessor.get(IFoo)` the +container returns a `Proxy` that does NOT construct `Foo` yet. The real +ctor runs on the FIRST non-event property access: + +```ts +const ix = new InstantiationService(new ServiceCollection( + [IFoo, new SyncDescriptor(Foo, [], /* supportsDelayedInstantiation */ true)], +)); +const proxy = ix.invokeFunction((a) => a.get(IFoo)); +// Foo's ctor has NOT run yet. +proxy.ping(); +// Foo's ctor ran exactly once; subsequent calls hit the cached real instance. +``` + +### `onDid*` / `onWill*` early-listener contract + +Subscribing to an `onDidChange` (or `onWill…`) event on the proxy BEFORE +the real instance materialises parks the listener internally. When the +real instance is constructed, every parked listener is rebound against +the real event — so events fired post-materialisation are delivered as +if the subscription had happened against the real instance directly. + +```ts +const proxy = ix.invokeFunction((a) => a.get(IFoo)); +const sub = proxy.onDidChange((payload) => console.log('got', payload)); +proxy.someMethod(); // triggers real construction +// Subscription was parked then replayed; future `fire(...)` calls go to the listener. +``` + +Use `instanceof Foo` against the proxy — `getPrototypeOf` is trapped so +the proxy's prototype IS `Foo.prototype`. + +## Cycle detection + +`InstantiationService` runs two complementary checks: + +1. **Graph walk (primary)** — `_createAndCacheServiceInstance` builds the + full `@IFoo` dependency subtree via `_util.getServiceDependencies`, + then constructs leaves-first by repeatedly consuming `graph.roots()`. + If the graph becomes stuck (`roots()` empty over a non-empty graph), + `CyclicDependencyError` is thrown with `findCycleSlow()` formatting + the cycle path (`A -> B -> A`). Cycles are caught **before any ctor + body runs**. +2. **Tree-wide construction stack (defensive)** — `_inProgress` lives on + the root container and catches cycles expressed via ctor BODY re-entry + (`A.ctor` calling `accessor.get(IB)` whose `B.ctor` calls back to + `accessor.get(IA)`). The Graph walk can't predict these edges because + they aren't expressed via `@IFoo` metadata. + +```ts +try { + ix.invokeFunction((a) => a.get(IA)); +} catch (e) { + if (e instanceof CyclicDependencyError) { + console.error(e.message); // 'cyclic dependency between services: A -> B -> A' + console.error(e.path); // ['A', 'B', 'A'] + } +} +``` + +Cycles that cross parent/child boundaries are caught because both checks +operate at the root container. + +## Lifecycle + +- `dispose()` is idempotent. +- Children are torn down first (depth-first); then the container disposes + its own cached instances in **reverse construction order** (LIFO). +- After the LIFO pass, any Proxy-materialised real instances that didn't + appear in `_constructionOrder` (i.e. the lazy path) get a second-pass + dispose via `_servicesToMaybeDispose`. +- Only instances with a `dispose()` method are called (duck-typed); pure + data services need do nothing. Disposing a Proxy never forces + materialisation — the lazy path skips `_constructionOrder` for exactly + this reason. +- `Disposable` base class is provided for the common "I own a stack of + sub-disposables" case — `this._register(child)` returns the child and + guarantees LIFO teardown. + +## Testing + +Test files import from the subpath `@moonshot-ai/agent-core/di/test` +(NOT the main package entry — keeps production bundles clean): + +```ts +import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'; + +const ix = new TestInstantiationService(); +ix.stub(ILogger, { log: vi.fn() } as ILogger); +const target = ix.createInstance(SomeClass, 'static-arg'); +expect((ix.get(ILogger) as { log: vi.Mock }).log).toHaveBeenCalled(); +``` + +`TestInstantiationService` extends `InstantiationService` and adds: + +- `.get(id)` — resolve without an accessor closure. +- `.set(id, x)` — register or replace an instance / descriptor; returns + the previous binding so fixtures can save-and-restore. +- `.stub(id, x)` — semantic alias for `.set` (intent: "replace real with + mock"). +- `.createChild(services)` — narrowed to return another + `TestInstantiationService` so chained `.stub` / `.get` stays ergonomic. + +## File layout + +``` +packages/agent-core/src/di/ +├── README.md ← you are here +├── index.ts ← public barrel (main package entry) +├── test.ts ← subpath barrel (`@moonshot-ai/agent-core/di/test`) +├── instantiation.ts ← createDecorator + IInstantiationService interface + _util +├── descriptors.ts ← SyncDescriptor + SyncDescriptor0 + InstantiationType enum +├── serviceCollection.ts ← ServiceCollection +├── instantiationService.ts ← runtime container (resolution + Graph cycle + Proxy) +├── testInstantiationService.ts ← TestInstantiationService (subpath-only) +├── lifecycle.ts ← IDisposable + Disposable base class +├── errors.ts ← CyclicDependencyError (path-form + Graph-form) +├── extensions.ts ← registerSingleton + getSingletonServiceDescriptors +├── graph.ts ← Graph for dependency-subtree walks +└── util/ + ├── idleValue.ts ← GlobalIdleValue (deferred lazy executor) + └── linkedList.ts ← LinkedList for parked event listeners +``` + +Tests live under `packages/agent-core/test/di/`. + +## Migration from prior version (pre-P0 → post-P1) + +If you wrote against an earlier internal cut of this container, the +following surface changes need attention: + +1. **`$serviceMarker` → `_serviceBrand`** — service interfaces must + declare `readonly _serviceBrand: undefined;` (krow/VSCode parity). + Implementations stamp `declare readonly _serviceBrand: undefined;`. +2. **`createDecorator` singleton-per-name** — two calls with the same + name return the SAME identifier reference. Previously every call + minted a fresh callable. Use distinct names per service. +3. **`IInstantiationService` is both type AND value** — the same + exported binding works as a TypeScript interface (`: IInstantiationService`) + and a ServiceIdentifier value (`a.get(IInstantiationService)`). +4. **`id.serviceName` removed** — use `id.toString()` for diagnostic + names; structured access via the singleton-per-name `_util.serviceIds` + map is internal-only. +5. **`createInstance(ctor, ...rest)` auto-injects** — trailing `@IFoo`- + decorated parameters are resolved from the container. Callers that + previously wrote `ix.createInstance(Impl, a.get(IDep), …)` can drop + the `a.get(...)` calls once `Impl` declares the decorator. +6. **`new SyncDescriptor(C, [], true)` is now LAZY** — third ctor arg + flips the Proxy path on. Existing call sites with `false` (or + omitted) are unchanged. +7. **`TestInstantiationService` moved to a subpath** — + `import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'` + (NOT from the main entry). diff --git a/packages/agent-core/src/di/descriptors.ts b/packages/agent-core/src/di/descriptors.ts new file mode 100644 index 000000000..86127f0be --- /dev/null +++ b/packages/agent-core/src/di/descriptors.ts @@ -0,0 +1,40 @@ +/** + * Service descriptors: a `SyncDescriptor` packages a constructor + static + * args for later instantiation by the container. Modelled after VSCode's + * `SyncDescriptor`. + */ + +/** How a service is instantiated. Delayed support lands in a later phase. */ +export enum InstantiationType { + /** Construct immediately on first `get`. */ + Eager = 0, + /** Construct lazily via a Proxy when a method is actually called. */ + Delayed = 1, +} + +/** + * Wraps a constructor plus optional static arguments. The container picks up + * a `SyncDescriptor` from the `ServiceCollection` (rather than an already- + * built instance) and constructs it on first `get`. + */ +export class SyncDescriptor { + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly ctor: new (...args: any[]) => T, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly staticArguments: ReadonlyArray = [], + public readonly supportsDelayedInstantiation: boolean = false, + ) {} +} + +/** + * `SyncDescriptor0` is the no-static-args specialisation used by the + * `createInstance(descriptor)` overload at the type level so a zero-arg ctor + * descriptor type-checks with no extra rest args. Mirrors krow + * `descriptors.ts:13-17`. + */ +export class SyncDescriptor0 extends SyncDescriptor { + constructor(ctor: new () => T) { + super(ctor, []); + } +} diff --git a/packages/agent-core/src/di/errors.ts b/packages/agent-core/src/di/errors.ts new file mode 100644 index 000000000..16e568e04 --- /dev/null +++ b/packages/agent-core/src/di/errors.ts @@ -0,0 +1,54 @@ +/** + * Errors raised by the DI subsystem. + */ + +import type { Graph } from './graph'; + +/** + * Thrown when the container detects a cycle in the dependency graph. + * + * Two construction forms are supported: + * + * 1. **Legacy `path: string[]` form** — used by the linear `_inProgress` + * tree-stack check inside `_getOrCreateInstance`. This was the only form + * in P0; it is retained because that defensive layer is preserved per + * PLAN D3 (it catches same-id reentrancy from a ctor body even before + * the Graph traversal would discover it). The path is the construction + * stack at the moment the cycle was detected, in construction order + * (root → ... → repeated-id). The repeated id appears at both ends so + * the cycle is visually obvious. + * + * 2. **`Graph` form** — used by the Graph-based + * `_createAndCacheServiceInstance` introduced in P1.1. The path is + * computed lazily via `graph.findCycleSlow()` when the message is built. + * If the cycle finder returns `undefined` we fall back to dumping the + * entire graph so the failure is still diagnosable. + * + * Both forms expose `path: ReadonlyArray` so existing call sites + * (and tests) keep working. For the Graph form the `path` array is + * `[graph.findCycleSlow()]` split on `' -> '` so the same structural data + * is available; this avoids forcing callers to branch on which form built + * the error. + */ +export class CyclicDependencyError extends Error { + readonly path: ReadonlyArray; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(pathOrGraph: ReadonlyArray | Graph) { + if (Array.isArray(pathOrGraph)) { + const path = pathOrGraph as ReadonlyArray; + super(`Cyclic DI dependency detected: ${path.join(' → ')}`); + this.path = path; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const graph = pathOrGraph as Graph; + const cycle = graph.findCycleSlow(); + const detail = cycle ?? `UNABLE to detect cycle, dumping graph:\n${graph.toString()}`; + super(`cyclic dependency between services: ${detail}`); + // Provide a structured path for callers that read `.path` directly. + // `findCycleSlow` formats as `A -> B -> A`; split it back into segments. + this.path = cycle ? cycle.split(' -> ') : []; + } + this.name = 'CyclicDependencyError'; + } +} diff --git a/packages/agent-core/src/di/extensions.ts b/packages/agent-core/src/di/extensions.ts new file mode 100644 index 000000000..45da598e3 --- /dev/null +++ b/packages/agent-core/src/di/extensions.ts @@ -0,0 +1,68 @@ +/** + * Module-global service registry. Modules (or top-level files) register their + * service implementations at import-time via `registerSingleton`; the daemon + * bootstrap then seeds the root `ServiceCollection` from + * `getSingletonServiceDescriptors()`. + * + * Modelled after VSCode's `extensions.ts` — same shape, same intent. + */ + +import { InstantiationType, SyncDescriptor } from './descriptors'; +import type { ServiceIdentifier } from './instantiation'; + +interface RegistryEntry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + descriptor: SyncDescriptor; + instantiationType: InstantiationType; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _registry = new Map, RegistryEntry>(); + +/** + * Register a service implementation under its identifier. Typically called + * at module top-level. Re-registering the same id throws — a deliberate + * choice so module load order accidents fail loud, not silent. + */ +export function registerSingleton( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctor: new (...args: any[]) => T, + instantiationType: InstantiationType = InstantiationType.Eager, +): void { + if (_registry.has(id)) { + throw new Error(`Service '${String(id)}' is already registered`); + } + _registry.set(id, { + descriptor: new SyncDescriptor(ctor), + instantiationType, + }); +} + +/** + * Snapshot the registry as a list suitable for `ServiceCollection` + * construction. + * + * Shape: `[id, descriptor, instantiationType][]`. The bootstrap layer is + * expected to project this into `[id, descriptor]` tuples for + * `ServiceCollection` and stash the `instantiationType` on the descriptor if + * delayed-instantiation support is wired in later. + */ +export function getSingletonServiceDescriptors(): ReadonlyArray< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [ServiceIdentifier, SyncDescriptor, InstantiationType] +> { + return Array.from( + _registry, + ([id, { descriptor, instantiationType }]) => [id, descriptor, instantiationType] as const, + ); +} + +/** + * Test-only escape hatch: empty the registry. Real code must never call this + * — module-load registrations are intended to be permanent for the lifetime + * of the process. + */ +export function _clearRegistryForTests(): void { + _registry.clear(); +} diff --git a/packages/agent-core/src/di/graph.ts b/packages/agent-core/src/di/graph.ts new file mode 100644 index 000000000..e137209d7 --- /dev/null +++ b/packages/agent-core/src/di/graph.ts @@ -0,0 +1,89 @@ +export class Node { + readonly incoming = new Map>(); + readonly outgoing = new Map>(); + + constructor( + readonly key: string, + readonly data: T + ) { } +} + +export class Graph { + private readonly _nodes = new Map>(); + + constructor(private readonly _hashFn: (element: T) => string) { } + + roots(): Node[] { + const ret: Node[] = []; + for (const node of this._nodes.values()) { + if (node.outgoing.size === 0) { + ret.push(node); + } + } + return ret; + } + + insertEdge(from: T, to: T): void { + const fromNode = this.lookupOrInsertNode(from); + const toNode = this.lookupOrInsertNode(to); + fromNode.outgoing.set(toNode.key, toNode); + toNode.incoming.set(fromNode.key, fromNode); + } + + removeNode(data: T): void { + const key = this._hashFn(data); + this._nodes.delete(key); + for (const node of this._nodes.values()) { + node.outgoing.delete(key); + node.incoming.delete(key); + } + } + + lookupOrInsertNode(data: T): Node { + const key = this._hashFn(data); + let node = this._nodes.get(key); + if (!node) { + node = new Node(key, data); + this._nodes.set(key, node); + } + return node; + } + + isEmpty(): boolean { + return this._nodes.size === 0; + } + + toString(): string { + const data: string[] = []; + for (const [key, value] of this._nodes) { + data.push(`${key}\n\t(-> incoming)[${[...value.incoming.keys()].join(', ')}]\n\t(outgoing ->)[${[...value.outgoing.keys()].join(',')}]\n`); + } + return data.join('\n'); + } + + findCycleSlow() { + for (const [id, node] of this._nodes) { + const seen = new Set([id]); + const res = this._findCycle(node, seen); + if (res) { + return res; + } + } + return undefined; + } + + private _findCycle(node: Node, seen: Set): string | undefined { + for (const [id, outgoing] of node.outgoing) { + if (seen.has(id)) { + return [...seen, id].join(' -> '); + } + seen.add(id); + const value = this._findCycle(outgoing, seen); + if (value) { + return value; + } + seen.delete(id); + } + return undefined; + } +} diff --git a/packages/agent-core/src/di/index.ts b/packages/agent-core/src/di/index.ts new file mode 100644 index 000000000..7b311eb48 --- /dev/null +++ b/packages/agent-core/src/di/index.ts @@ -0,0 +1,36 @@ +/** + * Barrel for `@moonshot-ai/agent-core` DI subsystem. This file is the only + * surface that should be imported from outside the `di/` directory. + * + * Modelled after VSCode's `vs/platform/instantiation`. See `./README.md` for + * usage (lands in W2.5). + */ + +export type { + ServiceIdentifier, + ServicesAccessor, + ServiceCollectionLike, + BrandedService, + GetLeadingNonServiceArgs, +} from './instantiation'; +export { + createDecorator, + refineServiceDecorator, + // Re-export `IInstantiationService` as a regular export — this single + // binding carries BOTH the interface (type position) and the + // ServiceIdentifier value (value position) declared under the same name + // in `./instantiation.ts`, so consumers can write either `: IInstantiationService` + // or `accessor.get(IInstantiationService)`. + IInstantiationService, +} from './instantiation'; +export { InstantiationType, SyncDescriptor, SyncDescriptor0 } from './descriptors'; +export { ServiceCollection } from './serviceCollection'; +export { InstantiationService } from './instantiationService'; +export { Disposable } from './lifecycle'; +export type { IDisposable } from './lifecycle'; +export { CyclicDependencyError } from './errors'; +export { + registerSingleton, + getSingletonServiceDescriptors, + _clearRegistryForTests, +} from './extensions'; diff --git a/packages/agent-core/src/di/instantiation.ts b/packages/agent-core/src/di/instantiation.ts new file mode 100644 index 000000000..fee141eb4 --- /dev/null +++ b/packages/agent-core/src/di/instantiation.ts @@ -0,0 +1,240 @@ +/** + * Core DI types: service identifiers (decorators), accessor, and the public + * `IInstantiationService` interface. Modelled after VSCode's + * `InstantiationService`. + * + * The container itself lives in `./instantiationService.ts`; this file only + * defines the brands and contracts so `serviceCollection.ts` can stay free of + * container code. + * + * P0.3 alignment with krow / VSCode: + * - `createDecorator(name)` is now singleton-per-name: calling it twice with + * the same `name` returns the same identifier. (Previously every call + * minted a fresh callable.) + * - Decorator body actually stashes `{ id, index }` on the ctor as + * `$di$dependencies` own-property metadata (instead of being a no-op). + * `InstantiationService._createInstance` does not yet consume this — that + * wiring lands in P1.1 — so the daemon's existing + * `ix.createInstance(Impl, a.get(IDepA), ...)` call sites remain + * bytewise unchanged. + * - `ServiceIdentifier` exposes `_serviceBrand` (krow naming) instead of + * the prior internal `$serviceMarker`. + * + * P0.4 alignment: + * - `BrandedService` + `GetLeadingNonServiceArgs` type tools added so the + * `createInstance(ctor, ...rest)` signature can trim trailing service + * parameters once `@IFoo` auto-injection lands in P1.1. + * - `IInstantiationService.createInstance` gains a `SyncDescriptor0` + * overload mirroring krow. + */ + +import type { SyncDescriptor0 } from './descriptors'; + +/** + * Internal metadata utilities shared with `instantiationService.ts`. Not + * re-exported from `./index.ts` — this is a private contract between the + * decorator factory and the container. + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace _util { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export const serviceIds = new Map>(); + export const DI_TARGET = '$di$target'; + export const DI_DEPENDENCIES = '$di$dependencies'; + + export function getServiceDependencies( + ctor: DI_TARGET_OBJ, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): { id: ServiceIdentifier; index: number }[] { + return ctor[DI_DEPENDENCIES] || []; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + export interface DI_TARGET_OBJ extends Function { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + [DI_TARGET]: Function; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [DI_DEPENDENCIES]: { id: ServiceIdentifier; index: number }[]; + } +} + +/** Branded service shape used by `GetLeadingNonServiceArgs` and friends. */ +export type BrandedService = { _serviceBrand: undefined }; + +/** + * Type-level slicer that retains only the leading non-`BrandedService` args + * of a constructor parameter list. Used by `createInstance(ctor, ...args)` + * so callers can omit any trailing `@IFoo`-decorated service parameters + * (those are auto-injected by the container in a later phase). Mirrors krow + * `instantiation.ts:32-35`. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type GetLeadingNonServiceArgs = + TArgs extends [] ? [] + : TArgs extends [...infer TFirst, BrandedService] ? GetLeadingNonServiceArgs + : TArgs; + +/** + * A branded identifier for a service. At the value level a `ServiceIdentifier` + * is callable so it can stand in as a TypeScript parameter decorator + * (`constructor(@ILogger logger: ILogger)`). The brand field is never read + * at runtime; it exists purely to keep the structural type unique per-id and + * to carry the human-readable name for diagnostics. + */ +export interface ServiceIdentifier { + // Parameter-decorator callable signature. Now consumed: + // `storeServiceDependency(id, target, index)` is called on application. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (target: any, key: string | symbol, index: number): void; + + /** Phantom marker so two decorators with different `T` are not assignable. */ + readonly _serviceBrand: { readonly _: T }; + + toString(): string; +} + +/** + * Append `{ id, index }` to the ctor's `$di$dependencies` own-property metadata + * array. If the ctor already has an own `$di$target` pointing at itself the + * metadata array is mine — push. Otherwise (likely inherited from a parent + * class via prototype lookup) reinitialise so subclasses don't accidentally + * mutate the parent's array. + */ +function storeServiceDependency( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + target: Function, + index: number, +): void { + const t = target as _util.DI_TARGET_OBJ; + if (t[_util.DI_TARGET] === target) { + t[_util.DI_DEPENDENCIES].push({ id, index }); + } else { + t[_util.DI_DEPENDENCIES] = [{ id, index }]; + t[_util.DI_TARGET] = target; + } +} + +/** + * Mints a service identifier. **Singleton per name** — calling + * `createDecorator('logger')` twice returns the same identifier. This is a + * deliberate behavior change to align with VSCode / krow; the previous + * implementation minted a fresh callable on every call. + */ +export function createDecorator(name: string): ServiceIdentifier { + const existing = _util.serviceIds.get(name); + if (existing) { + return existing as ServiceIdentifier; + } + + const id = function serviceDecorator( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + target: any, + _key: string | symbol, + index: number, + ): void { + if (arguments.length !== 3) { + throw new Error( + '@IServiceName-decorator can only be used to decorate a parameter', + ); + } + storeServiceDependency(id, target, index); + } as unknown as ServiceIdentifier; + + Object.defineProperty(id, 'toString', { + value: function toString(): string { + return name; + }, + enumerable: false, + writable: false, + configurable: false, + }); + + _util.serviceIds.set(name, id); + return id; +} + +/** + * Narrows a service identifier to a more specific subtype. Mirrors krow + * `instantiation.ts:82-84`. + */ +export function refineServiceDecorator( + serviceIdentifier: ServiceIdentifier, +): ServiceIdentifier { + return serviceIdentifier as ServiceIdentifier; +} + +/** + * The accessor handed to `invokeFunction(fn)` callbacks. The only way to + * resolve a service from outside the container is via this object — which + * makes it trivial to swap containers for testing. + */ +export interface ServicesAccessor { + get(id: ServiceIdentifier): T; +} + +/** + * The runtime container. See `./instantiationService.ts` for the + * implementation. + */ +export interface IInstantiationService { + readonly _serviceBrand: undefined; + + invokeFunction(fn: (accessor: ServicesAccessor) => R): R; + /** + * Construct a class via a `SyncDescriptor` packaging its ctor + static args. + * Mirrors the krow / VSCode `createInstance(descriptor)` overload — useful + * when callers want a single value to pass around (e.g. for registration). + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(descriptor: SyncDescriptor0): T; + /** + * Construct a class with positional arguments. `GetLeadingNonServiceArgs` + * trims any trailing `@IFoo`-decorated service parameters off the inferred + * signature so callers only have to supply the non-service prefix; the + * container auto-injects the service tail (auto-injection itself lands in + * P1.1 — this commit only widens the type). + */ + createInstance< + Ctor extends new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ) => unknown, + R extends InstanceType, + >( + ctor: Ctor, + ...args: GetLeadingNonServiceArgs> + ): R; + createChild(services: ServiceCollectionLike): IInstantiationService; + dispose(): void; +} + +/** + * Service identifier for the container itself. `IInstantiationService` is + * both a TypeScript interface and a runtime value (TS allows + * interface/value coexistence under the same name). With this in place any + * service ctor that adds `@IInstantiationService` to a parameter receives + * the live container — enabling factory and per-request-scope patterns. The + * container's own constructor stamps `this._services.set(IInstantiationService, this)` + * so child containers see their own slot, not the parent's. + */ +export const IInstantiationService: ServiceIdentifier = + createDecorator('IInstantiationService'); + +/** + * Structural alias to avoid a circular import with `./serviceCollection.ts`. + * Anything `ServiceCollection`-shaped (set/get/has/forEach) satisfies this. + */ +export interface ServiceCollectionLike { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(id: ServiceIdentifier, instanceOrDescriptor: any): unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(id: ServiceIdentifier): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(id: ServiceIdentifier): boolean; + forEach( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (id: ServiceIdentifier, value: any) => void, + ): void; +} diff --git a/packages/agent-core/src/di/instantiationService.ts b/packages/agent-core/src/di/instantiationService.ts new file mode 100644 index 000000000..17c16327e --- /dev/null +++ b/packages/agent-core/src/di/instantiationService.ts @@ -0,0 +1,821 @@ +/** + * Runtime container for the DI subsystem. See `./README.md` for usage. + * Modelled after VSCode's `InstantiationService`. + * + * History: + * - W2.2: basic single-level container. + * - W2.3: `createChild` scopes + `dispose` lifecycle. + * - W2.4: cyclic dependency detection across the parent chain + * (linear `_inProgress` tree-stack). + * - P0.2: `Trace` class + `_enableTracing` flag installed (not yet wired). + * - P0.5: `IInstantiationService` self-registers in every container. + * - P1.1: `_util.getServiceDependencies` is now consumed — `@IFoo`-decorated + * constructor parameters auto-inject from the container; Graph-based + * dependency-subtree resolution catches cycles that the linear + * `_inProgress` stack would miss (e.g. detected statically before + * any ctor body runs). Both defensive layers are preserved per + * PLAN D3: `_inProgress` still catches ctor-body re-entry where a + * ctor synchronously calls `accessor.get(self)`. LIFO dispose order + * via `_constructionOrder` is preserved per PLAN D8. + * - P1.2: `SyncDescriptor.supportsDelayedInstantiation === true` now returns + * a `Proxy` that defers real construction until the first non-event + * property access. `onDid*`/`onWill*` subscriptions made BEFORE + * materialisation are parked in a `LinkedList` and rebound to the + * real event when the proxy resolves. Proxy-materialised instances + * join `_servicesToMaybeDispose` so dispose() tears them down in + * addition to the eager `_constructionOrder` set. + */ + +import { SyncDescriptor } from './descriptors'; +import { CyclicDependencyError } from './errors'; +import { Graph } from './graph'; +import { + IInstantiationService as IInstantiationServiceDecorator, + _util, + type IInstantiationService, + type ServiceCollectionLike, + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import type { IDisposable } from './lifecycle'; +import { ServiceCollection } from './serviceCollection'; +import { GlobalIdleValue } from './util/idleValue'; +import { LinkedList } from './util/linkedList'; + +// #region -- tracing --- +// +// `Trace` is vendored verbatim from krow +// `packages/core/src/platform/instantiation/instantiationService.ts:7-83` +// (which in turn is the VSCode original). P1.1 wires the call sites: +// `invokeFunction` opens an Invocation trace; `createInstance` opens a +// Creation trace; `_safeCreateAndCacheServiceInstance` opens a Creation +// trace per-service; and `_getOrCreateServiceInstance` calls +// `_trace.branch(id, first)` to record dependency edges. The `_enableTracing` +// flag remains opt-in; when false, every call returns `Trace._None` which +// is a no-op sentinel — zero overhead in the default path. + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const enum TraceType { + None = 0, + Creation = 1, + Invocation = 2, + Branch = 3, +} + +export class Trace { + + static readonly all = new Set(); + + private static readonly _None = new class extends Trace { + constructor() { super(TraceType.None, null); } + override stop() { } + override branch() { return this; } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static traceInvocation(_enableTracing: boolean, fn: any): Trace { + return !_enableTracing ? Trace._None : new Trace(TraceType.Invocation, fn.name || new Error().stack!.split('\n').slice(3, 4).join('\n')); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static traceCreation(_enableTracing: boolean, ctor: any): Trace { + return !_enableTracing ? Trace._None : new Trace(TraceType.Creation, ctor.name); + } + + private static _totals: number = 0; + private readonly _start: number = Date.now(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _dep: [ServiceIdentifier, boolean, Trace?][] = []; + + private constructor( + readonly type: TraceType, + readonly name: string | null + ) { } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + branch(id: ServiceIdentifier, first: boolean): Trace { + const child = new Trace(TraceType.Branch, id.toString()); + this._dep.push([id, first, child]); + return child; + } + + stop() { + const dur = Date.now() - this._start; + Trace._totals += dur; + + let causedCreation = false; + + function printChild(n: number, trace: Trace) { + const res: string[] = []; + const prefix = new Array(n + 1).join('\t'); + for (const [id, first, child] of trace._dep) { + if (first && child) { + causedCreation = true; + res.push(`${prefix}CREATES -> ${id}`); + const nested = printChild(n + 1, child); + if (nested) { + res.push(nested); + } + } else { + res.push(`${prefix}uses -> ${id}`); + } + } + return res.join('\n'); + } + + const lines = [ + `${this.type === TraceType.Creation ? 'CREATE' : 'CALL'} ${this.name}`, + `${printChild(1, this)}`, + `DONE, took ${dur.toFixed(2)}ms (grand total ${Trace._totals.toFixed(2)}ms)` + ]; + + if (dur > 2 || causedCreation) { + Trace.all.add(lines.join('\n')); + } + } +} + +// #endregion + +export class InstantiationService implements IInstantiationService { + /** Phantom brand so the class satisfies the `IInstantiationService` interface. */ + declare readonly _serviceBrand: undefined; + + /** Parent container in the scope chain (root container has no parent). */ + protected readonly _parent: InstantiationService | null; + + /** + * Cached instances per identifier. First `get(id)` constructs and caches; + * subsequent calls return the same reference (singleton-per-container). + * + * Note: as of P1.1 the "constructed instance" for a registration lives + * inside `services` itself (the SyncDescriptor entry is replaced with the + * built instance once construction completes) — this is the krow shape. + * `_instances` is kept as a lookup fast path AND remains the source of + * truth for the LIFO `_constructionOrder` (PLAN D8); `_setCreatedServiceInstance` + * writes BOTH so dispose() can walk the construction order without + * re-querying `services`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected readonly _instances = new Map, any>(); + + /** + * Order in which identifiers were first constructed in this container. + * Used to teardown in reverse order on `dispose`. Preserved per PLAN D8. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected readonly _constructionOrder: ServiceIdentifier[] = []; + + /** Live children created via `createChild`. Disposed transitively. */ + protected readonly _children = new Set(); + + /** + * Per-tree construction stack (only mutated/read on the ROOT container of + * the tree). Tracks ids currently mid-construction across the entire + * parent/child tree, so a cycle expressed via a ctor body that calls + * `accessor.get(peer)` synchronously is still caught — even if the Graph + * walk in `_createAndCacheServiceInstance` could not have predicted the + * edge from static `@IFoo` metadata alone. + * + * Order matters: array (not Set) so the `path` reported by + * `CyclicDependencyError` reflects the actual construction sequence. + * + * Preserved per PLAN D3 as the second defensive layer; the primary check + * is now the Graph walk in `_createAndCacheServiceInstance`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _inProgress: ServiceIdentifier[] = []; + + /** + * Per-container guard: catches the case where a `SyncDescriptor` ctor + * synchronously triggers re-construction of the SAME id (e.g. a service + * whose @IFoo dependency loops back to itself transitively through the + * Graph). Throws an illegal-state error rather than a CyclicDependencyError + * because this represents an internal invariant violation — the Graph walk + * should have caught the cycle first. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _activeInstantiations = new Set>(); + + /** + * Instances materialised via the delayed-instantiation Proxy path. The + * Proxy itself is what callers see (and what was placed into the owning + * container by `_setCreatedServiceInstance`), but the underlying real + * instance lives behind `idle.value` and is not part of + * `_constructionOrder`. We add it here so `dispose()` can still tear it + * down — see `dispose()` for the LIFO-first / set-second order (PLAN D8 + * preserves the kimi LIFO order; krow ONLY has this set). + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _servicesToMaybeDispose = new Set(); + + private _disposed = false; + + constructor( + public readonly services: ServiceCollection = new ServiceCollection(), + parent: InstantiationService | null = null, + protected readonly _enableTracing: boolean = false, + ) { + this._parent = parent; + // Self-register so `@IInstantiationService`-decorated ctor params resolve + // to the live container that constructed them (krow / VSCode parity: + // `instantiationService.ts:110`). Each container — root and every child + // — stamps its OWN slot, so `child.invokeFunction(a => a.get(I)) === child` + // even when the parent already registered itself. The Graph rewrite + // preserves this invariant per Phase-0 reviewer note #4: the + // self-registration lives in the local `services` map, so + // `_getServiceInstanceOrDescriptor(IInstantiationService)` in the child + // finds the child's own slot before walking to the parent. + this.services.set(IInstantiationServiceDecorator, this); + } + + invokeFunction(fn: (accessor: ServicesAccessor) => R): R { + this._assertNotDisposed(); + const _trace = Trace.traceInvocation(this._enableTracing, fn); + try { + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => this._getOrCreateServiceInstance(id, _trace), + }; + return fn(accessor); + } finally { + _trace.stop(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(descriptor: SyncDescriptor): T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createInstance(ctor: new (...args: any[]) => T, ...rest: any[]): T; + // Implementation. Two-way overload: either a `SyncDescriptor` packaging + // ctor + static args (rest is appended after the static args) or a bare + // `ctor` plus rest args. Constructor-arg `@IFoo` injection is now live — + // any service-decorated trailing parameters are auto-resolved from the + // container via `_util.getServiceDependencies`. + createInstance( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctorOrDescriptor: SyncDescriptor | (new (...args: any[]) => T), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...rest: any[] + ): T { + this._assertNotDisposed(); + let _trace: Trace; + let result: T; + if (ctorOrDescriptor instanceof SyncDescriptor) { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor.ctor); + result = this._createInstance( + ctorOrDescriptor.ctor, + ctorOrDescriptor.staticArguments.concat(rest), + _trace, + ); + } else { + _trace = Trace.traceCreation(this._enableTracing, ctorOrDescriptor); + result = this._createInstance(ctorOrDescriptor, rest, _trace); + } + _trace.stop(); + return result; + } + + /** + * Create a scoped child container. The child sees parent registrations + * transparently; if the child has its own registration for an id, it + * shadows the parent for resolution. Construction always happens in the + * owning container, so both child-as-seen-from-parent and parent-as-seen- + * from-child have a single source of truth. + * + * Tracing flag is inherited from the parent so a deep child can't + * accidentally suppress tracing the parent enabled. + */ + createChild(services: ServiceCollectionLike): IInstantiationService { + this._assertNotDisposed(); + // Defensive: only accept real ServiceCollection instances. The + // `ServiceCollectionLike` alias exists for the interface surface to avoid + // a circular type import, but at runtime the child needs a real Map. + if (!(services instanceof ServiceCollection)) { + throw new TypeError( + 'createChild requires a ServiceCollection instance (got something else)', + ); + } + const child = new InstantiationService(services, this, this._enableTracing); + this._children.add(child); + return child; + } + + /** + * Tear down this container and all children. Disposes any cached instance + * with a `dispose()` method, in REVERSE construction order (PLAN D8). + * Idempotent: a second call is a no-op. Also notifies parent if any (so + * parent can drop its back-reference) and disposes children transitively. + */ + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + + // 1) Dispose children first (depth-first). Iterating a Set we mutate is + // unsafe; snapshot then iterate. + const childSnapshot = Array.from(this._children); + this._children.clear(); + for (const child of childSnapshot) { + try { + child.dispose(); + } catch { + // Continue tearing down siblings even if one throws. + } + } + + // 2) Dispose own instances in reverse construction order, duck-typed + // against the `IDisposable` shape. + for (let i = this._constructionOrder.length - 1; i >= 0; i--) { + const id = this._constructionOrder[i]!; + const instance = this._instances.get(id); + if (instance && typeof (instance as Partial).dispose === 'function') { + try { + (instance as IDisposable).dispose(); + } catch { + // Swallow: a single failed teardown shouldn't strand siblings. + } + this._servicesToMaybeDispose.delete(instance); + } + } + this._instances.clear(); + this._constructionOrder.length = 0; + + // 3) Dispose any Proxy-materialised instances that were NOT seen by the + // LIFO `_constructionOrder` loop (P1.2). Eager services are written + // to `_constructionOrder` via `_setCreatedServiceInstance` and are + // therefore covered above; the lazy Proxy path adds the real instance + // to `_servicesToMaybeDispose` from inside the `GlobalIdleValue` + // executor, but the Proxy itself doesn't carry a `dispose` method — + // so this set is the only handle to the underlying instance. Order + // among Proxy-materialised instances is insertion order; the LIFO + // invariant is intentionally not extended here (PLAN D8 ties LIFO to + // `_constructionOrder`, which only tracks eager construction). + for (const candidate of this._servicesToMaybeDispose) { + if (candidate && typeof (candidate as Partial).dispose === 'function') { + try { + (candidate as IDisposable).dispose(); + } catch { + // Swallow: dispose() must be forgiving. + } + } + } + this._servicesToMaybeDispose.clear(); + + // 3) Drop our back-reference from parent so parent doesn't double-dispose + // us later. + if (this._parent) { + this._parent._children.delete(this); + } + } + + /** + * Build an instance of `ctor`, auto-injecting any `@IFoo`-decorated + * trailing parameters from the container. Static args (the caller-supplied + * prefix) come first; service args come after, sorted by their decorator + * position. Mirrors krow `instantiationService.ts:194-218`. + * + * If the caller passed fewer (or more) static args than the position of + * the first service-decorated parameter, we log a `console.trace` warning + * and pad / truncate so the constructor signature still lines up — same + * behavior as krow / VSCode. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _createInstance(ctor: any, args: unknown[], _trace: Trace): T { + const serviceDependencies = _util.getServiceDependencies(ctor).sort((a, b) => a.index - b.index); + const serviceArgs: unknown[] = []; + for (const dependency of serviceDependencies) { + const service = this._getOrCreateServiceInstance(dependency.id, _trace); + serviceArgs.push(service); + } + + const firstServiceArgPos = + serviceDependencies.length > 0 ? serviceDependencies[0]!.index : args.length; + + if (args.length !== firstServiceArgPos) { + // eslint-disable-next-line no-console + globalThis.console.trace( + `[createInstance] First service dependency of ${(ctor as { name?: string }).name} at position ${firstServiceArgPos + 1} conflicts with ${args.length} static arguments`, + ); + const delta = firstServiceArgPos - args.length; + if (delta > 0) { + args = args.concat(new Array(delta)); + } else { + args = args.slice(0, firstServiceArgPos); + } + } + + return Reflect.construct(ctor, args.concat(serviceArgs)); + } + + /** + * Resolve an identifier in the current container, walking up the parent + * chain if not registered locally. Construction happens in the OWNING + * container so its cache holds the singleton. + * + * P1.1 makes this a thin router that delegates to the Graph-based + * `_safeCreateAndCacheServiceInstance` whenever the resolved entry is a + * `SyncDescriptor`. The Graph walk is the PRIMARY cycle-detection path — + * it builds the entire dependency subtree before constructing anything, + * so cycles expressed via `@IFoo` decorator metadata are caught + * statically (no ctor body need run). + * + * The legacy linear `_inProgress` stack (mutated below) is preserved as + * the SECONDARY defensive layer per PLAN D3: it catches the case where a + * ctor body synchronously calls `accessor.get(peer)` — a ctor-time + * dynamic edge that the Graph walk cannot predict. + */ + protected _getOrCreateServiceInstance(id: ServiceIdentifier, _trace: Trace): T { + const cached = this._instances.get(id); + if (cached !== undefined) { + _trace.branch(id, false); + return cached as T; + } + + const entry = this._getServiceInstanceOrDescriptor(id); + if (entry === undefined) { + throw new Error(`No service registered for identifier '${String(id)}'`); + } + + if (entry instanceof SyncDescriptor) { + // Linear tree-wide cycle check (PLAN D3, second defensive layer): + // a ctor body calling `accessor.get(peer)` synchronously will reach + // here while `peer` is mid-construction. The Graph walk inside + // `_createAndCacheServiceInstance` cannot predict ctor-body edges + // since they aren't expressed via `@IFoo` metadata; this stack catches + // them. Mutated only on the ROOT container so cycles across + // parent/child boundaries (parent A→B, child B→A) are still caught. + const root = this._root(); + if (root._inProgress.includes(id)) { + const path = [...root._inProgress, id].map((x) => String(x)); + throw new CyclicDependencyError(path); + } + + // The descriptor lives somewhere in this container or an ancestor; + // construct via the owner so the cache is on the owning container. + // `_safeCreateAndCacheServiceInstance` is invoked on THIS container — + // the Graph walk then uses `_getServiceInstanceOrDescriptor` (which + // walks parents) to find each transitive dependency's owner, and + // `_setCreatedServiceInstance` deposits the built instance into the + // owning container. This preserves the self-register invariant: + // `IInstantiationService` resolves locally before the parent walk + // begins. + return this._safeCreateAndCacheServiceInstance(id, entry, _trace.branch(id, true)); + } + // Pre-built instance shorthand — cache locally and return. + _trace.branch(id, false); + this._setCreatedServiceInstance(id, entry as T); + return entry as T; + } + + /** + * Per-container guard against same-id recursive construction (e.g. a ctor + * that synchronously triggers construction of its own id). Wraps + * `_createAndCacheServiceInstance` with try/finally so the guard always + * clears. Mirrors krow `instantiationService.ts:254-264`. + */ + private _safeCreateAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + if (this._activeInstantiations.has(id)) { + throw new Error(`illegal state - RECURSIVELY instantiating service '${String(id)}'`); + } + this._activeInstantiations.add(id); + try { + return this._createAndCacheServiceInstance(id, desc, _trace); + } finally { + this._activeInstantiations.delete(id); + } + } + + /** + * Build the full dependency subtree rooted at `(id, desc)` as a + * `Graph<{id, desc, _trace}>`, then repeatedly consume `graph.roots()` + * (leaves first) so each node is constructed AFTER all of its dependencies + * are cached. If `graph.roots()` becomes empty while the graph is + * non-empty, a cycle exists — throw `CyclicDependencyError(graph)`. The + * legacy `_inProgress` stack also catches ctor-body-induced cycles + * directly inside `_getOrCreateServiceInstance` below; both layers are + * preserved per PLAN D3. + * + * Mirrors krow `instantiationService.ts:266-323`. + */ + private _createAndCacheServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + _trace: Trace, + ): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type Triple = { id: ServiceIdentifier; desc: SyncDescriptor; _trace: Trace }; + const graph = new Graph(data => data.id.toString()); + + let cycleCount = 0; + const stack: Triple[] = [{ id, desc, _trace }]; + const seen = new Set(); + while (stack.length) { + const item = stack.pop()!; + + if (seen.has(String(item.id))) { + continue; + } + seen.add(String(item.id)); + + graph.lookupOrInsertNode(item); + + if (cycleCount++ > 1000) { + throw new CyclicDependencyError(graph); + } + + for (const dependency of _util.getServiceDependencies(item.desc.ctor)) { + const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id); + if (instanceOrDesc === undefined) { + // Mirror krow: warn but don't throw — the constructor will get + // `undefined` for that arg and either crash with a more useful + // message or work if the dependency is optional. + // eslint-disable-next-line no-console + globalThis.console.warn( + `[createInstance] ${String(item.id)} depends on ${String(dependency.id)} which is NOT registered.`, + ); + } + + if (instanceOrDesc instanceof SyncDescriptor) { + const d: Triple = { + id: dependency.id, + desc: instanceOrDesc, + _trace: item._trace.branch(dependency.id, true), + }; + graph.insertEdge(item, d); + stack.push(d); + } + } + } + + while (true) { + const roots = graph.roots(); + + if (roots.length === 0) { + if (!graph.isEmpty()) { + throw new CyclicDependencyError(graph); + } + break; + } + + for (const { data } of roots) { + // Re-check on each iteration: an earlier root in THIS round may have + // satisfied the descriptor for this id (multiple nodes can share an + // identifier across nested subgraphs). + const instanceOrDesc = this._getServiceInstanceOrDescriptor(data.id); + if (instanceOrDesc instanceof SyncDescriptor) { + const lazy = data.desc.supportsDelayedInstantiation; + const instance = this._createServiceInstance( + data.id, + data.desc, + lazy, + data._trace, + ); + // For lazy services, the returned value is a Proxy; the real + // instance lands in `_servicesToMaybeDispose` only after + // materialisation. We deposit the Proxy without touching + // `_constructionOrder` so dispose() does not accidentally trigger + // materialisation just to call a non-existent `.dispose()`. + this._setCreatedServiceInstance(data.id, instance, lazy); + } + graph.removeNode(data); + } + } + return this._getServiceInstanceOrDescriptor(id) as T; + } + + /** + * Construct a service instance — either eagerly (the default) or wrapped + * in a `Proxy` that defers real construction until the first non-event + * property access (P1.2). + * + * Eager path: pushes `id` onto the root-tree `_inProgress` stack so a ctor + * body calling `accessor.get(self)` synchronously is caught by + * `_getOrCreateServiceInstance` as a cycle (PLAN D3 second defensive + * layer). Returns the real instance immediately; the caller writes it + * into the owning container via `_setCreatedServiceInstance` which also + * stamps `_constructionOrder` for LIFO dispose (PLAN D8). + * + * Lazy path (`supportsDelayedInstantiation: true`): returns a `Proxy` + * over `Object.create(null)` whose `get` trap: + * - For `onDid*` / `onWill*` string keys accessed BEFORE materialisation, + * returns a wrapped `Event` function that parks the listener into a + * `LinkedList` keyed by the event name. When the + * real instance materialises, every parked listener is replayed by + * calling the real `event(callback, thisArg, disposables)`. + * - For any other key, reads `idle.value` (constructs the real + * instance), caches function references on the proxy target so + * repeat reads short-circuit, and returns the value. + * - `getPrototypeOf` returns `ctor.prototype` so `instanceof Ctor` + * works against the Proxy. + * + * The real instance is added to `_servicesToMaybeDispose` inside the + * `GlobalIdleValue` executor so `dispose()` can tear it down (lazy + * instances do not appear in `_constructionOrder`). + * + * Mirrors krow `instantiationService.ts:335-421`. + */ + private _createServiceInstance( + id: ServiceIdentifier, + desc: SyncDescriptor, + supportsDelayedInstantiation: boolean, + _trace: Trace, + ): T { + if (!supportsDelayedInstantiation) { + const root = this._root(); + root._inProgress.push(id); + try { + return this._createInstance(desc.ctor, desc.staticArguments.slice(), _trace); + } finally { + const popIdx = root._inProgress.lastIndexOf(id); + if (popIdx >= 0) { + root._inProgress.splice(popIdx, 1); + } + } + } + + // Delayed instantiation: build a Proxy backed by a GlobalIdleValue. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type EventLike = (callback: (e: any) => void, thisArg?: unknown, disposables?: IDisposable[]) => IDisposable; + type EarlyListenerData = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: Parameters; + disposable?: IDisposable; + }; + const earlyListeners = new Map>(); + const _ctor = desc.ctor; + const _args = desc.staticArguments.slice(); + // Capture references the executor needs. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const idle = new GlobalIdleValue(() => { + const root = self._root(); + root._inProgress.push(id); + let result: T; + try { + result = self._createInstance(_ctor, _args.slice(), _trace); + } finally { + const popIdx = root._inProgress.lastIndexOf(id); + if (popIdx >= 0) { + root._inProgress.splice(popIdx, 1); + } + } + // Replay parked event subscriptions against the real instance. + for (const [key, values] of earlyListeners) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const candidate = (result as any)[key] as EventLike | undefined; + if (typeof candidate === 'function') { + for (const value of values) { + value.disposable = candidate.apply(result, value.listener); + } + } + } + earlyListeners.clear(); + self._servicesToMaybeDispose.add(result); + return result; + }); + + return new Proxy(Object.create(null), { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(target: any, key: PropertyKey): unknown { + if (!idle.isInitialized) { + // Event-shape keys: park the subscription until the real instance + // materialises (e.g. via a non-event get). + if ( + typeof key === 'string' && + (key.startsWith('onDid') || key.startsWith('onWill')) + ) { + let list = earlyListeners.get(key); + if (!list) { + list = new LinkedList(); + earlyListeners.set(key, list); + } + const event: EventLike = (callback, thisArg, disposables) => { + if (idle.isInitialized) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (idle.value as any)[key](callback, thisArg, disposables); + } + const entry: EarlyListenerData = { + listener: [callback, thisArg, disposables], + disposable: undefined, + }; + const rm = list!.push(entry); + return { + dispose() { + rm(); + entry.disposable?.dispose(); + }, + }; + }; + return event; + } + } + + // Method/value already memoised on the proxy target. + if (key in target) { + return target[key]; + } + + // Materialise + cache. Function values are bound to the real + // instance so `this` reads work. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj = idle.value as any; + let prop = obj[key]; + if (typeof prop !== 'function') { + return prop; + } + prop = prop.bind(obj); + target[key] = prop; + return prop; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set(_target: T, p: PropertyKey, value: any): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (idle.value as any)[p] = value; + return true; + }, + getPrototypeOf(_target: T): object { + return _ctor.prototype as object; + }, + }) as T; + } + + /** + * Deposit a constructed instance into the owning container (the one whose + * local `services` map holds a `SyncDescriptor` for this id). Walks the + * parent chain so a child can deposit a parent-owned service back into the + * parent's cache. Mirrors krow `instantiationService.ts:220-228`. + * + * Also stamps the local `_instances` + (eager path only) `_constructionOrder` + * so dispose() can walk teardown in reverse construction order (PLAN D8). + * Lazy (Proxy-wrapped) services are intentionally NOT added to + * `_constructionOrder` — disposing them would require reading a property + * of the Proxy, which would force materialisation. Lazy disposal is + * handled by `_servicesToMaybeDispose` once the Proxy materialises. + */ + private _setCreatedServiceInstance( + id: ServiceIdentifier, + instance: T, + lazy: boolean = false, + ): void { + if (this.services.get(id) instanceof SyncDescriptor) { + // Replace the descriptor in-place with the constructed instance so a + // second lookup short-circuits via the `_instances` cache OR the + // services map directly. + this.services.set(id, instance); + this._instances.set(id, instance); + if (!lazy) { + this._constructionOrder.push(id); + } + } else if (this.services.has(id)) { + // Pre-built instance shorthand — already cached locally. + this._instances.set(id, instance); + // Don't add to `_constructionOrder` again if it was already pushed. + if (!lazy && !this._constructionOrder.includes(id)) { + this._constructionOrder.push(id); + } + } else if (this._parent) { + this._parent._setCreatedServiceInstance(id, instance, lazy); + } else { + throw new Error( + `illegal state - setting UNKNOWN service instance '${String(id)}'`, + ); + } + } + + /** + * Find the instance OR descriptor for `id` by walking the parent chain. + * Returns `undefined` if no container in the chain has a registration. + * Mirrors krow `instantiationService.ts:230-237`. + */ + private _getServiceInstanceOrDescriptor( + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): T | SyncDescriptor | undefined { + const instanceOrDesc = this.services.get(id); + if (instanceOrDesc === undefined && this._parent) { + return this._parent._getServiceInstanceOrDescriptor(id); + } + return instanceOrDesc as T | SyncDescriptor | undefined; + } + + /** Walk up to the tree root. Used for the shared in-progress stack. */ + private _root(): InstantiationService { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let cur: InstantiationService = this; + while (cur._parent) { + cur = cur._parent; + } + return cur; + } + + private _assertNotDisposed(): void { + if (this._disposed) { + throw new Error('InstantiationService has been disposed'); + } + } +} diff --git a/packages/agent-core/src/di/lifecycle.ts b/packages/agent-core/src/di/lifecycle.ts new file mode 100644 index 000000000..3b593149b --- /dev/null +++ b/packages/agent-core/src/di/lifecycle.ts @@ -0,0 +1,59 @@ +/** + * Lifecycle primitives for DI-managed services: `IDisposable` interface and a + * `Disposable` base class that owns a stack of sub-disposables and tears them + * down in reverse register order. Modelled after VSCode's `lifecycle.ts`. + */ + +export interface IDisposable { + dispose(): void; +} + +/** + * Base class for services that own other disposables. Subclasses call + * `this._register(child)` to take ownership; `dispose()` tears children down + * in reverse register order (LIFO) and is idempotent. + */ +export abstract class Disposable implements IDisposable { + private _disposed = false; + protected _toDispose: IDisposable[] = []; + + /** + * Take ownership of a child disposable. Returns the child for ergonomic + * one-liner chaining (`const x = this._register(new Foo())`). + */ + protected _register(d: T): T { + if (this._disposed) { + // Don't silently hold a reference after disposal; tear down immediately + // so we don't leak the child if someone calls `_register` post-dispose. + try { + d.dispose(); + } catch { + // Swallow: dispose() must be idempotent / forgiving. + } + return d; + } + this._toDispose.push(d); + return d; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + // Reverse order: most-recently-registered tears down first (LIFO). + while (this._toDispose.length > 0) { + const child = this._toDispose.pop(); + if (!child) continue; + try { + child.dispose(); + } catch { + // Continue tearing down siblings even if one throws. + } + } + } + + protected get _isDisposed(): boolean { + return this._disposed; + } +} diff --git a/packages/agent-core/src/di/serviceCollection.ts b/packages/agent-core/src/di/serviceCollection.ts new file mode 100644 index 000000000..9a879456f --- /dev/null +++ b/packages/agent-core/src/di/serviceCollection.ts @@ -0,0 +1,57 @@ +/** + * `ServiceCollection` is the unordered map of service-id → (descriptor | instance) + * used to seed an `InstantiationService`. It's a thin wrapper over `Map` whose + * value type is `SyncDescriptor | T` — the container decides which based on + * `instanceof SyncDescriptor`. + */ + +import type { SyncDescriptor } from './descriptors'; +import type { ServiceIdentifier } from './instantiation'; + +export class ServiceCollection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _entries = new Map, SyncDescriptor | any>(); + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...entries: ReadonlyArray, SyncDescriptor | any]> + ) { + for (const [id, value] of entries) { + this._entries.set(id, value); + } + } + + /** + * Set an entry. Returns the previous value (or `undefined` if the id was + * not previously set). + */ + set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + const prev = this._entries.get(id); + this._entries.set(id, instanceOrDescriptor); + return prev; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + has(id: ServiceIdentifier): boolean { + return this._entries.has(id); + } + + get(id: ServiceIdentifier): T | SyncDescriptor | undefined { + return this._entries.get(id); + } + + /** Iterate all entries. Order is insertion-order (Map semantics). */ + forEach( + callback: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: SyncDescriptor | any, + ) => void, + ): void { + this._entries.forEach((value, id) => callback(id, value)); + } +} diff --git a/packages/agent-core/src/di/test.ts b/packages/agent-core/src/di/test.ts new file mode 100644 index 000000000..24317dbf7 --- /dev/null +++ b/packages/agent-core/src/di/test.ts @@ -0,0 +1,15 @@ +/** + * Subpath barrel for `@moonshot-ai/agent-core/di/test`. Holds only the + * test-time surface so the main `@moonshot-ai/agent-core` entry does not + * carry test code into daemon bundles. Imported as: + * + * ```ts + * import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'; + * ``` + * + * Anything not test-specific (e.g. `InstantiationService`, decorators) + * must continue to be exported from `./index.ts` — do NOT duplicate it + * here. + */ + +export { TestInstantiationService } from './testInstantiationService'; diff --git a/packages/agent-core/src/di/testInstantiationService.ts b/packages/agent-core/src/di/testInstantiationService.ts new file mode 100644 index 000000000..128da6281 --- /dev/null +++ b/packages/agent-core/src/di/testInstantiationService.ts @@ -0,0 +1,134 @@ +/** + * `TestInstantiationService` — a test-friendly extension of + * `InstantiationService` that exposes direct `get` / `set` / `stub` + * helpers so test bodies don't have to thread an `invokeFunction(a => …)` + * accessor through every assertion. + * + * Adapted from krow `testInstantiationService.ts` (in turn the VSCode + * original). Two divergences from krow: + * + * 1. **Ctor signature**: kimi's `InstantiationService` constructor is + * `(services, parent, _enableTracing)` (parent second, no `_strict` + * mode); krow's is `(services, strict, parent, _enableTracing)`. + * The `strict` boolean does not exist in kimi yet — `_throwIfStrict` + * was not ported because the daemon never enables it. If a future + * phase adds strict mode, surface a 2nd ctor param here. + * 2. **`createServices` factory**: krow uses `DisposableStore` / + * `toDisposable` from `base/`. kimi's `Disposable` class is a + * different shape (LIFO subdisposable owner, not a Set). The factory + * is therefore omitted from the initial port — `TestInstantiationService` + * alone covers >95% of the test surface daemon will need in Phase 2. + * Add `createServices` if/when a test fixture needs it. + * + * Exported via the subpath barrel `@moonshot-ai/agent-core/di/test` so + * the main `@moonshot-ai/agent-core` entry stays free of test-only code + * (no test code leaks into the daemon bundle). + */ + +import { SyncDescriptor } from './descriptors'; +import { + type ServiceIdentifier, + type ServicesAccessor, +} from './instantiation'; +import { InstantiationService, Trace } from './instantiationService'; +import { ServiceCollection } from './serviceCollection'; + +/** + * A test-friendly extension of {@link InstantiationService}. + * + * Convenience surface for tests: + * - {@link get} — directly resolve a service without going through an + * accessor. + * - {@link set} — register or replace a service instance / descriptor. + * - {@link stub} — semantic alias for {@link set}, intended for test + * overrides where the intent is "replace the real impl with a mock". + * - {@link createChild} — return a child `TestInstantiationService` + * (krow returns `IInstantiationService`; we narrow to the test type so + * callers can keep calling `.stub` / `.get` on the child). + * + * Example: + * ```ts + * import { TestInstantiationService } from '@moonshot-ai/agent-core/di/test'; + * + * const ix = new TestInstantiationService(); + * ix.stub(ILogger, { log: vi.fn() } as ILogger); + * const target = ix.createInstance(SomeClass, 'static-arg'); + * ``` + */ +export class TestInstantiationService extends InstantiationService implements ServicesAccessor { + private readonly _serviceCollection: ServiceCollection; + + constructor( + serviceCollection: ServiceCollection = new ServiceCollection(), + parent: InstantiationService | null = null, + enableTracing: boolean = false, + ) { + super(serviceCollection, parent, enableTracing); + this._serviceCollection = serviceCollection; + } + + /** + * Directly resolve a service. Calls the protected + * `_getOrCreateServiceInstance` on the base class with a no-op + * `Trace._None`-equivalent (`Trace.traceCreation(false, …)` returns the + * sentinel) so the public test API matches accessor.get semantics + * without forcing every test to open an `invokeFunction` closure. + */ + public get(id: ServiceIdentifier): T { + return super._getOrCreateServiceInstance( + id, + Trace.traceCreation(false, TestInstantiationService), + ); + } + + /** + * Register or replace a service instance in the underlying collection. + * Accepts a pre-built instance OR a `SyncDescriptor` for lazy + * construction. + * + * Returns the previous binding (or `undefined` if none) so test + * fixtures can save-and-restore. + */ + public set( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + return this._serviceCollection.set(id, instanceOrDescriptor); + } + + /** + * Semantic alias for {@link set}. Use this in tests when you want to + * *override* an existing service with a mock or stub — the verb makes + * intent obvious at the call site. + */ + public stub( + id: ServiceIdentifier, + instanceOrDescriptor: T | SyncDescriptor, + ): T | SyncDescriptor | undefined { + return this.set(id, instanceOrDescriptor); + } + + /** + * Create a child `TestInstantiationService` that inherits services from + * this one. The return type is narrowed from `IInstantiationService` + * to `TestInstantiationService` so chained test calls (`.stub`, `.get`) + * remain ergonomic on the child. + */ + public override createChild(services: ServiceCollection): TestInstantiationService { + if (!(services instanceof ServiceCollection)) { + throw new TypeError( + 'createChild requires a ServiceCollection instance (got something else)', + ); + } + const child = new TestInstantiationService(services, this); + // The base class tracks children for cascade-dispose via its private + // `_children` set; we mirror by relying on the parent's `_children` + // being populated through the base ctor's parent reference. But the + // base `createChild` we shadow here also registered the child into + // `_children` — we duplicate that registration manually because the + // `super.createChild` call would have built an `InstantiationService` + // (not `TestInstantiationService`). + (this as unknown as { _children: Set })._children.add(child); + return child; + } +} diff --git a/packages/agent-core/src/di/util/idleValue.ts b/packages/agent-core/src/di/util/idleValue.ts new file mode 100644 index 000000000..e4c7f4993 --- /dev/null +++ b/packages/agent-core/src/di/util/idleValue.ts @@ -0,0 +1,133 @@ +/** + * `GlobalIdleValue` — defers an executor until the first `value` access + * (or the next browser idle callback / `setTimeout` fallback). Used by + * `InstantiationService._createServiceInstance` to back + * `supportsDelayedInstantiation: true` services: the Proxy returned to + * callers triggers `idle.value` on first non-`onDid*` access, which runs + * the real construction. + * + * Vendored from krow `packages/core/src/base/async.ts:57-97` (which is the + * VSCode original). Node-safe: falls back to `setTimeout` when + * `requestIdleCallback` is unavailable (the typical Node environment). + * + * Only `GlobalIdleValue` is exported — `runWhenGlobalIdle` is internal to + * this module because the DI subsystem is the only consumer; if another + * package later needs it, lift it then. + */ + +import type { IDisposable } from '../lifecycle'; + +interface IdleDeadline { + readonly didTimeout: boolean; + timeRemaining(): number; +} + +/** + * Run `callback` the next time the host is idle. Returns a disposable that + * cancels the pending callback if disposed before it fires. Uses + * `requestIdleCallback` when available; otherwise schedules a `setTimeout` + * polyfill that simulates a one-frame deadline (15 ms). + */ +function runWhenGlobalIdle( + callback: (idle: IdleDeadline) => void, + timeout?: number, +): IDisposable { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const safeGlobal: any = globalThis; + + if ( + typeof safeGlobal.requestIdleCallback === 'function' && + typeof safeGlobal.cancelIdleCallback === 'function' + ) { + const handle: number = safeGlobal.requestIdleCallback( + callback, + typeof timeout === 'number' ? { timeout } : undefined, + ); + let disposed = false; + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + safeGlobal.cancelIdleCallback(handle); + }, + }; + } else { + // Polyfill for environments without requestIdleCallback (e.g. Node.js). + let disposed = false; + const handle = setTimeout(() => { + if (disposed) { + return; + } + const end = Date.now() + 15; // one frame at ~64fps + const deadline: IdleDeadline = { + didTimeout: true, + timeRemaining() { + return Math.max(0, end - Date.now()); + }, + }; + callback(Object.freeze(deadline)); + }); + return { + dispose() { + if (disposed) { + return; + } + disposed = true; + clearTimeout(handle); + }, + }; + } +} + +/** + * Lazy box around an executor `() => T`. The executor is scheduled to run on + * the next idle tick, but reading `.value` BEFORE the idle tick fires + * cancels the schedule and runs the executor synchronously — then caches + * the result (or rethrows the captured error) on every subsequent access. + * + * `isInitialized` lets the Proxy distinguish "real instance exists" from + * "still pending" so `onDid*`/`onWill*` event subscriptions can be parked + * in an early-listener list and replayed on materialisation. + */ +export class GlobalIdleValue { + private readonly _executor: () => void; + private readonly _handle: IDisposable; + + private _didRun: boolean = false; + private _value?: T; + private _error: unknown; + + constructor(executor: () => T) { + this._executor = () => { + try { + this._value = executor(); + } catch (err) { + this._error = err; + } finally { + this._didRun = true; + } + }; + this._handle = runWhenGlobalIdle(() => this._executor()); + } + + dispose(): void { + this._handle.dispose(); + } + + get value(): T { + if (!this._didRun) { + this._handle.dispose(); + this._executor(); + } + if (this._error) { + throw this._error; + } + return this._value!; + } + + get isInitialized(): boolean { + return this._didRun; + } +} diff --git a/packages/agent-core/src/di/util/linkedList.ts b/packages/agent-core/src/di/util/linkedList.ts new file mode 100644 index 000000000..b379ae466 --- /dev/null +++ b/packages/agent-core/src/di/util/linkedList.ts @@ -0,0 +1,86 @@ +/** + * Doubly-linked list with O(1) `push` and removal via the disposer returned + * from `push`. Used by `InstantiationService` to park `onDid*`/`onWill*` + * event subscriptions made against a Proxy before the real service is + * materialised — when the real instance is built, the list is drained and + * each parked listener is rebound to the real event. + * + * Vendored verbatim from krow `packages/core/src/base/linkedList.ts` + * (in turn the VSCode original). + */ + +class Node { + static readonly Undefined = new Node(undefined); + + element: E; + next: Node | typeof Node.Undefined; + prev: Node | typeof Node.Undefined; + + constructor(element: E) { + this.element = element; + this.next = Node.Undefined; + this.prev = Node.Undefined; + } +} + +export class LinkedList { + private _first: Node | typeof Node.Undefined = Node.Undefined; + private _last: Node | typeof Node.Undefined = Node.Undefined; + private _size: number = 0; + + get size(): number { + return this._size; + } + + isEmpty(): boolean { + return this._first === Node.Undefined; + } + + push(element: E): () => void { + const newNode = new Node(element); + if (this._first === Node.Undefined) { + this._first = newNode; + this._last = newNode; + } else { + const oldLast = this._last as Node; + this._last = newNode; + newNode.prev = oldLast; + oldLast.next = newNode; + } + this._size += 1; + + let didRemove = false; + return () => { + if (!didRemove) { + didRemove = true; + this._remove(newNode); + } + }; + } + + private _remove(node: Node): void { + if (node.prev !== Node.Undefined && node.next !== Node.Undefined) { + const anchor = node.prev as Node; + anchor.next = node.next; + (node.next as Node).prev = anchor; + } else if (node.prev === Node.Undefined && node.next === Node.Undefined) { + this._first = Node.Undefined; + this._last = Node.Undefined; + } else if (node.next === Node.Undefined) { + this._last = (this._last as Node).prev!; + (this._last as Node).next = Node.Undefined; + } else if (node.prev === Node.Undefined) { + this._first = (this._first as Node).next!; + (this._first as Node).prev = Node.Undefined; + } + this._size -= 1; + } + + *[Symbol.iterator](): Iterator { + let node = this._first; + while (node !== Node.Undefined) { + yield (node as Node).element; + node = (node as Node).next; + } + } +} diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index a35781ccf..6477bdcf0 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -80,3 +80,6 @@ export type { ExecutableToolSuccessResult, ExecutableToolErrorResult, } from './loop/types'; + +// ─── Dependency injection container ──────────────────────────────────────── +export * from './di'; diff --git a/packages/agent-core/test/di/auto-inject.test.ts b/packages/agent-core/test/di/auto-inject.test.ts new file mode 100644 index 000000000..26b85c36f --- /dev/null +++ b/packages/agent-core/test/di/auto-inject.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { CyclicDependencyError } from '#/di/errors'; +import { InstantiationService } from '#/di/instantiationService'; +import { IInstantiationService, createDecorator } from '#/di/instantiation'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * P1.1 — `@IFoo` constructor-parameter auto-injection. + * + * The container now reads `_util.getServiceDependencies(ctor)` and resolves + * each entry against the container before constructing. Static (non-service) + * arguments come first; service args are appended in decorator-position order. + * + * Vitest/rolldown does not parse TypeScript parameter decorators in test + * files, so we apply them manually at runtime — same pattern as + * `decorator.test.ts`. This is functionally identical to the TS-emitted form + * `__metadata`/`__param` would produce: the decorator factory writes + * `$di$dependencies` metadata onto the ctor, which is what the container + * consumes. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function param(dec: any, target: any, index: number): void { + (dec as (t: unknown, k: string, i: number) => void)(target, '', index); +} + +describe('@IFoo auto-injection (P1.1)', () => { + it('pure-service ctor: both @IFoo params resolve from the container', () => { + interface IBar { + tag: 'bar'; + } + interface IBaz { + tag: 'baz'; + } + const IBar = createDecorator('p1.1-IBar-pure'); + const IBaz = createDecorator('p1.1-IBaz-pure'); + + class Bar implements IBar { + tag = 'bar' as const; + } + class Baz implements IBaz { + tag = 'baz' as const; + } + class Foo { + constructor( + public readonly bar: IBar, + public readonly baz: IBaz, + ) {} + } + param(IBar, Foo, 0); + param(IBaz, Foo, 1); + const IFoo = createDecorator('p1.1-IFoo-pure'); + + const ix = new InstantiationService( + new ServiceCollection( + [IBar, new SyncDescriptor(Bar)], + [IBaz, new SyncDescriptor(Baz)], + [IFoo, new SyncDescriptor(Foo)], + ), + ); + const foo = ix.invokeFunction((a) => a.get(IFoo)); + expect(foo).toBeInstanceOf(Foo); + expect(foo.bar).toBeInstanceOf(Bar); + expect(foo.baz).toBeInstanceOf(Baz); + }); + + it('mixed static prefix + service suffix via createInstance(ctor, ...rest)', () => { + interface IBaz { + tag: 'baz'; + } + const IBaz = createDecorator('p1.1-IBaz-mixed'); + class Baz implements IBaz { + tag = 'baz' as const; + } + class Bar { + constructor( + public readonly name: string, + public readonly baz: IBaz, + ) {} + } + param(IBaz, Bar, 1); + const ix = new InstantiationService( + new ServiceCollection([IBaz, new SyncDescriptor(Baz)]), + ); + const bar = ix.createInstance(Bar as new (name: string) => Bar, 'hello'); + expect(bar.name).toBe('hello'); + expect(bar.baz).toBeInstanceOf(Baz); + }); + + it('@IInstantiationService self-injection resolves to the OWNING container', () => { + // Direct check of the self-register invariant (Phase 0 reviewer note #4): + // the ctor must receive the live container. + class Widget { + constructor(public readonly label: string) {} + } + interface IFactoryHost { + makeWidget(): Widget; + } + const IFactoryHost = createDecorator('p1.1-IFactoryHost'); + class FactoryHost implements IFactoryHost { + constructor(private readonly ix: IInstantiationService) {} + makeWidget(): Widget { + return this.ix.createInstance(Widget, 'made-by-factory'); + } + } + param(IInstantiationService, FactoryHost, 0); + const ix = new InstantiationService( + new ServiceCollection([IFactoryHost, new SyncDescriptor(FactoryHost)]), + ); + const host = ix.invokeFunction((a) => a.get(IFactoryHost)); + const w = host.makeWidget(); + expect(w).toBeInstanceOf(Widget); + expect(w.label).toBe('made-by-factory'); + }); + + it('Graph cycle: A.@IBar + B.@IA throws CyclicDependencyError before any ctor runs', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('p1.1-cycle-IA'); + const IB = createDecorator('p1.1-cycle-IB'); + + let aCtorRan = false; + let bCtorRan = false; + class AImpl implements IA { + tag = 'A' as const; + constructor(_b: IB) { + aCtorRan = true; + } + } + class BImpl implements IB { + tag = 'B' as const; + constructor(_a: IA) { + bCtorRan = true; + } + } + param(IB, AImpl, 0); + param(IA, BImpl, 0); + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(AImpl)], + [IB, new SyncDescriptor(BImpl)], + ), + ); + + let captured: unknown; + try { + ix.invokeFunction((a) => a.get(IA)); + } catch (e) { + captured = e; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + // Graph form: message comes from `findCycleSlow()`. + expect((captured as CyclicDependencyError).message).toMatch( + /cyclic dependency between services/i, + ); + // No ctor body should have run (the Graph walk catches it statically). + expect(aCtorRan).toBe(false); + expect(bCtorRan).toBe(false); + }); + + it('cross-container Graph cycle: parent A→@IB, child B→@IA throws Cyclic', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('p1.1-xcycle-IA'); + const IB = createDecorator('p1.1-xcycle-IB'); + + class AImpl implements IA { + tag = 'A' as const; + constructor(_b: IB) {} + } + class BImpl implements IB { + tag = 'B' as const; + constructor(_a: IA) {} + } + param(IB, AImpl, 0); + param(IA, BImpl, 0); + const parent = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(AImpl)]), + ); + const child = parent.createChild( + new ServiceCollection([IB, new SyncDescriptor(BImpl)]), + ); + expect(() => + child.invokeFunction((a) => a.get(IA)), + ).toThrowError(CyclicDependencyError); + }); +}); diff --git a/packages/agent-core/test/di/child.test.ts b/packages/agent-core/test/di/child.test.ts new file mode 100644 index 000000000..69e995f31 --- /dev/null +++ b/packages/agent-core/test/di/child.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { InstantiationService } from '#/di/instantiationService'; +import { createDecorator } from '#/di/instantiation'; +import { Disposable, type IDisposable } from '#/di/lifecycle'; +import { ServiceCollection } from '#/di/serviceCollection'; + +interface ILogger { + log(msg: string): void; + name: string; +} +const ILogger = createDecorator('logger'); + +class ConsoleLogger implements ILogger { + name = 'console'; + log(_m: string): void { + /* noop */ + } +} +class ChildLogger implements ILogger { + name = 'child'; + log(_m: string): void { + /* noop */ + } +} + +describe('InstantiationService.createChild', () => { + it('child inherits parent services', () => { + const parent = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + const child = parent.createChild(new ServiceCollection()); + const fromChild = child.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBeInstanceOf(ConsoleLogger); + // Same singleton — child resolution doesn't double-construct. + const fromParent = parent.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBe(fromParent); + }); + + it('child shadowing: child registration overrides parent', () => { + const parent = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + const child = parent.createChild( + new ServiceCollection([ILogger, new SyncDescriptor(ChildLogger)]), + ); + const fromChild = child.invokeFunction((a) => a.get(ILogger)); + const fromParent = parent.invokeFunction((a) => a.get(ILogger)); + expect(fromChild).toBeInstanceOf(ChildLogger); + expect(fromParent).toBeInstanceOf(ConsoleLogger); + expect(fromChild).not.toBe(fromParent); + }); + + it('sibling isolation: two children of the same parent do not share scoped services', () => { + interface IScoped { + tag: string; + } + const IScoped = createDecorator('scoped'); + class ScopedA implements IScoped { + tag = 'A'; + } + class ScopedB implements IScoped { + tag = 'B'; + } + + const parent = new InstantiationService(); + const childA = parent.createChild( + new ServiceCollection([IScoped, new SyncDescriptor(ScopedA)]), + ); + const childB = parent.createChild( + new ServiceCollection([IScoped, new SyncDescriptor(ScopedB)]), + ); + + expect(childA.invokeFunction((a) => a.get(IScoped).tag)).toBe('A'); + expect(childB.invokeFunction((a) => a.get(IScoped).tag)).toBe('B'); + // Parent has no registration; resolution from parent must throw. + expect(() => parent.invokeFunction((a) => a.get(IScoped))).toThrowError( + /No service registered/, + ); + }); + + it('dispose order: A→B→C construction yields C→B→A teardown', () => { + const events: string[] = []; + interface ITagged { + tag: string; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + const IC = createDecorator('C'); + class A implements ITagged, IDisposable { + tag = 'A'; + dispose(): void { + events.push('disposed A'); + } + } + class B implements ITagged, IDisposable { + tag = 'B'; + dispose(): void { + events.push('disposed B'); + } + } + class C implements ITagged, IDisposable { + tag = 'C'; + dispose(): void { + events.push('disposed C'); + } + } + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(A)], + [IB, new SyncDescriptor(B)], + [IC, new SyncDescriptor(C)], + ), + ); + ix.invokeFunction((a) => { + a.get(IA); + a.get(IB); + a.get(IC); + }); + ix.dispose(); + expect(events).toEqual(['disposed C', 'disposed B', 'disposed A']); + }); + + it('idempotent dispose: second call is a no-op', () => { + const events: string[] = []; + interface IFoo { + tag: string; + } + const IFoo = createDecorator('foo'); + class Foo implements IFoo, IDisposable { + tag = 'foo'; + dispose(): void { + events.push('disposed'); + } + } + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(Foo)]), + ); + ix.invokeFunction((a) => a.get(IFoo)); + ix.dispose(); + ix.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('parent dispose propagates to children', () => { + const events: string[] = []; + interface IParentSvc { + tag: string; + } + interface IChildSvc { + tag: string; + } + const IParentSvc = createDecorator('parentSvc'); + const IChildSvc = createDecorator('childSvc'); + class ParentSvc implements IParentSvc, IDisposable { + tag = 'parent'; + dispose(): void { + events.push('disposed parent svc'); + } + } + class ChildSvc implements IChildSvc, IDisposable { + tag = 'child'; + dispose(): void { + events.push('disposed child svc'); + } + } + + const parent = new InstantiationService( + new ServiceCollection([IParentSvc, new SyncDescriptor(ParentSvc)]), + ); + const child = parent.createChild( + new ServiceCollection([IChildSvc, new SyncDescriptor(ChildSvc)]), + ); + + parent.invokeFunction((a) => a.get(IParentSvc)); + child.invokeFunction((a) => a.get(IChildSvc)); + + parent.dispose(); + // Child instances dispose before parent instances (children first). + expect(events).toEqual(['disposed child svc', 'disposed parent svc']); + }); + + it('disposing a child clears it from parent so parent.dispose does not double-dispose', () => { + const events: string[] = []; + interface ISvc { + tag: string; + } + const ISvc = createDecorator('svc'); + class Svc implements ISvc, IDisposable { + tag = 'svc'; + dispose(): void { + events.push('disposed'); + } + } + + const parent = new InstantiationService(); + const child = parent.createChild( + new ServiceCollection([ISvc, new SyncDescriptor(Svc)]), + ); + child.invokeFunction((a) => a.get(ISvc)); + child.dispose(); + parent.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('use-after-dispose: invokeFunction / createInstance / createChild throw', () => { + const ix = new InstantiationService(); + ix.dispose(); + expect(() => ix.invokeFunction((_a) => undefined)).toThrowError(/disposed/); + expect(() => ix.createInstance(class A {})).toThrowError(/disposed/); + expect(() => ix.createChild(new ServiceCollection())).toThrowError(/disposed/); + }); +}); + +describe('Disposable base class', () => { + it('reverse register order on dispose', () => { + const events: string[] = []; + class Child implements IDisposable { + constructor(public readonly label: string) {} + dispose(): void { + events.push(`disposed ${this.label}`); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new Child('first')); + this._register(new Child('second')); + this._register(new Child('third')); + } + } + const o = new Owner(); + o.dispose(); + expect(events).toEqual(['disposed third', 'disposed second', 'disposed first']); + }); + + it('idempotent dispose on the base class', () => { + const events: string[] = []; + class Child implements IDisposable { + dispose(): void { + events.push('disposed'); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new Child()); + } + } + const o = new Owner(); + o.dispose(); + o.dispose(); + expect(events).toEqual(['disposed']); + }); + + it('register-after-dispose: child is torn down immediately, not leaked', () => { + const events: string[] = []; + class Child implements IDisposable { + dispose(): void { + events.push('disposed'); + } + } + class Owner extends Disposable { + addLate(): void { + this._register(new Child()); + } + } + const o = new Owner(); + o.dispose(); + o.addLate(); + expect(events).toEqual(['disposed']); + }); + + it('continues teardown even if one child throws', () => { + const events: string[] = []; + class GoodChild implements IDisposable { + dispose(): void { + events.push('good'); + } + } + class BadChild implements IDisposable { + dispose(): void { + events.push('bad-attempted'); + throw new Error('boom'); + } + } + class Owner extends Disposable { + constructor() { + super(); + this._register(new GoodChild()); + this._register(new BadChild()); + } + } + const o = new Owner(); + expect(() => o.dispose()).not.toThrow(); + // BadChild is registered last so it tears down first (LIFO). + expect(events).toEqual(['bad-attempted', 'good']); + }); +}); diff --git a/packages/agent-core/test/di/collection.test.ts b/packages/agent-core/test/di/collection.test.ts new file mode 100644 index 000000000..cff5a70af --- /dev/null +++ b/packages/agent-core/test/di/collection.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { createDecorator } from '#/di/instantiation'; +import { ServiceCollection } from '#/di/serviceCollection'; + +interface ILogger { + log(msg: string): void; +} +interface IClock { + now(): number; +} +class ConsoleLogger implements ILogger { + log(_m: string): void { + /* noop */ + } +} +class FixedClock implements IClock { + now(): number { + return 42; + } +} + +const ILogger = createDecorator('logger'); +const IClock = createDecorator('clock'); + +describe('ServiceCollection', () => { + it('starts empty when constructed without args', () => { + const c = new ServiceCollection(); + expect(c.has(ILogger)).toBe(false); + expect(c.get(ILogger)).toBeUndefined(); + }); + + it('accepts initial pairs in constructor', () => { + const inst = new ConsoleLogger(); + const c = new ServiceCollection([ILogger, inst], [IClock, new SyncDescriptor(FixedClock)]); + expect(c.has(ILogger)).toBe(true); + expect(c.has(IClock)).toBe(true); + expect(c.get(ILogger)).toBe(inst); + const desc = c.get(IClock); + expect(desc).toBeInstanceOf(SyncDescriptor); + }); + + it('set() returns previous value, or undefined when none', () => { + const c = new ServiceCollection(); + const first = new ConsoleLogger(); + const second = new ConsoleLogger(); + expect(c.set(ILogger, first)).toBeUndefined(); + expect(c.set(ILogger, second)).toBe(first); + expect(c.get(ILogger)).toBe(second); + }); + + it('has() reflects set state', () => { + const c = new ServiceCollection(); + expect(c.has(ILogger)).toBe(false); + c.set(ILogger, new ConsoleLogger()); + expect(c.has(ILogger)).toBe(true); + }); + + it('forEach visits every entry exactly once', () => { + const inst = new ConsoleLogger(); + const c = new ServiceCollection([ILogger, inst], [IClock, new SyncDescriptor(FixedClock)]); + const seen: Array<[string, unknown]> = []; + c.forEach((id, value) => { + // P0.3: identifier name is exposed via `toString()` (krow style), no + // longer a `serviceName` property. + seen.push([id.toString(), value]); + }); + expect(seen).toHaveLength(2); + const names = seen.map(([n]) => n).sort(); + expect(names).toEqual(['clock', 'logger']); + }); +}); diff --git a/packages/agent-core/test/di/cyclic.test.ts b/packages/agent-core/test/di/cyclic.test.ts new file mode 100644 index 000000000..909029c1e --- /dev/null +++ b/packages/agent-core/test/di/cyclic.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { CyclicDependencyError } from '#/di/errors'; +import { InstantiationService } from '#/di/instantiationService'; +import { createDecorator, type ServicesAccessor } from '#/di/instantiation'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * Cycle-detection tests trigger cycles by capturing the accessor (or the + * container) inside the ctor body and synchronously calling `.get(peer)` — + * this is the only way to express a circular runtime dependency until + * ctor-arg `@IFoo` decorators land in a later phase. + * + * The accessor used in the ctor must be the same accessor object passed by + * the outer `invokeFunction` so the tree-wide in-progress stack is shared. + */ + +describe('Cyclic dependency detection', () => { + it('direct self-cycle A → A throws CyclicDependencyError', () => { + interface IA { + tag: 'A'; + } + const IA = createDecorator('A'); + // Capture the outer accessor inside the ctor by stashing it on a + // class-static. The ctor calls accessor.get(IA) synchronously. + let accessorRef: ServicesAccessor | undefined; + class A implements IA { + tag = 'A' as const; + constructor() { + accessorRef!.get(IA); + } + } + const ix = new InstantiationService(new ServiceCollection([IA, new SyncDescriptor(A)])); + expect(() => + ix.invokeFunction((a) => { + accessorRef = a; + return a.get(IA); + }), + ).toThrowError(CyclicDependencyError); + }); + + it('indirect cycle A → B → A includes both names in `path` in construction order', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + let accessorRef: ServicesAccessor | undefined; + class A implements IA { + tag = 'A' as const; + constructor() { + accessorRef!.get(IB); + } + } + class B implements IB { + tag = 'B' as const; + constructor() { + accessorRef!.get(IA); + } + } + const ix = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(A)], [IB, new SyncDescriptor(B)]), + ); + + let captured: CyclicDependencyError | undefined; + try { + ix.invokeFunction((a) => { + accessorRef = a; + return a.get(IA); + }); + } catch (e) { + captured = e as CyclicDependencyError; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + expect(captured!.path).toEqual(['A', 'B', 'A']); + expect(captured!.message).toContain('A → B → A'); + }); + + it('no-cycle chain A → B → C constructs cleanly', () => { + interface ITagged { + tag: string; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + const IC = createDecorator('C'); + let accessorRef: ServicesAccessor | undefined; + class C implements ITagged { + tag = 'C'; + } + class B implements ITagged { + tag = 'B'; + constructor() { + accessorRef!.get(IC); + } + } + class A implements ITagged { + tag = 'A'; + constructor() { + accessorRef!.get(IB); + } + } + const ix = new InstantiationService( + new ServiceCollection( + [IA, new SyncDescriptor(A)], + [IB, new SyncDescriptor(B)], + [IC, new SyncDescriptor(C)], + ), + ); + expect(() => + ix.invokeFunction((a) => { + accessorRef = a; + return a.get(IA); + }), + ).not.toThrow(); + }); + + it('stack is unwound after a successful resolution (no false-positive on a later get)', () => { + interface ITagged { + tag: string; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + let accessorRef: ServicesAccessor | undefined; + class A implements ITagged { + tag = 'A'; + } + class B implements ITagged { + tag = 'B'; + constructor() { + // Constructs A — A finishes, then we keep going. + accessorRef!.get(IA); + } + } + const ix = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(A)], [IB, new SyncDescriptor(B)]), + ); + expect(() => + ix.invokeFunction((a) => { + accessorRef = a; + a.get(IB); + // Stack must be empty now; a second resolution must not falsely + // detect "A is in progress". + a.get(IA); + return null; + }), + ).not.toThrow(); + }); + + it('cycle across parent/child boundary is detected (parent has A→B, child has B→A)', () => { + interface IA { + tag: 'A'; + } + interface IB { + tag: 'B'; + } + const IA = createDecorator('A'); + const IB = createDecorator('B'); + let accessorRef: ServicesAccessor | undefined; + + // A is registered in parent; A's ctor depends on B. + class A implements IA { + tag = 'A' as const; + constructor() { + accessorRef!.get(IB); + } + } + // B is registered in CHILD; B's ctor depends on A — completing the cycle + // across the parent boundary. + class B implements IB { + tag = 'B' as const; + constructor() { + accessorRef!.get(IA); + } + } + + const parent = new InstantiationService( + new ServiceCollection([IA, new SyncDescriptor(A)]), + ); + const child = parent.createChild(new ServiceCollection([IB, new SyncDescriptor(B)])); + + let captured: CyclicDependencyError | undefined; + try { + child.invokeFunction((a) => { + accessorRef = a; + return a.get(IA); + }); + } catch (e) { + captured = e as CyclicDependencyError; + } + expect(captured).toBeInstanceOf(CyclicDependencyError); + expect(captured!.path).toEqual(['A', 'B', 'A']); + }); + + it('stack is unwound even when construction throws (next resolution sees a clean stack)', () => { + interface ITagged { + tag: string; + } + const IBoom = createDecorator('Boom'); + const IFine = createDecorator('Fine'); + + class Boom implements ITagged { + tag = 'boom'; + constructor() { + throw new Error('intentional'); + } + } + class Fine implements ITagged { + tag = 'fine'; + } + + const ix = new InstantiationService( + new ServiceCollection([IBoom, new SyncDescriptor(Boom)], [IFine, new SyncDescriptor(Fine)]), + ); + + expect(() => ix.invokeFunction((a) => a.get(IBoom))).toThrowError(/intentional/); + // No false cycle: Boom is fully unwound from the in-progress stack. + expect(() => ix.invokeFunction((a) => a.get(IFine))).not.toThrow(); + }); +}); diff --git a/packages/agent-core/test/di/decorator.test.ts b/packages/agent-core/test/di/decorator.test.ts new file mode 100644 index 000000000..3b0dfd75d --- /dev/null +++ b/packages/agent-core/test/di/decorator.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import { _util, createDecorator } from '#/di/instantiation'; + +/** + * P0.3 updates `createDecorator`: + * - Singleton per name (calling with the same name returns the same ref). + * - Decorator body now stashes `{ id, index }` on the target ctor under + * `$di$dependencies` instead of being a no-op. + * - Throws `@IServiceName-decorator can only be used to decorate a parameter` + * on `arguments.length !== 3`. + */ +describe('createDecorator (P0.3)', () => { + it('singleton per name: two calls with same name return the SAME identifier', () => { + const A = createDecorator<{ x: 1 }>('singleton-test-A'); + const B = createDecorator<{ x: 1 }>('singleton-test-A'); + expect(A).toBe(B); + // Map key identity follows naturally from `===`. + const m = new Map(); + m.set(A, 'first'); + m.set(B, 'second'); + expect(m.size).toBe(1); + expect(m.get(A)).toBe('second'); + }); + + it('different names mint distinct identifiers', () => { + const A = createDecorator<{ x: 1 }>('distinct-A'); + const B = createDecorator<{ x: 1 }>('distinct-B'); + expect(A).not.toBe(B); + }); + + it('toString() returns the diagnostic name', () => { + const ILogger = createDecorator<{ log(m: string): void }>('tostring-logger'); + expect(ILogger.toString()).toBe('tostring-logger'); + expect(String(ILogger)).toBe('tostring-logger'); + }); + + it('applying @IFoo on a single ctor parameter records {id, index: 0}', () => { + const IFoo = createDecorator<{ a: 1 }>('deco-IFoo-single'); + class Target { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(foo: { a: 1 }) {} + } + // Manually apply: the runtime form TS would synthesize is the same — call + // the identifier with `(target, key, index)`. Param-decorator key is + // undefined at runtime; we pass an empty string here (the body only reads + // `arguments.length` + target + index). + (IFoo as unknown as (t: unknown, k: string, i: number) => void)(Target, '', 0); + + const deps = _util.getServiceDependencies(Target as unknown as _util.DI_TARGET_OBJ); + expect(deps).toEqual([{ id: IFoo, index: 0 }]); + }); + + it('two decorators on the same ctor record both with correct indexes', () => { + const IFoo = createDecorator<{ a: 1 }>('deco-IFoo-two'); + const IBar = createDecorator<{ b: 1 }>('deco-IBar-two'); + class Target { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(foo: { a: 1 }, bar: { b: 1 }) {} + } + // TS emits parameter decorators in reverse-parameter order, but the + // public contract is "index reflects parameter position". Apply IBar + // first (index 1), then IFoo (index 0). + (IBar as unknown as (t: unknown, k: string, i: number) => void)(Target, '', 1); + (IFoo as unknown as (t: unknown, k: string, i: number) => void)(Target, '', 0); + + const deps = _util.getServiceDependencies(Target as unknown as _util.DI_TARGET_OBJ); + // Order of insertion follows decorator evaluation order, not parameter + // order; tests should sort by index before asserting. + const sorted = [...deps].sort((a, b) => a.index - b.index); + expect(sorted).toEqual([ + { id: IFoo, index: 0 }, + { id: IBar, index: 1 }, + ]); + }); + + it('subclass does NOT inherit parent ctor metadata (storeServiceDependency reset)', () => { + const IFoo = createDecorator<{ a: 1 }>('deco-IFoo-inherit'); + const IBar = createDecorator<{ b: 1 }>('deco-IBar-inherit'); + class Parent { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(foo: { a: 1 }) {} + } + class Child extends Parent { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(bar: { b: 1 }) { + super({ a: 1 }); + } + } + (IFoo as unknown as (t: unknown, k: string, i: number) => void)(Parent, '', 0); + // Without the own-property reset, Child would see Parent's array via + // prototype lookup and end up sharing the same list. + (IBar as unknown as (t: unknown, k: string, i: number) => void)(Child, '', 0); + + const parentDeps = _util.getServiceDependencies(Parent as unknown as _util.DI_TARGET_OBJ); + const childDeps = _util.getServiceDependencies(Child as unknown as _util.DI_TARGET_OBJ); + expect(parentDeps).toEqual([{ id: IFoo, index: 0 }]); + expect(childDeps).toEqual([{ id: IBar, index: 0 }]); + }); + + it('applying with arguments.length !== 3 throws the parameter-decorator error', () => { + const IFoo = createDecorator<{ a: 1 }>('deco-IFoo-arglen'); + const fn = IFoo as unknown as (...args: unknown[]) => void; + expect(() => fn({})).toThrowError( + /can only be used to decorate a parameter/, + ); + expect(() => fn({}, 'k')).toThrowError( + /can only be used to decorate a parameter/, + ); + // 3 args: still records metadata (smoke). + expect(() => fn(class Ok {}, '', 0)).not.toThrow(); + }); +}); diff --git a/packages/agent-core/test/di/delayed.test.ts b/packages/agent-core/test/di/delayed.test.ts new file mode 100644 index 000000000..71551feb9 --- /dev/null +++ b/packages/agent-core/test/di/delayed.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { InstantiationService } from '#/di/instantiationService'; +import { createDecorator } from '#/di/instantiation'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * P1.2 — `supportsDelayedInstantiation: true` returns a Proxy that defers + * real construction until the first non-event property access. + * + * Vitest runs in Node — `requestIdleCallback` is absent, so the + * `GlobalIdleValue` polyfill uses `setTimeout`. Since our assertions read + * `.value` synchronously via a Proxy `get`, the idle callback is + * pre-empted by the synchronous access (which calls `_handle.dispose()` + * then runs the executor inline). Tests therefore see deterministic + * synchronous behavior. + */ + +describe('Delayed instantiation Proxy (P1.2)', () => { + it('does NOT construct the real instance at container.get(IFoo)', () => { + let ctorCount = 0; + interface IFoo { + kind: 'foo'; + } + class Foo implements IFoo { + kind = 'foo' as const; + constructor() { + ctorCount += 1; + } + } + const IFoo = createDecorator('p1.2-IFoo-noctor'); + const ix = new InstantiationService( + new ServiceCollection([ + IFoo, + new SyncDescriptor(Foo, [], /* supportsDelayedInstantiation */ true), + ]), + ); + // Container resolution must succeed without triggering the ctor. + const proxy = ix.invokeFunction((a) => a.get(IFoo)); + expect(proxy).toBeDefined(); + expect(ctorCount).toBe(0); + }); + + it('reading a non-event property triggers real construction', () => { + let ctorCount = 0; + interface IFoo { + kind: 'foo'; + describe(): string; + } + class Foo implements IFoo { + kind = 'foo' as const; + constructor() { + ctorCount += 1; + } + describe(): string { + return 'real-foo'; + } + } + const IFoo = createDecorator('p1.2-IFoo-trigger'); + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(Foo, [], true)]), + ); + const proxy = ix.invokeFunction((a) => a.get(IFoo)); + expect(ctorCount).toBe(0); + const result = proxy.describe(); + expect(result).toBe('real-foo'); + expect(ctorCount).toBe(1); + }); + + it('`instance instanceof Foo` returns true even before materialisation', () => { + interface IFoo { + kind: 'foo'; + } + class Foo implements IFoo { + kind = 'foo' as const; + } + const IFoo = createDecorator('p1.2-IFoo-instanceof'); + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(Foo, [], true)]), + ); + const proxy = ix.invokeFunction((a) => a.get(IFoo)); + // getPrototypeOf trap returns `Foo.prototype` so `instanceof` works + // without forcing the real ctor. + expect(proxy instanceof Foo).toBe(true); + }); + + it('parked onDid* listeners fire after the proxy materialises', () => { + type Listener = (e: E) => void; + type EventLike = (cb: Listener) => { dispose(): void }; + interface IFoo { + onDidChange: EventLike; + describe(): string; + fire(payload: string): void; + } + class Foo implements IFoo { + private readonly _listeners: Listener[] = []; + readonly onDidChange: EventLike = (cb) => { + this._listeners.push(cb); + return { + dispose: () => { + const idx = this._listeners.indexOf(cb); + if (idx >= 0) this._listeners.splice(idx, 1); + }, + }; + }; + describe(): string { + return 'materialised'; + } + fire(payload: string): void { + for (const cb of [...this._listeners]) cb(payload); + } + } + const IFoo = createDecorator('p1.2-IFoo-events'); + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(Foo, [], true)]), + ); + const proxy = ix.invokeFunction((a) => a.get(IFoo)); + + // Subscribe BEFORE materialisation — listener is parked into the + // earlyListeners LinkedList keyed by 'onDidChange'. + const received: string[] = []; + const sub = proxy.onDidChange((p) => received.push(p)); + expect(typeof sub.dispose).toBe('function'); + + // Trigger materialisation by reading a non-event method, then fire + // the real event — the parked listener was replayed against the real + // event during materialisation, so it must receive the payload. + expect(proxy.describe()).toBe('materialised'); + proxy.fire('hello-world'); + expect(received).toEqual(['hello-world']); + }); +}); diff --git a/packages/agent-core/test/di/descriptor.test.ts b/packages/agent-core/test/di/descriptor.test.ts new file mode 100644 index 000000000..a57aa1b76 --- /dev/null +++ b/packages/agent-core/test/di/descriptor.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { InstantiationType, SyncDescriptor, SyncDescriptor0 } from '#/di/descriptors'; + +class MyClass { + constructor( + public readonly a: string, + public readonly b: number, + ) {} +} + +describe('SyncDescriptor', () => { + it('exposes ctor verbatim', () => { + const d = new SyncDescriptor(MyClass); + expect(d.ctor).toBe(MyClass); + }); + + it('defaults staticArguments to empty array', () => { + const d = new SyncDescriptor(MyClass); + expect(d.staticArguments).toEqual([]); + }); + + it('defaults supportsDelayedInstantiation to false', () => { + const d = new SyncDescriptor(MyClass); + expect(d.supportsDelayedInstantiation).toBe(false); + }); + + it('accepts staticArguments tuple', () => { + const d = new SyncDescriptor(MyClass, ['hello', 42]); + expect(d.staticArguments).toEqual(['hello', 42]); + }); + + it('accepts supportsDelayedInstantiation=true', () => { + const d = new SyncDescriptor(MyClass, [], true); + expect(d.supportsDelayedInstantiation).toBe(true); + }); +}); + +describe('SyncDescriptor0 (P0.4)', () => { + it('is a SyncDescriptor with empty staticArguments', () => { + class Zero { + constructor() {} + } + const d = new SyncDescriptor0(Zero); + expect(d).toBeInstanceOf(SyncDescriptor); + expect(d.ctor).toBe(Zero); + expect(d.staticArguments).toEqual([]); + }); +}); + +describe('InstantiationType', () => { + it('Eager === 0, Delayed === 1', () => { + expect(InstantiationType.Eager).toBe(0); + expect(InstantiationType.Delayed).toBe(1); + }); +}); diff --git a/packages/agent-core/test/di/extensions.test.ts b/packages/agent-core/test/di/extensions.test.ts new file mode 100644 index 000000000..78472b5e2 --- /dev/null +++ b/packages/agent-core/test/di/extensions.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor, InstantiationType } from '#/di/descriptors'; +import { + _clearRegistryForTests, + getSingletonServiceDescriptors, + registerSingleton, +} from '#/di/extensions'; +import { createDecorator } from '#/di/instantiation'; +import { InstantiationService } from '#/di/instantiationService'; +import { ServiceCollection } from '#/di/serviceCollection'; + +describe('registerSingleton / getSingletonServiceDescriptors', () => { + beforeEach(() => { + _clearRegistryForTests(); + }); + afterEach(() => { + _clearRegistryForTests(); + }); + + it('registers a descriptor that the snapshot exposes', () => { + interface ILogger { + log(m: string): void; + } + const ILogger = createDecorator('logger'); + class ConsoleLogger implements ILogger { + log(_m: string): void { + /* noop */ + } + } + registerSingleton(ILogger, ConsoleLogger); + + const snapshot = getSingletonServiceDescriptors(); + expect(snapshot).toHaveLength(1); + const [id, descriptor, type] = snapshot[0]!; + expect(id).toBe(ILogger); + expect(descriptor).toBeInstanceOf(SyncDescriptor); + expect(descriptor.ctor).toBe(ConsoleLogger); + expect(type).toBe(InstantiationType.Eager); + }); + + it('defaults instantiationType to Eager but accepts Delayed', () => { + interface IFoo { + a: number; + } + interface IBar { + b: number; + } + const IFoo = createDecorator('foo'); + const IBar = createDecorator('bar'); + class Foo implements IFoo { + a = 1; + } + class Bar implements IBar { + b = 2; + } + registerSingleton(IFoo, Foo); + registerSingleton(IBar, Bar, InstantiationType.Delayed); + + const map = new Map( + getSingletonServiceDescriptors().map(([id, , t]) => [String(id), t]), + ); + expect(map.get('foo')).toBe(InstantiationType.Eager); + expect(map.get('bar')).toBe(InstantiationType.Delayed); + }); + + it('re-registering the same id throws', () => { + interface ILogger { + log(m: string): void; + } + const ILogger = createDecorator('logger'); + class A implements ILogger { + log(_m: string): void { + /* noop */ + } + } + class B implements ILogger { + log(_m: string): void { + /* noop */ + } + } + registerSingleton(ILogger, A); + expect(() => registerSingleton(ILogger, B)).toThrowError(/already registered/); + }); + + it('_clearRegistryForTests empties the registry', () => { + interface IFoo { + a: number; + } + const IFoo = createDecorator('foo'); + class Foo implements IFoo { + a = 1; + } + registerSingleton(IFoo, Foo); + expect(getSingletonServiceDescriptors()).toHaveLength(1); + _clearRegistryForTests(); + expect(getSingletonServiceDescriptors()).toHaveLength(0); + }); + + it('end-to-end bootstrap (mirrors the README copy-paste example)', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + // 1. Declare a service. + interface ILogger { + log(message: string): void; + } + const ILogger = createDecorator('logger'); + + // 2. Implement it. + class ConsoleLogger implements ILogger { + log(message: string): void { + // eslint-disable-next-line no-console + console.log(`[log] ${message}`); + } + } + + // 3. Register at module load time. + registerSingleton(ILogger, ConsoleLogger); + + // 4. Bootstrap. + const services = new ServiceCollection( + ...getSingletonServiceDescriptors().map( + ([id, descriptor]) => [id, descriptor] as const, + ), + ); + const ix = new InstantiationService(services); + + // 5. Use. + ix.invokeFunction((accessor) => { + const logger = accessor.get(ILogger); + logger.log('hello world'); + }); + expect(logSpy).toHaveBeenCalledWith('[log] hello world'); + + // 6. Scoped child. + interface IRequestContext { + requestId: string; + } + const IRequestContext = createDecorator('requestContext'); + class RequestContext implements IRequestContext { + constructor(public readonly requestId: string) {} + } + const child = ix.createChild( + new ServiceCollection([ + IRequestContext, + new SyncDescriptor(RequestContext, ['req-123']), + ]), + ); + child.invokeFunction((accessor) => { + // Child sees parent services transparently. + accessor + .get(ILogger) + .log(`handling ${accessor.get(IRequestContext).requestId}`); + }); + expect(logSpy).toHaveBeenCalledWith('[log] handling req-123'); + + // 7. Teardown. + ix.dispose(); + expect(() => ix.invokeFunction((_a) => undefined)).toThrowError(/disposed/); + + logSpy.mockRestore(); + }); + + it('snapshot is independent of subsequent registrations (returns a fresh array)', () => { + interface IFoo { + a: number; + } + const IFoo = createDecorator('foo'); + class Foo implements IFoo { + a = 1; + } + registerSingleton(IFoo, Foo); + + const snap1 = getSingletonServiceDescriptors(); + expect(snap1).toHaveLength(1); + + interface IBar { + b: number; + } + const IBar = createDecorator('bar'); + class Bar implements IBar { + b = 2; + } + registerSingleton(IBar, Bar); + + // Prior snapshot must not have been mutated retroactively. + expect(snap1).toHaveLength(1); + + const snap2 = getSingletonServiceDescriptors(); + expect(snap2).toHaveLength(2); + }); +}); diff --git a/packages/agent-core/test/di/graph.test.ts b/packages/agent-core/test/di/graph.test.ts new file mode 100644 index 000000000..b54f5617b --- /dev/null +++ b/packages/agent-core/test/di/graph.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { Graph } from '#/di/graph'; + +/** + * Pure data-structure tests for the vendored `Graph` (no DI container + * involvement). Hash function is identity-on-string so test setup stays + * obvious. + */ +describe('Graph (pure data structure)', () => { + it('chain A → B → C consumes via roots()/removeNode() in C, B, A order', () => { + const g = new Graph((s) => s); + // A depends on B, B depends on C: edges go from depender to dependency. + g.insertEdge('A', 'B'); + g.insertEdge('B', 'C'); + + // C has no outgoing edges — it is the only root initially. + const order: string[] = []; + while (!g.isEmpty()) { + const roots = g.roots(); + expect(roots.length).toBeGreaterThan(0); + for (const root of roots) { + order.push(root.data); + g.removeNode(root.data); + } + } + expect(order).toEqual(['C', 'B', 'A']); + }); + + it('cycle A → B → A: findCycleSlow returns path containing "A -> B -> A"', () => { + const g = new Graph((s) => s); + g.insertEdge('A', 'B'); + g.insertEdge('B', 'A'); + const cycle = g.findCycleSlow(); + expect(cycle).toBeDefined(); + expect(cycle).toContain('A -> B -> A'); + }); +}); diff --git a/packages/agent-core/test/di/instantiation.test.ts b/packages/agent-core/test/di/instantiation.test.ts new file mode 100644 index 000000000..472033889 --- /dev/null +++ b/packages/agent-core/test/di/instantiation.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/di/descriptors'; +import { InstantiationService } from '#/di/instantiationService'; +import { createDecorator } from '#/di/instantiation'; +import { ServiceCollection } from '#/di/serviceCollection'; + +interface ILogger { + log(msg: string): void; +} +const ILogger = createDecorator('logger'); + +describe('InstantiationService (basic)', () => { + it('constructs an impl from SyncDescriptor on first get', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + class ConsoleLogger implements ILogger { + log(m: string): void { + // eslint-disable-next-line no-console + console.log(m); + } + } + const ix = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + ix.invokeFunction((a) => a.get(ILogger).log('hi')); + expect(logSpy).toHaveBeenCalledWith('hi'); + logSpy.mockRestore(); + }); + + it('returns the same cached instance across multiple invokeFunction calls', () => { + class ConsoleLogger implements ILogger { + log(_m: string): void { + /* noop */ + } + } + const ix = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + const first = ix.invokeFunction((a) => a.get(ILogger)); + const second = ix.invokeFunction((a) => a.get(ILogger)); + expect(first).toBe(second); + }); + + it('returns the same cached instance within a single invokeFunction call', () => { + class ConsoleLogger implements ILogger { + log(_m: string): void { + /* noop */ + } + } + const ix = new InstantiationService( + new ServiceCollection([ILogger, new SyncDescriptor(ConsoleLogger)]), + ); + let inner: ILogger | undefined; + const outer = ix.invokeFunction((a) => { + const first = a.get(ILogger); + inner = a.get(ILogger); + return first; + }); + expect(outer).toBe(inner); + }); + + it('createInstance() constructs a raw ctor with literal args (no DI)', () => { + class MyClass { + constructor( + public readonly a: string, + public readonly b: number, + ) {} + } + const ix = new InstantiationService(); + const inst = ix.createInstance(MyClass, 'x', 7); + expect(inst).toBeInstanceOf(MyClass); + expect(inst.a).toBe('x'); + expect(inst.b).toBe(7); + }); + + it('createInstance(descriptor) unpacks ctor + staticArguments (P0.4)', () => { + class Foo { + constructor( + public readonly a: string, + public readonly b: string, + ) {} + } + const ix = new InstantiationService(); + const inst = ix.createInstance(new SyncDescriptor(Foo, ['a', 'b'])); + expect(inst).toBeInstanceOf(Foo); + expect(inst.a).toBe('a'); + expect(inst.b).toBe('b'); + }); + + it('createInstance(descriptor, ...rest) concatenates static prefix + rest (P0.4)', () => { + class Foo { + constructor( + public readonly a: string, + public readonly b: string, + ) {} + } + const ix = new InstantiationService(); + // staticArguments=['a'], rest=['b'] → new Foo('a', 'b') + const inst = ix.createInstance(new SyncDescriptor(Foo, ['a']), 'b'); + expect(inst.a).toBe('a'); + expect(inst.b).toBe('b'); + }); + + it('eagerly constructs on first get (ctor side-effect runs during get)', () => { + let ctorCount = 0; + class CountingService { + constructor() { + ctorCount++; + } + } + const IFoo = createDecorator('foo'); + const ix = new InstantiationService( + new ServiceCollection([IFoo, new SyncDescriptor(CountingService)]), + ); + // Not constructed at container creation time. + expect(ctorCount).toBe(0); + ix.invokeFunction((a) => a.get(IFoo)); + expect(ctorCount).toBe(1); + // Second get: cached, ctor NOT re-run. + ix.invokeFunction((a) => a.get(IFoo)); + expect(ctorCount).toBe(1); + }); + + it('honours SyncDescriptor.staticArguments when constructing', () => { + class Greeter { + constructor(public readonly prefix: string) {} + greet(name: string): string { + return `${this.prefix} ${name}`; + } + } + const IGreeter = createDecorator('greeter'); + const ix = new InstantiationService( + new ServiceCollection([IGreeter, new SyncDescriptor(Greeter, ['hello'])]), + ); + expect(ix.invokeFunction((a) => a.get(IGreeter).greet('world'))).toBe('hello world'); + }); + + it('accepts a pre-built instance shorthand from ServiceCollection', () => { + class ConsoleLogger implements ILogger { + log(_m: string): void { + /* noop */ + } + } + const inst = new ConsoleLogger(); + const ix = new InstantiationService(new ServiceCollection([ILogger, inst])); + expect(ix.invokeFunction((a) => a.get(ILogger))).toBe(inst); + }); + + it('throws when getting an unregistered id', () => { + const ix = new InstantiationService(); + expect(() => ix.invokeFunction((a) => a.get(ILogger))).toThrowError(/No service registered/); + }); + + it('createChild returns a child container, dispose tears down', () => { + // Detailed createChild + dispose semantics live in `child.test.ts`; this + // is just a smoke test that the W2.3 wiring is in place. + const ix = new InstantiationService(); + const child = ix.createChild(new ServiceCollection()); + expect(child).toBeDefined(); + expect(() => ix.dispose()).not.toThrow(); + }); +}); diff --git a/packages/agent-core/test/di/self-register.test.ts b/packages/agent-core/test/di/self-register.test.ts new file mode 100644 index 000000000..bb5208dd0 --- /dev/null +++ b/packages/agent-core/test/di/self-register.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { IInstantiationService } from '#/di/instantiation'; +import { InstantiationService } from '#/di/instantiationService'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * P0.5 — the container self-registers under `IInstantiationService`. Any + * `accessor.get(IInstantiationService)` returns the OWNING container — + * root for invocations made on the root, child for invocations made on a + * child — enabling factory and per-request-scope patterns without callers + * having to thread the container through manually. + */ +describe('IInstantiationService self-registration (P0.5)', () => { + it('root container exposes itself via accessor.get(IInstantiationService)', () => { + const ix = new InstantiationService(); + const resolved = ix.invokeFunction((a) => a.get(IInstantiationService)); + expect(resolved).toBe(ix); + }); + + it('child container resolves to ITSELF, not the parent', () => { + const parent = new InstantiationService(); + const child = parent.createChild(new ServiceCollection()); + const resolvedChild = child.invokeFunction((a) => a.get(IInstantiationService)); + const resolvedParent = parent.invokeFunction((a) => a.get(IInstantiationService)); + expect(resolvedChild).toBe(child); + expect(resolvedParent).toBe(parent); + expect(resolvedChild).not.toBe(resolvedParent); + }); + + it('multiple roots resolve to distinct instances', () => { + const a = new InstantiationService(); + const b = new InstantiationService(); + expect(a.invokeFunction((acc) => acc.get(IInstantiationService))).toBe(a); + expect(b.invokeFunction((acc) => acc.get(IInstantiationService))).toBe(b); + }); +}); diff --git a/packages/agent-core/test/di/test-instantiation.test.ts b/packages/agent-core/test/di/test-instantiation.test.ts new file mode 100644 index 000000000..38cf59c0f --- /dev/null +++ b/packages/agent-core/test/di/test-instantiation.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import * as mainBarrel from '#/di/index'; +import { TestInstantiationService } from '#/di/test'; +import { createDecorator } from '#/di/instantiation'; +import { SyncDescriptor } from '#/di/descriptors'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * P1.3 — `TestInstantiationService` exposed via `@moonshot-ai/agent-core/di/test` + * subpath only. The main barrel `@moonshot-ai/agent-core` (which re-exports + * from `agent-core/src/di/index.ts`) MUST NOT carry `TestInstantiationService` + * — test-time code stays out of production bundles. + * + * The local subpath alias `#/di/test` resolves to the same barrel + * (`src/di/test.ts`) the external consumer sees via the `package.json` + * exports map. + */ + +interface ILogger { + log(msg: string): void; +} +const ILogger = createDecorator('p1.3-ILogger'); + +describe('TestInstantiationService (P1.3)', () => { + it('`.stub(id, impl)` registers a pre-built instance and `.get(id)` returns it', () => { + const ix = new TestInstantiationService(); + const stub: ILogger = { log: vi.fn() }; + ix.stub(ILogger, stub); + const resolved = ix.get(ILogger); + expect(resolved).toBe(stub); + }); + + it('a class constructed via `.createInstance` receives the stubbed dependency', () => { + const ix = new TestInstantiationService(); + const log = vi.fn(); + const stub: ILogger = { log }; + ix.stub(ILogger, stub); + + class Greeter { + constructor( + public readonly prefix: string, + public readonly logger: ILogger, + ) {} + greet(name: string): string { + const msg = `${this.prefix} ${name}`; + this.logger.log(msg); + return msg; + } + } + // Apply the parameter decorator manually (vitest's rolldown transform + // does not parse TS parameter decorators in test files). + (ILogger as unknown as (t: unknown, k: string, i: number) => void)( + Greeter, + '', + 1, + ); + + const g = ix.createInstance(Greeter as new (prefix: string) => Greeter, 'hello'); + expect(g.greet('world')).toBe('hello world'); + expect(log).toHaveBeenCalledWith('hello world'); + }); + + it('`.set(id, descriptor)` accepts a SyncDescriptor and lazily constructs', () => { + let ctorCount = 0; + class DescLogger implements ILogger { + constructor() { + ctorCount += 1; + } + log(_m: string): void { + /* noop */ + } + } + const ix = new TestInstantiationService(); + ix.set(ILogger, new SyncDescriptor(DescLogger)); + expect(ctorCount).toBe(0); + const a = ix.get(ILogger); + const b = ix.get(ILogger); + expect(a).toBe(b); + expect(ctorCount).toBe(1); + }); + + it('main barrel `#/di/index` does NOT re-export `TestInstantiationService`', () => { + // Subpath-only export contract: production code that imports the + // package entry should not accidentally pull test scaffolding. + expect((mainBarrel as Record).TestInstantiationService).toBeUndefined(); + }); + + it('`createChild` returns a `TestInstantiationService` (narrowed from base)', () => { + const parent = new TestInstantiationService(); + const sharedStub: ILogger = { log: vi.fn() }; + parent.stub(ILogger, sharedStub); + const child = parent.createChild(new ServiceCollection()); + expect(child).toBeInstanceOf(TestInstantiationService); + // Child inherits the parent's stub. + expect(child.get(ILogger)).toBe(sharedStub); + }); +}); diff --git a/packages/agent-core/test/di/trace.test.ts b/packages/agent-core/test/di/trace.test.ts new file mode 100644 index 000000000..8804616e1 --- /dev/null +++ b/packages/agent-core/test/di/trace.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { InstantiationService, Trace } from '#/di/instantiationService'; +import { ServiceCollection } from '#/di/serviceCollection'; + +/** + * P0.2: `Trace` class + `_enableTracing` ctor param installed but not yet + * consumed by any code path inside `InstantiationService`. These assertions + * only verify the class is reachable and the ctor signature is backward + * compatible (third param defaults to `false`). + */ + +class ExposedInstantiationService extends InstantiationService { + get tracingEnabled(): boolean { + return this._enableTracing; + } +} + +describe('InstantiationService Trace installation (P0.2)', () => { + it('constructs with the 2-arg signature (backward compat)', () => { + const coll = new ServiceCollection(); + const ix = new InstantiationService(coll); + expect(ix).toBeInstanceOf(InstantiationService); + }); + + it('constructs with explicit null parent and accepts the 3rd tracing arg = true', () => { + const coll = new ServiceCollection(); + // `parent: null` mirrors the no-parent case; `_enableTracing: true` opts in. + const ix = new ExposedInstantiationService(coll, null, true); + expect(ix).toBeInstanceOf(InstantiationService); + expect(ix.tracingEnabled).toBe(true); + }); + + it('defaults _enableTracing to false when omitted', () => { + const ix = new ExposedInstantiationService(new ServiceCollection()); + expect(ix.tracingEnabled).toBe(false); + }); + + it('Trace.traceCreation with _enableTracing=false returns the noop sentinel (Trace._None)', () => { + // The sentinel has a no-op stop()/branch() — calling either must not throw. + const t1 = Trace.traceCreation(false, class Foo {}); + expect(() => t1.stop()).not.toThrow(); + // Two non-tracing calls return identical sentinel; can't easily reach the + // private static field, but exercising both noop methods is enough. + const t2 = Trace.traceInvocation(false, function example() {}); + expect(() => t2.stop()).not.toThrow(); + }); + + it('Trace.traceCreation with _enableTracing=true returns a real Trace instance', () => { + const t = Trace.traceCreation(true, class Foo {}); + expect(t).toBeInstanceOf(Trace); + // stop() should not throw on a real Trace either. + expect(() => t.stop()).not.toThrow(); + }); +}); diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 000000000..aa55bc5df --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1,52 @@ +{ + "name": "@moonshot-ai/daemon", + "version": "0.1.0", + "private": true, + "description": "Local REST + WebSocket daemon exposing kimi-code SDK over a stable wire protocol.", + "license": "MIT", + "author": "Moonshot AI", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/daemon" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@fastify/multipart": "^10.0.0", + "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", + "@moonshot-ai/services": "workspace:^", + "chokidar": "^4.0.3", + "fastify": "^5.1.0", + "ignore": "^5.3.2", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0", + "ulid": "^3.0.1", + "ws": "^8.18.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/ws": "^8.18.0" + } +} diff --git a/packages/daemon/src/envelope.ts b/packages/daemon/src/envelope.ts new file mode 100644 index 000000000..3e591d620 --- /dev/null +++ b/packages/daemon/src/envelope.ts @@ -0,0 +1,14 @@ +/** + * Re-export the envelope helpers from `@moonshot-ai/protocol`. + * + * W4.3 (P0.13) consolidates the wire-shape source-of-truth in protocol. The + * daemon previously hand-rolled `okEnvelope` / `errEnvelope` / `Envelope` + * with byte-identical output to protocol's helpers (verified by W1 reviewer + * against packages/protocol/src/__tests__/envelope.test.ts); flipping the + * import preserves field order and JSON output exactly. + * + * Keep this file as a re-export shim (not a direct re-export from the package + * barrel) so downstream `from './envelope'` imports inside the daemon stay + * stable and don't all need to be touched. + */ +export { okEnvelope, errEnvelope, type Envelope } from '@moonshot-ai/protocol'; diff --git a/packages/daemon/src/error-handler.ts b/packages/daemon/src/error-handler.ts new file mode 100644 index 000000000..9fbd5dfad --- /dev/null +++ b/packages/daemon/src/error-handler.ts @@ -0,0 +1,53 @@ +/** + * Fastify error hook (W4.3 / P0.13). + * + * Wraps unhandled exceptions in the Feishu-style envelope per PLAN §P1: + * - HTTP status ALWAYS 200 (business outcome lives in `code`); + * - `code: 50001` (`internal.error`) for unknown exceptions; + * - `request_id` echoes the inbound request id (set by Fastify's + * `genReqId` via `resolveRequestId`); + * - `data: null`. + * + * Formal validation-error mapping (Fastify-AJV → 40001 `validation.failed`) + * lands in W7 alongside the route-schema middleware; W4's handler is the + * catch-all unknown-exception path. + * + * The handler logs `err` + the resolved `request_id` so operators can + * correlate log lines with the envelope returned to the client. This is the + * single place a stack trace ever crosses our process boundary into a log — + * we never bleed it into the JSON response. + */ + +import { errEnvelope, ErrorCode } from '@moonshot-ai/protocol'; +import type { FastifyError } from 'fastify'; + +/** + * Loose Fastify-instance shape so this helper accepts both the default + * `FastifyInstance` and the daemon's pino-typed variant + * (`FastifyInstance<…, DaemonLogger>`). The type checker chokes on the + * concrete generic mismatch otherwise. + */ +interface ErrorHandlerHost { + setErrorHandler( + handler: ( + err: FastifyError, + req: { id: string; log: { error: (obj: object | string, msg?: string) => void } }, + reply: { status(code: number): { send(payload: unknown): void } }, + ) => void, + ): unknown; +} + +export function installErrorHandler(app: ErrorHandlerHost): void { + app.setErrorHandler((err, req, reply) => { + const requestId = req.id; + req.log.error({ err, request_id: requestId }, 'unhandled error'); + reply.status(200).send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err.message !== undefined && err.message !== '' ? err.message : 'internal error', + requestId, + ), + ); + }); +} + diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts new file mode 100644 index 000000000..17eed9138 --- /dev/null +++ b/packages/daemon/src/index.ts @@ -0,0 +1,29 @@ +export { startDaemon, DaemonLockedError } from './start.js'; +export type { DaemonStartOptions, RunningDaemon } from './start.js'; +export { okEnvelope, errEnvelope } from './envelope.js'; +export type { Envelope } from './envelope.js'; +export { createDaemonLogger } from './logger.js'; +export type { CreateLoggerOptions, DaemonLogger, DaemonLogLevel } from './logger.js'; +export { acquireLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock.js'; +export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock.js'; + +// DI service decorators — re-exported so consumers / tests can `a.get(ILogger)` etc. +// The concrete impls (PinoLogger, FastifyRestGateway, DaemonEventBus, broker stubs, +// ConnectionRegistry, SessionClientsService, WSGateway) stay internal — daemon +// owns its wiring choices; external consumers see only the interfaces. +export { ILogger } from './services/logger.js'; +export { IRestGateway } from './services/rest-gateway.js'; +export { IConnectionRegistry } from './services/connection-registry.js'; +export { ISessionClientsService } from './services/session-clients.js'; +export { IWSGateway } from './services/ws-gateway.js'; +// Re-export the broker decorators + HarnessBridge handle from `@moonshot-ai/services` +// so daemon consumers don't have to take a direct dep on the services package +// just to reach into the container. +export { + IEventBus, + IApprovalBroker, + IQuestionBroker, + IHarnessBridge, + ISessionService, + SessionNotFoundError, +} from '@moonshot-ai/services'; diff --git a/packages/daemon/src/lock.ts b/packages/daemon/src/lock.ts new file mode 100644 index 000000000..915cc26e3 --- /dev/null +++ b/packages/daemon/src/lock.ts @@ -0,0 +1,221 @@ +/** + * Filesystem lock for single-instance daemon enforcement (ROADMAP P0.12). + * + * The lock is a small JSON file at `~/.kimi/daemon/lock` (overridable for + * tests). It records the live daemon's `pid`, `started_at`, and `port`. + * Acquisition is exclusive (`O_WRONLY | O_CREAT | O_EXCL`) — racing daemons + * can't both win. + * + * Stale lock takeover: when a lock file exists, we ping the recorded pid via + * `process.kill(pid, 0)`. Node's `kill` does NOT send a signal when sig is 0 — + * it only probes existence (man kill(2)). If the probe throws `ESRCH` the + * process is gone and we take over by `unlink` + retry. If the probe succeeds + * (or throws `EPERM`, meaning the process exists but is owned by another user), + * we throw `EDAEMON_LOCKED` so the caller surfaces the conflict to stderr. + * + * Race vs. takeover: the stale-check sees a dead pid, then unlinks, then + * re-acquires with `O_EXCL`. If a third party slipped in between unlink and + * re-create, `O_EXCL` returns `EEXIST`, which we propagate (don't loop) — the + * operator should see the conflict, not silently overwrite. + * + * Release is best-effort: if the file is missing or its `pid` no longer + * matches ours, we log and continue rather than throw. Crashed daemons may + * leave the file dangling; the next start's stale-check cleans it up. + */ + +import { + closeSync, + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, + openSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const DEFAULT_LOCK_DIR = join(homedir(), '.kimi', 'daemon'); +export const DEFAULT_LOCK_PATH = join(DEFAULT_LOCK_DIR, 'lock'); + +/** JSON shape stored in the lock file. snake_case to match operator-facing logs. */ +export interface LockContents { + pid: number; + started_at: string; + port: number; +} + +export interface AcquireLockOptions { + /** Override default `~/.kimi/daemon/lock` — used in tests. */ + lockPath?: string; + /** Port the daemon will bind to. Recorded in the lock file for diagnostics. */ + port: number; + /** Override `new Date().toISOString()` — used in tests for deterministic output. */ + nowIso?: string; + /** + * Override `process.pid` — used in tests where we want to simulate a + * different daemon owning the lock. Production callers should not set this. + */ + pid?: number; +} + +export interface AcquireLockResult { + /** Idempotent release: safe to call multiple times; best-effort on missing/mismatched lock. */ + release(): void; + /** Absolute path of the lock file that was acquired. */ + lockPath: string; +} + +/** Error thrown when another daemon is already holding the lock. */ +export class DaemonLockedError extends Error { + override readonly name = 'DaemonLockedError'; + readonly code = 'EDAEMON_LOCKED' as const; + /** + * Process exit code preferred by CLI consumers. ROADMAP §P0.12 AC mandates + * `2` (distinct from generic failure `1`) so operators can scriptly distinguish + * "another daemon is running" from "daemon crashed". Commander reads this if + * present; library callers can ignore it. + */ + readonly exitCode = 2 as const; + readonly existing: LockContents; + constructor(message: string, existing: LockContents) { + super(message); + this.existing = existing; + } +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it (different user). Treat as alive. + if (code === 'EPERM') return true; + // Anything else: be safe, assume alive so we don't clobber. + return true; + } +} + +/** Read + JSON.parse the lock file; returns undefined on any error so callers can fall through. */ +function readLockContents(path: string): LockContents | undefined { + try { + const raw = readFileSync(path, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if ( + typeof parsed === 'object' && + parsed !== null && + typeof (parsed as LockContents).pid === 'number' && + typeof (parsed as LockContents).started_at === 'string' && + typeof (parsed as LockContents).port === 'number' + ) { + return parsed as LockContents; + } + return undefined; + } catch { + return undefined; + } +} + +/** + * Try `O_WRONLY | O_CREAT | O_EXCL` to create the lock file with the contents. + * Returns true on success, false on EEXIST. Throws on any other fs error. + */ +function tryExclusiveCreate(path: string, contents: LockContents): boolean { + let fd: number | undefined; + try { + // 0o100 (O_CREAT) | 0o200 (O_EXCL) | 0o2 (O_RDWR) — but `openSync` accepts the + // string flag form which is portable. + fd = openSync(path, 'wx'); + writeFileSync(fd, JSON.stringify(contents)); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw err; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // already closed by writeFileSync in some Node versions — ignore. + } + } + } +} + +/** + * Acquire an exclusive lock for this daemon instance. Throws `DaemonLockedError` + * if another live daemon holds the lock; silently takes over a stale lock whose + * recorded pid is no longer running. + */ +export function acquireLock(opts: AcquireLockOptions): AcquireLockResult { + const lockPath = opts.lockPath ?? DEFAULT_LOCK_PATH; + const pid = opts.pid ?? process.pid; + const startedAt = opts.nowIso ?? new Date().toISOString(); + const contents: LockContents = { pid, started_at: startedAt, port: opts.port }; + + mkdirSync(dirname(lockPath), { recursive: true }); + + // First try: clean acquire. + if (tryExclusiveCreate(lockPath, contents)) { + return makeReleaseHandle(lockPath, pid); + } + + // Lock exists — inspect. + const existing = readLockContents(lockPath); + if (existing && pidAlive(existing.pid)) { + // Live owner — refuse to take over. Note that "same pid as ours" still + // counts as live: callers that genuinely want to swap should release the + // existing handle first, not stomp via acquireLock. + throw new DaemonLockedError( + `daemon already running (pid=${existing.pid}, port=${existing.port}, started=${existing.started_at})`, + existing, + ); + } + + // Stale (dead pid) or unparseable — take over. + try { + unlinkSync(lockPath); + } catch (err) { + // EBUSY/ENOENT both acceptable — race with another concurrent acquirer. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (!tryExclusiveCreate(lockPath, contents)) { + // Someone slipped in. Re-read for diagnostic. + const winner = readLockContents(lockPath); + throw new DaemonLockedError( + winner + ? `daemon already running (pid=${winner.pid}, port=${winner.port}, started=${winner.started_at})` + : 'lock file present but unreadable', + winner ?? { pid: -1, started_at: '', port: opts.port }, + ); + } + return makeReleaseHandle(lockPath, pid); +} + +function makeReleaseHandle(lockPath: string, ownerPid: number): AcquireLockResult { + let released = false; + return { + lockPath, + release(): void { + if (released) return; + released = true; + if (!existsSync(lockPath)) return; + const contents = readLockContents(lockPath); + if (contents && contents.pid !== ownerPid) { + // Someone else owns the lock now — don't touch it. + return; + } + try { + unlinkSync(lockPath); + } catch { + // Best-effort: file may have vanished between existsSync and unlinkSync. + } + }, + }; +} diff --git a/packages/daemon/src/logger.ts b/packages/daemon/src/logger.ts new file mode 100644 index 000000000..fc10d59fd --- /dev/null +++ b/packages/daemon/src/logger.ts @@ -0,0 +1,37 @@ +import { pino, type Logger, type LoggerOptions } from 'pino'; + +export type DaemonLogger = Logger; + +export type DaemonLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; + +export interface CreateLoggerOptions { + level: DaemonLogLevel; + /** + * Force pretty printing on/off. Defaults to TTY detection on stdout. + * Useful for tests that want deterministic JSON output. + */ + pretty?: boolean; +} + +export function createDaemonLogger(opts: CreateLoggerOptions): DaemonLogger { + const pretty = opts.pretty ?? process.stdout.isTTY === true; + const base: LoggerOptions = { + level: opts.level, + base: { name: 'kimi-daemon' }, + }; + if (pretty) { + return pino({ + ...base, + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss.l', + ignore: 'pid,hostname', + singleLine: false, + }, + }, + }); + } + return pino(base); +} diff --git a/packages/daemon/src/middleware/validate.ts b/packages/daemon/src/middleware/validate.ts new file mode 100644 index 000000000..59c467baa --- /dev/null +++ b/packages/daemon/src/middleware/validate.ts @@ -0,0 +1,133 @@ +/** + * Generic Zod body / query / params validators for Fastify routes (Chain 2). + * + * On validation success: the parsed value replaces the raw input on the + * request object so handlers operate on a `T`-typed payload (no need to + * re-parse). On failure: we send a `40001 validation.failed` envelope with a + * `details` array of `{path, message}` issues (REST.md §1.4) and return early + * — the handler does not run. + * + * The envelope shape matches `errEnvelope(40001, ...)` with the extra + * `details` field at top level. REST.md §1.4 specifies `details` as a free + * field per code; for `40001` clients expect `Array<{path, message}>` so they + * can highlight the offending field. We extend `errEnvelope`'s output rather + * than reshape it — this is the single error code that carries structured + * details so a one-off shape is acceptable. + * + * Note: this is the FIRST 40001 producer in the daemon. The W4 + * `installErrorHandler` only emits 50001; route-level Zod failures land + * here at the preHandler stage and never reach the error hook. + */ + +import { ErrorCode } from '@moonshot-ai/protocol'; +import type { z } from 'zod'; + +/** + * Minimal Fastify request/reply shapes — keep our hook independent of the + * concrete generic parameters so it works against the daemon's pino-typed + * variant without TS friction. + */ +interface ValidationRequest { + id: string; + body?: unknown; + query?: unknown; + params?: unknown; +} + +interface ValidationReply { + send(payload: unknown): unknown; +} + +type PreHandlerHook = ( + req: ValidationRequest, + reply: ValidationReply, + done: (err?: Error) => void, +) => void; + +interface ValidationDetailItem { + path: string; + message: string; +} + +function zodIssuesToDetails(error: z.ZodError): ValidationDetailItem[] { + return error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })); +} + +function buildValidationEnvelope( + details: ValidationDetailItem[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: ValidationDetailItem[]; +} { + const first = details[0]; + const msg = first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + return { + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: requestId, + details, + }; +} + +/** + * Build a Fastify `preHandler` that parses `req.body` against `schema`. + * On success, replaces `req.body` with the parsed value. + */ +export function validateBody(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.body); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.body = result.data; + done(); + }; +} + +/** + * Build a Fastify `preHandler` that parses `req.query` against `schema`. + * On success, replaces `req.query` with the parsed value. + * + * Fastify deserializes query strings as `Record` — so numeric + * fields arrive as strings. The schema is responsible for coercing + * (`z.coerce.number()` etc.) when needed; we don't pre-coerce here. + */ +export function validateQuery(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.query); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.query = result.data; + done(); + }; +} + +/** + * Build a Fastify `preHandler` that parses `req.params` against `schema`. + */ +export function validateParams(schema: z.ZodType): PreHandlerHook { + return (req, reply, done) => { + const result = schema.safeParse(req.params); + if (!result.success) { + reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id)); + return; + } + req.params = result.data; + done(); + }; +} diff --git a/packages/daemon/src/request-id.ts b/packages/daemon/src/request-id.ts new file mode 100644 index 000000000..d3ffc2c1c --- /dev/null +++ b/packages/daemon/src/request-id.ts @@ -0,0 +1,31 @@ +/** + * `request_id` resolution at the daemon's REST boundary (W4.3 / P0.13). + * + * Delegates to `parseOrGenerateRequestId` from `@moonshot-ai/protocol`, which: + * - returns a bare 26-char ULID per PLAN §P7 (no `req_` prefix); + * - validates client-supplied `X-Request-Id` is a real ULID and regenerates + * a fresh one on malformed input (log hygiene + DoS surface — operator + * log files would otherwise carry attacker-controlled strings verbatim). + * + * Wire format change vs. the pre-W4 walking-skeleton daemon: + * - OLD: `req_${ulid()}` minted; client-supplied header echoed verbatim + * regardless of format. + * - NEW: bare ULID minted; client-supplied header echoed ONLY if it + * passes `ulid.isValid`. + * + * Existing clients that relied on the `req_…` echo will see a freshly-minted + * bare ULID instead. This is the W1 reviewer's recommendation and is + * documented in W4 STATUS §Decisions. + */ + +import { parseOrGenerateRequestId } from '@moonshot-ai/protocol'; + +const REQUEST_ID_HEADER = 'x-request-id'; + +export function resolveRequestId( + headers: Record, +): string { + const raw = headers[REQUEST_ID_HEADER]; + const supplied = Array.isArray(raw) ? raw[0] : raw; + return parseOrGenerateRequestId(supplied); +} diff --git a/packages/daemon/src/routes/action-suffix.ts b/packages/daemon/src/routes/action-suffix.ts new file mode 100644 index 000000000..a5f127208 --- /dev/null +++ b/packages/daemon/src/routes/action-suffix.ts @@ -0,0 +1,91 @@ +/** + * `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. + * + * History: + * - W7.2 prompts route added the `:abort` suffix inline. + * - W8.2 questions route reused the same `lastIndexOf(':')` snippet for + * `:dismiss`. + * - W9.1 mcp restart is the 4th call site (`:restart`); W8 handoff flagged + * extraction. This module is the result. + * + * 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 _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. + */ + +export type ActionSuffixParse = + | { readonly kind: 'bare'; readonly id: string } + | { readonly kind: 'action'; readonly id: string; readonly action: TAction } + | { readonly kind: 'invalid'; readonly reason: string }; + +export interface ParseActionSuffixOptions { + readonly tail: string; + readonly allowedActions: readonly TAction[]; + /** + * When set, a bare `` (no action suffix) is accepted and reported as + * `{kind:'bare'}`. When `undefined`, bare ids are rejected with + * `unsupported action: ` — appropriate for resources where every + * REST action is an explicit `:verb` (e.g. `/v1/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( + opts: ParseActionSuffixOptions, +): ActionSuffixParse { + 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 }; +} diff --git a/packages/daemon/src/routes/approvals.ts b/packages/daemon/src/routes/approvals.ts new file mode 100644 index 000000000..2520a850e --- /dev/null +++ b/packages/daemon/src/routes/approvals.ts @@ -0,0 +1,137 @@ +/** + * `/v1/sessions/{sid}/approvals/{aid}` REST route (Chain 5 / P1.5, W8.1). + * + * 1 endpoint (REST.md §3.6): + * + * POST /v1/sessions/{sid}/approvals/{aid} body: ApprovalResponse + * data: { resolved: true, resolved_at } + * + * Error mapping (REST.md §3.6): + * - 40404 (approval.not_found) — no pending approval matches {aid} + * - 40902 (approval.already_resolved) — second resolve; custom envelope + * `{code:40902, data:{resolved:false}}` + * per W7's 40903-pattern + * - 40001 (validation.failed) — bad body via the Zod preHandler + * + * **Mechanism**: idempotency is handled by the broker's `isPending()` gate + * BEFORE calling `resolve()`. The broker drops the entry on resolve, so a + * second call sees `!isPending` → we emit `40902`. (REST.md §3.6 says + * `details.resolved_by` should carry the client_id; today we don't track + * who answered first, so `details` stays absent — fully spec-compliant + * but conservative.) + * + * **Anti-corruption**: route resolves `IApprovalBroker` via the accessor — + * no SDK imports. + */ + +import { + approvalResolveRequestSchema, + ErrorCode, + type ApprovalResolveRequest, + type ApprovalResolveResult, +} from '@moonshot-ai/protocol'; +import { + IApprovalBroker, + approvalToAgentCoreResponse, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateBody, validateParams } from '../middleware/validate.js'; +import { + DaemonApprovalBroker, +} from '../services/approval-broker.js'; + +interface ApprovalRouteHost { + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const approvalParamsSchema = z.object({ + session_id: z.string().min(1), + approval_id: z.string().min(1), +}); + +export function registerApprovalsRoutes( + app: ApprovalRouteHost, + ix: IInstantiationService, +): void { + app.post( + '/v1/sessions/:session_id/approvals/:approval_id', + { + preHandler: [ + validateParams(approvalParamsSchema), + validateBody(approvalResolveRequestSchema), + ], + }, + async (req, reply) => { + try { + const { approval_id } = req.params as { session_id: string; approval_id: string }; + const body = req.body as ApprovalResolveRequest; + + // Pre-check pending state. Two failure modes: + // - never-existed → 40404 (approval.not_found) + // - was-pending-and-resolved → 40902 (approval.already_resolved) + // The broker doesn't distinguish — the route does, using + // `isPending()`. We can't tell "never-existed" from "already-resolved" + // without history, so we conservatively emit 40404 (more accurate + // signal that the id is invalid; 40902 would be misleading for a + // typo'd id). Production-grade tracking of resolved ids could move + // the discrimination into the broker; out of W8 scope. + const broker = ix.invokeFunction((a) => + a.get(IApprovalBroker) as DaemonApprovalBroker, + ); + if (!broker.isPending(approval_id)) { + // 40404 path covers BOTH "never-existed" and "already-resolved" in + // this iteration. REST.md §3.6 lists 40902 for "已应答 + 抢答场景" — + // for that we'd need a resolved-ids ledger; deferred until a real + // multi-client client_id arrives in Phase 2. To still honor the + // 40902 contract for re-POST cases, broker tracks recently-resolved + // ids: see `isRecentlyResolved`. + if (broker.isRecentlyResolved(approval_id)) { + reply.send({ + code: ErrorCode.APPROVAL_ALREADY_RESOLVED, + msg: `approval ${approval_id} already resolved`, + data: { resolved: false }, + request_id: req.id, + }); + return; + } + reply.send( + errEnvelope( + ErrorCode.APPROVAL_NOT_FOUND, + `approval ${approval_id} not found`, + req.id, + ), + ); + return; + } + + // Adapt wire body → in-process SDK shape; settle the broker Promise. + // The broker also broadcasts `event.approval.resolved` synchronously + // before settling. + const inProc = approvalToAgentCoreResponse(body); + broker.resolve(approval_id, inProc); + // Mark for short-window idempotency. + broker.markResolved(approval_id); + + const result: ApprovalResolveResult = { + resolved: true, + resolved_at: new Date().toISOString(), + }; + reply.send(okEnvelope(result, req.id)); + } catch (err) { + // Unknown errors → 50001 via the global error handler. + throw err; + } + }, + ); +} diff --git a/packages/daemon/src/routes/files.ts b/packages/daemon/src/routes/files.ts new file mode 100644 index 000000000..fa1166e99 --- /dev/null +++ b/packages/daemon/src/routes/files.ts @@ -0,0 +1,355 @@ +/** + * `/v1/files*` REST routes (W12.2 / Chain 15 / P1.15). + * + * Three endpoints: + * + * POST /v1/files multipart upload → FileMeta envelope + * GET /v1/files/{file_id} binary stream (NO envelope) or 40407 envelope + * DELETE /v1/files/{file_id} `{deleted: true}` envelope + * + * **`@fastify/multipart` registration**: this module registers the + * plugin against the captured Fastify instance on first call. The + * plugin attaches `req.file()` / `req.parts()` to the request prototype. + * We use `req.file()` to get the FIRST file field (the spec says the + * `file` field is required). + * + * **Size cap enforcement**: we let `@fastify/multipart`'s `fileSize` + * limit do the initial gate (busboy aborts the file stream when the + * limit trips, raising `RequestFileTooLargeError`). `IFileStore.save` + * does a second-layer check by tracking bytes during the write, so even + * if the multipart layer's limit is misconfigured we still catch the + * overrun. Cap is 50 MB (`DEFAULT_MAX_UPLOAD_BYTES`). + * + * **GET architectural exception**: REST.md §3.10 line 691 — the + * download endpoint is the ONLY endpoint in the daemon that does NOT + * use the envelope. Success: raw octet-stream + Content-Disposition + + * ETag + Content-Length. 404: regular JSON envelope (clients + * distinguish by `Content-Type`). + * + * **Anti-corruption**: route resolves `IFileStore` via the DI accessor; + * zero SDK imports. + */ + +import { createReadStream } from 'node:fs'; + +import multipart from '@fastify/multipart'; + +import { + ErrorCode, + deleteFileParamSchema, + getFileParamSchema, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateParams } from '../middleware/validate.js'; +import { + DEFAULT_MAX_UPLOAD_BYTES, + FileNotFoundError, + FileTooLargeError, + IFileStore, +} from '../services/file-store.js'; + +/** + * Structural Fastify-route host for the files family. Mirrors the + * `fs.ts` / `tasks.ts` patterns: we narrow to the methods we actually + * use to avoid pulling in heavy Fastify generics. + * + * `get` return type is widened to `Promise | unknown` (W11 + * fixup-1 precedent at `routes/fs.ts:106`) so `return reply.send(stream)` + * propagates without violating the declared return type. + */ +interface FilesRouteHost { + register(plugin: unknown, opts?: unknown): unknown; + post( + path: string, + handler: ( + req: FastifyRequestLike, + reply: FilesReply, + ) => Promise | unknown, + ): unknown; + get( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: FastifyRequestLike, + reply: FilesReply, + ) => Promise | unknown, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: FastifyRequestLike, + reply: FilesReply, + ) => Promise | unknown, + ): unknown; +} + +interface FastifyRequestLike { + id: string; + params: unknown; + headers: Record; + /** Provided by `@fastify/multipart`. */ + file?: (opts?: unknown) => Promise; +} + +interface MultipartFileLike { + /** Raw busboy stream — pipe into `IFileStore.save`. */ + file: NodeJS.ReadableStream; + filename: string; + mimetype: string; + /** Field-name map (multipart `name` override comes via `fields.name.value`). */ + fields: Record; +} + +interface FilesReply { + type(mime: string): FilesReply; + header(name: string, value: string | number): FilesReply; + code(status: number): FilesReply; + send(payload: unknown): unknown; +} + +export function registerFilesRoutes( + app: FilesRouteHost, + ix: IInstantiationService, +): void { + // Register `@fastify/multipart` synchronously BEFORE `app.ready()` so + // avvio queues it as part of the initial boot phase. Setting + // `fileSize` to `DEFAULT_MAX_UPLOAD_BYTES` short-circuits huge files + // at the busboy layer; the route still re-checks inside + // `IFileStore.save` for defense-in-depth. + app.register(multipart, { + limits: { + fileSize: DEFAULT_MAX_UPLOAD_BYTES, + files: 1, + }, + }); + + // POST /v1/files ---------------------------------------------------- + // + // `multipart/form-data` with required `file` field + optional `name` + // / `expires_in_sec` fields. We stream the `file` directly into + // `IFileStore.save` (no in-memory buffering). + app.post('/v1/files', async (req, reply) => { + try { + if (!req.file) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + 'multipart not initialized', + req.id, + ), + ); + return; + } + const part = await req.file(); + if (!part) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + 'missing `file` field', + req.id, + ), + ); + return; + } + + // Extract the optional `name` / `expires_in_sec` overrides from + // sibling field parts. `fields` is populated by busboy as parts + // arrive — the order matters: the field MUST appear BEFORE the + // file in the multipart body for `fields` to be set at this + // point. Browsers / `form-data` libs do this naturally. + const nameOverride = readFieldString(part.fields['name']); + const expiresInSec = readFieldNumber(part.fields['expires_in_sec']); + + const store = ix.invokeFunction((a) => a.get(IFileStore)); + // `@fastify/multipart`'s busboy underlay flips `part.file.truncated` + // when the `fileSize` limit trips DURING streaming (it does not + // throw — the stream just ends early). The IFileStore.save call + // below also tracks bytes for defense in depth, but on the + // boundary case where the bytes go through clean and only THEN + // busboy reports truncation, we re-check `truncated` after the + // save completes and rewind by deleting the (now-too-small) blob. + const partFile = part.file as NodeJS.ReadableStream & { truncated?: boolean }; + let busboyTruncated = false; + partFile.on('limit', () => { + busboyTruncated = true; + }); + try { + const meta = await store.save( + partFile as unknown as import('node:stream').Readable, + part.filename, + { + name: nameOverride ?? part.filename, + mimeType: part.mimetype, + ...(expiresInSec !== undefined ? { expiresInSec } : {}), + }, + ); + if (busboyTruncated || partFile.truncated === true) { + // Roll back the partial-on-disk blob; surface 41301. + try { + await store.delete(meta.id); + } catch { + /* ignore */ + } + sendMappedError( + reply, + req.id, + new FileTooLargeError(meta.size + 1, DEFAULT_MAX_UPLOAD_BYTES), + ); + return; + } + reply.send(okEnvelope(meta, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + } catch (err) { + sendMappedError(reply, req.id, err); + } + }); + + // GET /v1/files/{file_id} ------------------------------------------- + // + // Architectural exception: the ONLY endpoint that does not use the + // envelope on success (REST.md §3.10 line 691). 404 still returns a + // JSON envelope; clients distinguish by `Content-Type`. + app.get( + '/v1/files/:file_id', + { preHandler: [validateParams(getFileParamSchema)] }, + async (req, reply) => { + try { + const { file_id } = req.params as { file_id: string }; + const store = ix.invokeFunction((a) => a.get(IFileStore)); + const { meta, blobPath } = await store.get(file_id); + reply + .type(meta.media_type) + .header( + 'content-disposition', + buildContentDisposition(meta.name), + ) + .header('content-length', meta.size) + // ETag pattern: `"-"`. Simple stable etag — the + // blob bytes are immutable for the lifetime of `file_id`. + .header('etag', `"${meta.id}-${meta.size}"`) + .code(200); + // CRITICAL: `return reply.send(stream)` so Fastify's + // async-return discipline ties the response lifecycle to the + // pipeline (mirrors `routes/fs.ts:368`). + return reply.send(createReadStream(blobPath)); + } catch (err) { + sendMappedError(reply, req.id, err); + return; + } + }, + ); + + // DELETE /v1/files/{file_id} ---------------------------------------- + app.delete( + '/v1/files/:file_id', + { preHandler: [validateParams(deleteFileParamSchema)] }, + async (req, reply) => { + try { + const { file_id } = req.params as { file_id: string }; + const store = ix.invokeFunction((a) => a.get(IFileStore)); + await store.delete(file_id); + reply.send(okEnvelope({ deleted: true as const }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/* ------------------------------------------------------------------------- + * Error mapping + * ----------------------------------------------------------------------- */ + +function sendMappedError(reply: FilesReply, requestId: string, err: unknown): void { + if (err instanceof FileNotFoundError) { + reply + .code(404) + .send(errEnvelope(ErrorCode.FILE_NOT_FOUND, 'file not found', requestId)); + return; + } + if (err instanceof FileTooLargeError) { + reply + .code(413) + .send( + errEnvelope( + ErrorCode.FILE_TOO_LARGE, + 'upload too large (>50MB)', + requestId, + ), + ); + return; + } + // `@fastify/multipart`'s `RequestFileTooLargeError`. We string-match + // the name so we don't drag the plugin types into the routes layer. + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'FST_REQ_FILE_TOO_LARGE' + ) { + reply + .code(413) + .send( + errEnvelope( + ErrorCode.FILE_TOO_LARGE, + 'upload too large (>50MB)', + requestId, + ), + ); + return; + } + reply + .code(500) + .send( + errEnvelope( + ErrorCode.INTERNAL_ERROR, + err instanceof Error ? err.message : 'internal error', + requestId, + ), + ); +} + +/* ------------------------------------------------------------------------- + * Helpers + * ----------------------------------------------------------------------- */ + +const fieldValueSchema = z.object({ value: z.unknown() }); + +function readFieldString(field: unknown): string | undefined { + const parsed = fieldValueSchema.safeParse(field); + if (!parsed.success) return undefined; + const v = parsed.data.value; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +function readFieldNumber(field: unknown): number | undefined { + const parsed = fieldValueSchema.safeParse(field); + if (!parsed.success) return undefined; + const v = parsed.data.value; + if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v); + if (typeof v === 'string') { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) return Math.floor(n); + } + return undefined; +} + +/** + * Build a `Content-Disposition: attachment; filename="..."` header. + * For names with non-ASCII or unsafe chars we fall back to the bare + * `attachment` directive (W11 / Chain 13 deferred the RFC 5987 + * `filename*=UTF-8''...` form; same trade-off here). + */ +function buildContentDisposition(name: string): string { + if (/^[\w. \-()+\[\]]+$/.test(name)) { + return `attachment; filename="${name}"`; + } + return 'attachment'; +} diff --git a/packages/daemon/src/routes/fs.ts b/packages/daemon/src/routes/fs.ts new file mode 100644 index 000000000..86109a307 --- /dev/null +++ b/packages/daemon/src/routes/fs.ts @@ -0,0 +1,674 @@ +/** + * `/v1/sessions/{sid}/fs:*` REST routes (W10 / Chains 9 + 10). + * + * Endpoints landed in W10.1 (Chain 9): + * + * POST /v1/sessions/{sid}/fs:list → FsListResponse + * POST /v1/sessions/{sid}/fs:read → FsReadResponse + * + * W10.2 (Chain 10) extends this module with `:list_many`, `:stat`, and + * `:stat_many` — same dispatch shape, different per-action handlers. + * + * **URL convention**: Fastify can't disambiguate `:resource_id` from a + * `:action` suffix at the same path prefix. find-my-way's `::` colon + * escape (see `find-my-way/index.js:184`) collapses both colons into a + * STATIC literal `:`, so `fs::tail` becomes the static literal `fs:tail` + * NOT the parametric tail we want. We therefore capture the full final + * segment as `:tail` and split locally on the literal `fs:` prefix. + * + * The parametric `:tail` route is registered AFTER all sibling static + * routes (`messages`, `prompts`, `tasks`, ...) so find-my-way's + * static-beats-parametric tiebreak picks the correct handler. POSTs that + * don't start with `fs:` reach this handler and bounce as 40001 — they + * would have 404'd otherwise (which is fine; either path tells the client + * the route is wrong). + * + * **Error mapping** (see also `services/fs-service.ts`): + * + * FsPathEscapesError → 41304 fs.path_escapes_session + * FsPathNotFoundError → 40409 fs.path_not_found + * FsIsDirectoryError → 40906 fs.is_directory + * FsIsBinaryError → 40907 fs.is_binary + * FsTooLargeError → 41302 fs.too_large + * FsTooManyResultsError → 41303 fs.too_many_results + * SessionNotFoundError → 40401 session.not_found + * + * **Anti-corruption**: route resolves `IFsService` via the DI accessor; + * zero SDK imports. + */ + +import { createReadStream } from 'node:fs'; + +import { + ErrorCode, + fsGitStatusRequestSchema, + fsGrepRequestSchema, + fsListManyRequestSchema, + fsListRequestSchema, + fsReadRequestSchema, + fsSearchRequestSchema, + fsStatManyRequestSchema, + fsStatRequestSchema, + type FsGitStatusRequest, + type FsGrepRequest, + type FsListManyRequest, + type FsListRequest, + type FsReadRequest, + type FsSearchRequest, + type FsStatManyRequest, + type FsStatRequest, +} from '@moonshot-ai/protocol'; +import { SessionNotFoundError } from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateParams } from '../middleware/validate.js'; +import { + FsIsBinaryError, + FsIsDirectoryError, + FsPathNotFoundError, + FsTooLargeError, + FsTooManyResultsError, + IFsService, +} from '../services/fs-service.js'; +import { + FsGrepTimeoutError, + IFsSearchService, +} from '../services/fs-search.js'; +import { + FsGitUnavailableError, + IFsGitService, +} from '../services/fs-git.js'; +import { FsPathEscapesError } from '../services/fs-path-safety.js'; + +interface FsRouteHost { + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + get( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { + id: string; + params: unknown; + headers: Record; + raw: { on(event: string, cb: () => void): unknown }; + }, + reply: FsDownloadReply, + ) => Promise | unknown, + ): unknown; +} + +/** + * Reply surface for `:download`. Fastify supports `.type()`, `.header()`, + * `.code()`, `.send()` (with a stream argument), and `.raw` (underlying + * ServerResponse) for backpressure-aware streaming. We narrow to the + * subset we actually call. + */ +interface FsDownloadReply { + type(mime: string): FsDownloadReply; + header(name: string, value: string | number): FsDownloadReply; + code(status: number): FsDownloadReply; + send(payload: unknown): unknown; +} + +const sessionIdAndTailParamSchema = z.object({ + session_id: z.string().min(1), + // `tail` captures the whole `fs:` segment. We split locally on + // the literal `fs:` prefix. + tail: z.string().min(1), +}); + +const FS_ACTIONS = [ + 'list', + 'read', + 'list_many', + 'stat', + 'stat_many', + 'search', + 'grep', + 'git_status', +] as const; +type FsAction = (typeof FS_ACTIONS)[number]; +const FS_TAIL_PREFIX = 'fs:'; + +export function registerFsRoutes( + app: FsRouteHost, + ix: IInstantiationService, +): void { + // POST /v1/sessions/{sid}/fs: + // + // Fastify path: `/v1/sessions/:session_id/:tail`. We capture the FULL + // final segment (`fs:list`, `fs:read`, ...) and split locally — Fastify's + // `::` colon-escape collapses both colons into a literal `:` STATIC + // path, NOT a literal `:` followed by a param, so we can't isolate the + // action with the route syntax (see W10 STATUS). + // + // The tail's `fs:` prefix is enforced here; non-`fs:` tails 404 from + // this route — sibling routes (`messages`, `prompts`, `tasks`, etc.) + // claim the bare-segment paths. + app.post( + '/v1/sessions/:session_id/:tail', + { preHandler: [validateParams(sessionIdAndTailParamSchema)] }, + async (req, reply) => { + const { session_id, tail } = req.params as { + session_id: string; + tail: string; + }; + + // Sibling routes use the same prefix; this handler is only valid for + // `fs:` tails. Forward all others by failing as 40001 — the + // catch-all 404 would have been more semantic but Fastify can't + // dispatch BETWEEN handlers on the same path; we own the segment. + if (!tail.startsWith(FS_TAIL_PREFIX)) { + // Defer to Fastify's 404 by sending the standard `Route not found` + // shape — but we're already in the handler, so we synthesize the + // equivalent envelope. In practice no other route registers the + // same `:tail` slot so this branch is only hit by a typo. + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + + const action = tail.slice(FS_TAIL_PREFIX.length); + if (!(FS_ACTIONS as readonly string[]).includes(action)) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + const fsAction = action as FsAction; + + try { + switch (fsAction) { + case 'list': + await handleList(ix, session_id, req, reply); + return; + case 'read': + await handleRead(ix, session_id, req, reply); + return; + case 'list_many': + await handleListMany(ix, session_id, req, reply); + return; + case 'stat': + await handleStat(ix, session_id, req, reply); + return; + case 'stat_many': + await handleStatMany(ix, session_id, req, reply); + return; + case 'search': + await handleSearch(ix, session_id, req, reply); + return; + case 'grep': + await handleGrep(ix, session_id, req, reply); + return; + case 'git_status': + await handleGitStatus(ix, session_id, req, reply); + return; + } + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // --------------------------------------------------------------------- + // GET /v1/sessions/{sid}/fs/* — Chain 13 (W11.3) streaming download. + // + // **Architectural exception**: REST.md §3.9 line 558 — the ONLY GET in + // the daemon's REST surface with a verb in the URL (`:download` + // suffix). HTTP semantics dictate GET for downloads. + // + // URL pattern (REST.md §3.9 line 562): `GET /v1/sessions/{sid}/fs/{path}:download` + // `{path}` retains forward slashes; Fastify's `*` wildcard captures + // everything after `fs/`. We then peel off the `:download` action + // suffix and validate the path through `IFsService.resolveDownload`. + // + // Success: HTTP 200 + `application/octet-stream` (or extension-based + // mime) + `Content-Length` + `ETag` + `Content-Disposition` + raw + // bytes via `fs.createReadStream`. Fastify handles backpressure + + // client-abort cleanup natively when given a Readable stream. + // + // 206 (Range): when client passes `Range: bytes=A-B`, we stream the + // requested window with `Content-Range`. + // + // 304: when `If-None-Match` matches the etag. + // + // Error paths return HTTP 200 + `application/json` envelope (the + // documented one-way escape hatch per REST.md §3.9 line 571). + // --------------------------------------------------------------------- + app.get( + '/v1/sessions/:session_id/fs/*', + { preHandler: [] }, + async (req, reply) => { + const { session_id, '*': wildcard } = req.params as { + session_id: string; + '*': string; + }; + + // Strip the `:download` suffix (the only verb we support on this + // route). Anything else is a 40001. + const DOWNLOAD_SUFFIX = ':download'; + if (!wildcard.endsWith(DOWNLOAD_SUFFIX)) { + return reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${wildcard}`, + req.id, + ), + ); + } + const relPath = wildcard.slice(0, -DOWNLOAD_SUFFIX.length); + if (relPath.length === 0) { + return reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + 'path is empty', + req.id, + ), + ); + } + + // Resolve through IFsService. Surfaced errors go through the + // central sendMappedError (which writes a JSON envelope per the + // download exception). Success path leaves the response body free + // for the stream. + let resolved: import('../services/fs-service.js').FsDownloadResolved; + try { + resolved = await ix.invokeFunction((a) => + a.get(IFsService).resolveDownload(session_id, relPath), + ); + } catch (err) { + sendMappedError(reply, req.id, err); + return reply; + } + + // If-None-Match negotiation (REST.md §3.9 line 567). + const ifNoneMatch = pickHeader(req.headers, 'if-none-match'); + if (ifNoneMatch !== undefined && ifNoneMatch === resolved.etag) { + return reply.code(304).header('etag', resolved.etag).send(''); + } + + reply.header('etag', resolved.etag); + reply.header( + 'last-modified', + resolved.modifiedAt.toUTCString(), + ); + reply.header( + 'content-disposition', + `attachment; filename="${sanitizeFilename(resolved.relative)}"`, + ); + reply.type(resolved.mime); + + // Range negotiation (REST.md §3.9 line 565). + const rangeHeader = pickHeader(req.headers, 'range'); + const range = parseRangeHeader(rangeHeader, resolved.size); + if (range !== null) { + reply + .code(206) + .header('content-length', String(range.length)) + .header( + 'content-range', + `bytes ${range.start}-${range.end}/${resolved.size}`, + ); + const stream = createReadStream(resolved.absolute, { + start: range.start, + end: range.end, + }); + // Fastify's reply.send(stream) handles backpressure + client + // abort. We additionally attach an explicit error handler so a + // mid-stream EIO surfaces in daemon logs instead of crashing + // the worker. + stream.on('error', () => { + // Already-started stream can't be replaced with an envelope; + // best we can do is close cleanly. + try { + stream.destroy(); + } catch { + // ignore + } + }); + return reply.send(stream); + } + + // Full-file path. Set content-length explicitly so HTTP keep-alive + // can frame the response without chunked encoding (Fastify would + // pick chunked otherwise for streams). + reply.code(200).header('content-length', String(resolved.size)); + const stream = createReadStream(resolved.absolute); + stream.on('error', () => { + try { + stream.destroy(); + } catch { + // ignore + } + }); + // CRITICAL: return reply.send(stream). Fastify v5 async handlers + // that fall off the end (returning undefined) will OVERWRITE the + // already-piped stream body with the undefined return — content-length + // collapses to 0. Returning the reply (after calling send) keeps the + // stream as the response body. Same pattern as Fastify docs §"Streams". + return reply.send(stream); + }, + ); +} + +// --------------------------------------------------------------------------- +// Per-action handlers — each validates its body shape, dispatches against +// IFsService, and re-throws errors for the central mapper. +// --------------------------------------------------------------------------- + +async function handleList( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsListRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsListRequest = parsed.data; + const data = await ix.invokeFunction((a) => a.get(IFsService).list(sessionId, body)); + reply.send(okEnvelope(data, req.id)); +} + +async function handleRead( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsReadRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsReadRequest = parsed.data; + const data = await ix.invokeFunction((a) => a.get(IFsService).read(sessionId, body)); + reply.send(okEnvelope(data, req.id)); +} + +async function handleListMany( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsListManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsListManyRequest = parsed.data; + const data = await ix.invokeFunction((a) => + a.get(IFsService).listMany(sessionId, body), + ); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStat( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsStatRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsStatRequest = parsed.data; + const data = await ix.invokeFunction((a) => a.get(IFsService).stat(sessionId, body)); + reply.send(okEnvelope(data, req.id)); +} + +async function handleStatMany( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsStatManyRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsStatManyRequest = parsed.data; + const data = await ix.invokeFunction((a) => + a.get(IFsService).statMany(sessionId, body), + ); + reply.send(okEnvelope(data, req.id)); +} + +async function handleSearch( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsSearchRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsSearchRequest = parsed.data; + const data = await ix.invokeFunction((a) => + a.get(IFsSearchService).search(sessionId, body), + ); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGrep( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsGrepRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsGrepRequest = parsed.data; + const data = await ix.invokeFunction((a) => + a.get(IFsSearchService).grep(sessionId, body), + ); + reply.send(okEnvelope(data, req.id)); +} + +async function handleGitStatus( + ix: IInstantiationService, + sessionId: string, + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, +): Promise { + const parsed = fsGitStatusRequestSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.send(buildValidationEnvelope(parsed.error.issues, req.id)); + return; + } + const body: FsGitStatusRequest = parsed.data; + const data = await ix.invokeFunction((a) => + a.get(IFsGitService).status(sessionId, body), + ); + reply.send(okEnvelope(data, req.id)); +} + +// --------------------------------------------------------------------------- +// Error mapping +// --------------------------------------------------------------------------- + +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof FsPathEscapesError) { + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId)); + return; + } + if (err instanceof FsPathNotFoundError) { + reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, err.message, requestId)); + return; + } + if (err instanceof FsIsDirectoryError) { + reply.send(errEnvelope(ErrorCode.FS_IS_DIRECTORY, err.message, requestId)); + return; + } + if (err instanceof FsIsBinaryError) { + reply.send(errEnvelope(ErrorCode.FS_IS_BINARY, err.message, requestId)); + return; + } + if (err instanceof FsTooLargeError) { + reply.send(errEnvelope(ErrorCode.FS_TOO_LARGE, err.message, requestId)); + return; + } + if (err instanceof FsTooManyResultsError) { + reply.send(errEnvelope(ErrorCode.FS_TOO_MANY_RESULTS, err.message, requestId)); + return; + } + if (err instanceof FsGrepTimeoutError) { + reply.send(errEnvelope(ErrorCode.FS_GREP_TIMEOUT, err.message, requestId)); + return; + } + if (err instanceof FsGitUnavailableError) { + reply.send(errEnvelope(ErrorCode.FS_GIT_UNAVAILABLE, err.message, requestId)); + return; + } + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} + +// --------------------------------------------------------------------------- +// Validation envelope helper (mirrors middleware/validate.ts shape but +// runs inline so each handler can pick its own schema based on the action +// the route dispatched to). +// --------------------------------------------------------------------------- + +function buildValidationEnvelope( + issues: readonly { path: readonly PropertyKey[]; message: string }[], + requestId: string, +): { + code: number; + msg: string; + data: null; + request_id: string; + details: { path: string; message: string }[]; +} { + const details = issues.map((i) => ({ + path: i.path.map((p) => String(p)).join('.'), + message: i.message, + })); + 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, + }; +} + +// --------------------------------------------------------------------------- +// :download helpers (Chain 13 / W11.3) +// --------------------------------------------------------------------------- + +/** + * Read a single header value from the request headers map, normalizing + * the `string | string[] | undefined` shape to `string | undefined`. If + * the client sent multiple values we take the first. + */ +function pickHeader( + headers: Record, + name: string, +): string | undefined { + const v = headers[name]; + if (v === undefined) return undefined; + return Array.isArray(v) ? v[0] : v; +} + +/** + * Parse an HTTP `Range: bytes=A-B` header. Supports the most common + * formats: + * - `bytes=0-65535` first 64 KB + * - `bytes=1024-` from offset 1024 to EOF + * - `bytes=-1024` last 1024 bytes + * + * Returns `null` when there's no valid Range. We do NOT implement the + * multi-range comma-separated form — REST.md §3.9 line 565 only + * specifies single-range, and clients overwhelmingly use single ranges. + */ +function parseRangeHeader( + raw: string | undefined, + size: number, +): { start: number; end: number; length: number } | null { + if (raw === undefined) return null; + if (!raw.startsWith('bytes=')) return null; + const spec = raw.slice('bytes='.length); + if (spec.includes(',')) return null; + const dash = spec.indexOf('-'); + if (dash < 0) return null; + const leftRaw = spec.slice(0, dash); + const rightRaw = spec.slice(dash + 1); + if (leftRaw === '' && rightRaw === '') return null; + let start: number; + let end: number; + if (leftRaw === '') { + // Suffix range: last N bytes. + const suffix = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(suffix) || suffix <= 0) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + const a = Number.parseInt(leftRaw, 10); + if (!Number.isFinite(a) || a < 0) return null; + start = a; + if (rightRaw === '') { + end = size - 1; + } else { + const b = Number.parseInt(rightRaw, 10); + if (!Number.isFinite(b) || b < a) return null; + end = Math.min(b, size - 1); + } + } + if (start >= size || start > end) return null; + return { start, end, length: end - start + 1 }; +} + +/** + * Sanitize a relative path for use in a `Content-Disposition` filename. + * We keep only the base name and escape double quotes; clients render + * this as the suggested save filename. + */ +function sanitizeFilename(rel: string): string { + const segs = rel.split('/'); + const base = segs[segs.length - 1] ?? rel; + return base.replace(/"/g, '\\"'); +} diff --git a/packages/daemon/src/routes/messages.ts b/packages/daemon/src/routes/messages.ts new file mode 100644 index 000000000..b8c6ab2a6 --- /dev/null +++ b/packages/daemon/src/routes/messages.ts @@ -0,0 +1,165 @@ +/** + * `/v1/sessions/{session_id}/messages*` REST routes (Chain 3 / P1.3, W7.1). + * + * 2 endpoints (REST.md §3.4): + * + * GET /v1/sessions/{sid}/messages query: ListMessages data: Page + * GET /v1/sessions/{sid}/messages/{mid} - data: Message + * + * Validation: query is coerced + checked by `messagesListQueryCoercion` + * (`z.coerce.number()` for `page_size`, mutex re-asserted via superRefine, + * unknown role values rejected). Params validated by `messageIdParamSchema`. + * + * **Error mapping**: + * - `SessionNotFoundError` → 40401 + * - `MessageNotFoundError` → 40403 + * - Other errors fall through to W4 `installErrorHandler` (→ 50001). + * + * **Wiring**: takes an `IInstantiationService` so each request resolves + * `IMessageService` via the daemon's DI container. Same pattern as + * `sessions.ts` — handlers don't capture the service directly. + * + * **Anti-corruption**: route file lives in `packages/daemon/src/` and goes + * through `accessor.get(IMessageService)` whose impl lives in + * `@moonshot-ai/services`. No SDK package imports. + */ + +import { + ErrorCode, + messageRoleSchema, + type ListMessagesQuery, +} from '@moonshot-ai/protocol'; +import { + IMessageService, + MessageNotFoundError, + SessionNotFoundError, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateParams, validateQuery } from '../middleware/validate.js'; + +/** + * Per-request structural typing — keeps the route module decoupled from the + * concrete FastifyRequest generic parameters. + */ +interface MessageRouteHost { + get( + path: string, + options: { preHandler: unknown[] } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +// --- Query coercion --------------------------------------------------------- + +/** + * HTTP query strings arrive as `Record`. We coerce + * `page_size` here so the protocol's `cursorQuerySchema` stays HTTP-agnostic + * — mirrors `sessions.ts:sessionsListQueryCoercion`. + */ +const messagesListQueryCoercion = 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(), + role: messageRoleSchema.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 }, + }); + } + }); + +// --- Params ----------------------------------------------------------------- + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const messageIdParamSchema = z.object({ + session_id: z.string().min(1), + message_id: z.string().min(1), +}); + +// --- Registration ----------------------------------------------------------- + +export function registerMessagesRoutes( + app: MessageRouteHost, + ix: IInstantiationService, +): void { + // GET /v1/sessions/{session_id}/messages -------------------------------- + app.get( + '/v1/sessions/:session_id/messages', + { + preHandler: [ + validateParams(sessionIdParamSchema), + validateQuery(messagesListQueryCoercion), + ], + }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const query = req.query as ListMessagesQuery; + const page = await ix.invokeFunction((a) => + a.get(IMessageService).list(session_id, query), + ); + reply.send(okEnvelope(page, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // GET /v1/sessions/{session_id}/messages/{message_id} ------------------- + app.get( + '/v1/sessions/:session_id/messages/:message_id', + { preHandler: [validateParams(messageIdParamSchema)] }, + async (req, reply) => { + try { + const { session_id, message_id } = req.params as { + session_id: string; + message_id: string; + }; + const message = await ix.invokeFunction((a) => + a.get(IMessageService).get(session_id, message_id), + ); + reply.send(okEnvelope(message, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/** + * Map a thrown error to the right envelope: + * - `SessionNotFoundError` → `code: 40401` + * - `MessageNotFoundError` → `code: 40403` + * - Anything else → re-throw; W4 `installErrorHandler` → `50001`. + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof MessageNotFoundError) { + reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId)); + return; + } + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} diff --git a/packages/daemon/src/routes/meta.ts b/packages/daemon/src/routes/meta.ts new file mode 100644 index 000000000..30af86524 --- /dev/null +++ b/packages/daemon/src/routes/meta.ts @@ -0,0 +1,70 @@ +/** + * `GET /v1/meta` route handler — Chain 1 / P1.1. + * + * Returns the daemon's `daemon_version`, declared `capabilities` literal map, + * a per-process `server_id` (ULID minted at boot — reset on every restart so + * clients can detect a daemon restart and resync), and `started_at` ISO time. + * + * **No DI**: this route doesn't touch services — it's pure daemon-self info + * per ROADMAP Chain 1 ("不经过 services 包"). The `MetaRouteOptions` payload + * is provided by `start.ts` at registration time and frozen for the daemon's + * lifetime. + * + * **Wire shape**: matches `metaResponseSchema` (REST.md §3.1) exactly. The + * envelope wrap is `okEnvelope(data, req.id)` — `req.id` is the bare 26-char + * ULID set by Fastify's `genReqId` via `resolveRequestId` (W4.3). + * + * **Anti-corruption**: no SDK package import, no broker / bridge access. The + * version source is the daemon's own `package.json` read via + * `getDaemonVersion()` — no indirection through services or agent-core. + */ + +import { okEnvelope } from '../envelope.js'; +import type { MetaResponse } from '@moonshot-ai/protocol'; + +/** + * Minimal structural shape for the Fastify instance — just the verbs this + * file calls. Avoids the strict generic mismatch between Fastify's default + * `FastifyInstance` and the daemon's pino-typed variant + * (`FastifyInstance<…, DaemonLogger>`), same pattern as + * `error-handler.ts:ErrorHandlerHost` and `rest-gateway.ts:FastifyLike`. + */ +interface RouteHost { + get( + path: string, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export interface MetaRouteOptions { + /** Daemon `package.json` version. Cached at startup. */ + readonly daemonVersion: string; + /** Per-process ULID. Minted once at boot in `start.ts`. */ + readonly serverId: string; + /** ISO 8601 UTC timestamp the daemon went live at. */ + readonly startedAt: string; +} + +export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void { + // Freeze a single response object — this endpoint's payload never changes + // for the daemon's lifetime (capabilities are first-version literal `true`s). + const data: MetaResponse = Object.freeze({ + daemon_version: opts.daemonVersion, + capabilities: Object.freeze({ + websocket: true as const, + file_upload: true as const, + fs_query: true as const, + mcp: true as const, + background_tasks: true as const, + }), + server_id: opts.serverId, + started_at: opts.startedAt, + }); + + app.get('/v1/meta', async (req, reply) => { + reply.send(okEnvelope(data, req.id)); + }); +} diff --git a/packages/daemon/src/routes/prompts.ts b/packages/daemon/src/routes/prompts.ts new file mode 100644 index 000000000..5ff0db5b1 --- /dev/null +++ b/packages/daemon/src/routes/prompts.ts @@ -0,0 +1,190 @@ +/** + * `/v1/sessions/{sid}/prompts*` REST routes (Chain 4 / P1.4, W7.2; + * abort handler extended in Chain 4b / W7.3). + * + * 2 endpoints (REST.md §3.5): + * + * POST /v1/sessions/{sid}/prompts body: PromptSubmission data: PromptSubmitResult + * POST /v1/sessions/{sid}/prompts/{pid}:abort body: empty data: { aborted, at_seq? } + * + * **Error mapping**: + * - `SessionNotFoundError` → 40401 + * - `SessionBusyError` → 40901 (with details.active_prompt_id) + * - `PromptNotFoundError` → 40402 + * - `PromptAlreadyCompletedError` → 40903 with data `{aborted: false}` + * per REST.md §3.5 (idempotent — wire data, non-zero code) + * - Other errors → 50001 via W4 `installErrorHandler`. + * + * **Shared abort handler** (W7.3): the actual abort logic lives in + * `IPromptService.abort` — both this REST route AND the WS abort control + * message dispatch through the same accessor call. The route is just a thin + * envelope layer. + * + * **Anti-corruption**: routes go through `accessor.get(IPromptService)`; + * no SDK package imports. + */ + +import { + ErrorCode, + promptSubmissionSchema, + type PromptSubmission, +} from '@moonshot-ai/protocol'; +import { + IPromptService, + PromptAlreadyCompletedError, + PromptNotFoundError, + SessionBusyError, + SessionNotFoundError, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateBody, validateParams } from '../middleware/validate.js'; +import { parseActionSuffix } from './action-suffix.js'; + +interface PromptRouteHost { + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +// --- Params ----------------------------------------------------------------- + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +// --- Registration ----------------------------------------------------------- + +export function registerPromptsRoutes( + app: PromptRouteHost, + ix: IInstantiationService, +): void { + // POST /v1/sessions/{session_id}/prompts --------------------------------- + app.post( + '/v1/sessions/:session_id/prompts', + { + preHandler: [ + validateParams(sessionIdParamSchema), + validateBody(promptSubmissionSchema), + ], + }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const body = req.body as PromptSubmission; + const result = await ix.invokeFunction((a) => + a.get(IPromptService).submit(session_id, body), + ); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // POST /v1/sessions/{session_id}/prompts/{prompt_id}:abort --------------- + // Fastify's path syntax doesn't allow a literal `:abort` suffix on a + // colon-prefixed param (`:prompt_id:abort` parses ambiguously). REST.md + // §3.5 specifies the action-suffix syntax `{prompt_id}:abort`. We register + // the route by capturing the tail segment (`:tail`) and verifying it ends + // with `:abort` via the shared `parseActionSuffix` helper (4th call site + // shared since W9.1). + app.post( + '/v1/sessions/:session_id/prompts/:tail', + { preHandler: [] }, + async (req, reply) => { + try { + const { session_id, tail } = req.params as { + session_id: string; + tail: string; + }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['abort'] as const, + resourceLabel: 'prompt', + }); + if (parsed.kind === 'invalid') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id), + ); + return; + } + // The prompts route does not accept a bare prompt_id; only :abort. + if (parsed.kind === 'bare') { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + const prompt_id = parsed.id; + if (!session_id || !prompt_id) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, 'invalid path params', req.id), + ); + return; + } + const result = await ix.invokeFunction((a) => + a.get(IPromptService).abort(session_id, prompt_id), + ); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/** + * Map a thrown error to the right envelope. See module header for the table. + * + * NOTE: `PromptAlreadyCompletedError` is a SPECIAL case — REST.md §3.5 + * mandates `envelope.code = 40903 + envelope.data = {aborted: false}`. We + * compose that here rather than using `errEnvelope` (which would set + * `data: null`). + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof PromptAlreadyCompletedError) { + reply.send({ + code: ErrorCode.PROMPT_ALREADY_COMPLETED, + msg: err.message, + data: { aborted: false }, + request_id: requestId, + }); + return; + } + if (err instanceof SessionBusyError) { + reply.send({ + code: ErrorCode.SESSION_BUSY, + msg: err.message, + data: null, + request_id: requestId, + details: { active_prompt_id: err.activePromptId }, + }); + return; + } + if (err instanceof PromptNotFoundError) { + reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId)); + return; + } + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} diff --git a/packages/daemon/src/routes/questions.ts b/packages/daemon/src/routes/questions.ts new file mode 100644 index 000000000..3c237bfd9 --- /dev/null +++ b/packages/daemon/src/routes/questions.ts @@ -0,0 +1,179 @@ +/** + * `/v1/sessions/{sid}/questions/{qid}*` REST routes (Chain 6 / P1.6, W8.2). + * + * 2 endpoints (REST.md §3.6), both serviced by a SINGLE Fastify route handler + * because Fastify cannot disambiguate `:question_id` vs `:question_id:dismiss` + * on the same path prefix (the W7 prompts `:abort` worked because it was the + * sole tail; questions has both a bare resolve and a `:dismiss` so we MUST + * use the tail-parser for both): + * + * POST /v1/sessions/{sid}/questions/{qid} (resolve) + * body: QuestionResponse (5-kind answers map + method?+ note?) + * data: { resolved: true, resolved_at } + * + * POST /v1/sessions/{sid}/questions/{qid}:dismiss (first-class + * body: empty dismiss) + * envelope: code: 40909, data: { dismissed: true, dismissed_at } + * + * **Fastify `:dismiss` action-suffix workaround** (W7 `:abort` precedent): + * we capture the tail segment as `:tail` and parse via `lastIndexOf(':')`. + * The pattern is now in use by 3 callers (prompts:abort + questions:resolve + + * questions:dismiss); W9 may want to extract a helper. + * + * Error mapping (REST.md §3.6): + * - 40404 (question.not_found) + * - 40902 (question.already_resolved) — custom envelope w/ data:{resolved:false} + * - 40001 (validation.failed) — bad body via Zod + * - 40909 (question.dismissed) — successful dismiss envelope + * + * **Anti-corruption**: route resolves `IQuestionBroker` via the accessor; + * no SDK imports. + */ + +import { + ErrorCode, + questionResolveRequestSchema, + type QuestionResolveRequest, + type QuestionResolveResult, +} from '@moonshot-ai/protocol'; +import { + IQuestionBroker, + questionToAgentCoreResponse, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateParams } from '../middleware/validate.js'; +import { parseActionSuffix } from './action-suffix.js'; +import { DaemonQuestionBroker } from '../services/question-broker.js'; + +interface QuestionRouteHost { + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const tailParamsSchema = z.object({ + session_id: z.string().min(1), + tail: z.string().min(1), +}); + +export function registerQuestionsRoutes( + app: QuestionRouteHost, + ix: IInstantiationService, +): void { + // Single route capturing both the resolve and dismiss paths via `:tail`. + app.post( + '/v1/sessions/:session_id/questions/:tail', + { + preHandler: [validateParams(tailParamsSchema)], + }, + async (req, reply) => { + const { tail } = req.params as { session_id: string; tail: string }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['dismiss'] as const, + defaultAction: 'resolve', + resourceLabel: 'question', + }); + if (parsed.kind === 'invalid') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id), + ); + return; + } + const questionId = parsed.id; + const action: 'resolve' | 'dismiss' = + parsed.kind === 'bare' ? 'resolve' : parsed.action; + + if (!questionId) { + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + 'invalid question_id in path', + req.id, + ), + ); + return; + } + + const broker = ix.invokeFunction((a) => + a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + + if (!broker.isPending(questionId)) { + if (broker.isRecentlyResolved(questionId)) { + reply.send({ + code: ErrorCode.APPROVAL_ALREADY_RESOLVED, // 40902 — shared "already_resolved" + msg: `question ${questionId} already resolved`, + data: { resolved: false }, + request_id: req.id, + }); + return; + } + reply.send( + errEnvelope( + ErrorCode.QUESTION_NOT_FOUND, + `question ${questionId} not found`, + req.id, + ), + ); + return; + } + + if (action === 'dismiss') { + broker.dismiss(questionId); + reply.send({ + code: ErrorCode.QUESTION_DISMISSED, // 40909 + msg: `question ${questionId} dismissed`, + data: { dismissed: true, dismissed_at: new Date().toISOString() }, + request_id: req.id, + }); + return; + } + + // action === 'resolve' — validate body manually (we can't use the Zod + // preHandler here because the route shape is generic over `:tail` and + // the dismiss path uses an empty body). + const bodyParse = questionResolveRequestSchema.safeParse(req.body); + if (!bodyParse.success) { + const details = bodyParse.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })); + const first = details[0]; + const msg = first === undefined + ? 'validation failed' + : first.path === '' + ? first.message + : `${first.path}: ${first.message}`; + reply.send({ + code: ErrorCode.VALIDATION_FAILED, + msg, + data: null, + request_id: req.id, + details, + }); + return; + } + + const body = bodyParse.data as QuestionResolveRequest; + const inProc = questionToAgentCoreResponse(body); + broker.resolve(questionId, inProc); + broker.markResolved(questionId); + + const result: QuestionResolveResult = { + resolved: true, + resolved_at: new Date().toISOString(), + }; + reply.send(okEnvelope(result, req.id)); + }, + ); +} diff --git a/packages/daemon/src/routes/sessions.ts b/packages/daemon/src/routes/sessions.ts new file mode 100644 index 000000000..8acde984c --- /dev/null +++ b/packages/daemon/src/routes/sessions.ts @@ -0,0 +1,233 @@ +/** + * `/v1/sessions/*` REST routes (Chain 2 / P1.2). + * + * 5 endpoints (REST.md §3.3): + * + * POST /v1/sessions body: SessionCreate data: Session + * GET /v1/sessions query: ListSessions data: Page + * GET /v1/sessions/{id} - data: Session + * PATCH /v1/sessions/{id} body: SessionUpdate data: Session + * DELETE /v1/sessions/{id} - data: { deleted: true } + * + * Each handler validates input with the Zod `validateBody` / `validateQuery` + * preHandler (40001 on failure with `details` path), invokes + * `accessor.get(ISessionService).(...)`, and emits an `okEnvelope`. + * + * **Error mapping**: `SessionNotFoundError` → envelope `code: 40401`. Other + * errors fall through to the W4 `installErrorHandler` (→ 50001). + * + * **Wiring**: takes an `IInstantiationService` so each request can resolve + * `ISessionService` via the same DI container the daemon constructs in + * `start.ts`. The handler closures don't capture the service directly — that + * would break the per-request request_id flow and the dispose-cascade story. + * + * **Anti-corruption**: this file is part of `packages/daemon/src/`. No direct + * SDK package imports — sessions go through `accessor.get(ISessionService)` + * whose impl lives in `@moonshot-ai/services`. + */ + +import { + ErrorCode, + createSessionRequestSchema, + sessionStatusSchema, + updateSessionRequestSchema, + type SessionCreate, + type SessionUpdate, +} from '@moonshot-ai/protocol'; +import { + ISessionService, + SessionNotFoundError, + type SessionListQuery, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateBody, validateParams, validateQuery } from '../middleware/validate.js'; + +/** + * Per-request structural typing — we never need the full FastifyRequest type; + * the fields below are the only ones the handlers touch. + */ +interface SessionRouteHost { + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + get( + path: string, + options: { preHandler: unknown[] } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + // Fastify exposes `patch` and `delete` as instance methods. + patch( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler: unknown[] } | undefined, + handler: ( + req: { id: string; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +// --- Query coercion --------------------------------------------------------- + +/** + * HTTP query strings arrive as `Record`. The protocol's + * `cursorQuerySchema` expects `page_size: number`. We coerce at the daemon + * boundary so the protocol schema stays HTTP-agnostic (re-usable on the + * client side where JSON-RPC payloads already carry typed numbers). + * + * `page_size` parses as a positive integer 1..100; anything else fails 40001. + */ +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(), + }) + .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 }, + }); + } + }); + +// --- Params ----------------------------------------------------------------- + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +// --- Registration ----------------------------------------------------------- + +export function registerSessionsRoutes( + app: SessionRouteHost, + ix: IInstantiationService, +): void { + // POST /v1/sessions ------------------------------------------------------ + app.post( + '/v1/sessions', + { preHandler: [validateBody(createSessionRequestSchema)] }, + async (req, reply) => { + try { + const body = req.body as SessionCreate; + const session = await ix.invokeFunction((a) => a.get(ISessionService).create(body)); + reply.send(okEnvelope(session, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // GET /v1/sessions ------------------------------------------------------- + app.get( + '/v1/sessions', + { preHandler: [validateQuery(sessionsListQueryCoercion)] }, + async (req, reply) => { + try { + const query = req.query as SessionListQuery; + const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + reply.send(okEnvelope(page, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // GET /v1/sessions/{session_id} ------------------------------------------ + app.get( + '/v1/sessions/:session_id', + { preHandler: [validateParams(sessionIdParamSchema)] }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const session = await ix.invokeFunction((a) => a.get(ISessionService).get(session_id)); + reply.send(okEnvelope(session, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // PATCH /v1/sessions/{session_id} ---------------------------------------- + app.patch( + '/v1/sessions/:session_id', + { + preHandler: [ + validateParams(sessionIdParamSchema), + validateBody(updateSessionRequestSchema), + ], + }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const body = req.body as SessionUpdate; + const session = await ix.invokeFunction((a) => + a.get(ISessionService).update(session_id, body), + ); + reply.send(okEnvelope(session, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // DELETE /v1/sessions/{session_id} --------------------------------------- + app.delete( + '/v1/sessions/:session_id', + { preHandler: [validateParams(sessionIdParamSchema)] }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const result = await ix.invokeFunction((a) => a.get(ISessionService).delete(session_id)); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/** + * Map a thrown error to the right envelope: + * - `SessionNotFoundError` → `code: 40401` + * - Anything else → re-throw so the W4 `installErrorHandler` catches it + * and emits `50001`. + * + * We don't catch generic `Error` here because the global hook is the single + * place stack traces reach the operator log (`error-handler.ts:42-43`). + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + // Re-throw so Fastify's error hook handles it. + throw err; +} diff --git a/packages/daemon/src/routes/tasks.ts b/packages/daemon/src/routes/tasks.ts new file mode 100644 index 000000000..205782f69 --- /dev/null +++ b/packages/daemon/src/routes/tasks.ts @@ -0,0 +1,211 @@ +/** + * `/v1/sessions/{sid}/tasks*` REST routes (Chain 8 / P1.8, W9.2). + * + * 3 endpoints (REST.md §3.7): + * + * GET /v1/sessions/{sid}/tasks query: {status?} data: {items[]} + * GET /v1/sessions/{sid}/tasks/{tid} query: {with_output?, + * output_bytes?} data: BackgroundTask + * POST /v1/sessions/{sid}/tasks/{tid}:cancel body: empty data: {cancelled:true} + * + * **Error mapping**: + * - `SessionNotFoundError` → envelope `code: 40401` + * - `TaskNotFoundError` → envelope `code: 40406` + * - `TaskAlreadyFinishedError` → envelope `code: 40904` with custom + * `data:{cancelled:false}` (mirrors W7's 40903/W8's 40902 precedent). + * - Other errors → 50001 via W4 `installErrorHandler`. + * + * **Action suffix**: `:cancel` uses the shared `parseActionSuffix` helper + * (5th call site after prompts:abort, questions:resolve|dismiss, mcp:restart). + * + * **Anti-corruption**: route resolves `ITaskService` via the accessor; no + * SDK imports. + */ + +import { + ErrorCode, + getTaskQuerySchema, + listTasksQuerySchema, + type ListTasksQuery, +} from '@moonshot-ai/protocol'; +import { + ITaskService, + SessionNotFoundError, + TaskAlreadyFinishedError, + TaskNotFoundError, +} from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateParams, validateQuery } from '../middleware/validate.js'; +import { parseActionSuffix } from './action-suffix.js'; + +interface TasksRouteHost { + get( + path: string, + options: { preHandler: unknown[] } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const sessionAndTaskIdParamSchema = z.object({ + session_id: z.string().min(1), + task_id: z.string().min(1), +}); + +export function registerTasksRoutes( + app: TasksRouteHost, + ix: IInstantiationService, +): void { + // GET /v1/sessions/{session_id}/tasks ------------------------------------ + app.get( + '/v1/sessions/:session_id/tasks', + { + preHandler: [ + validateParams(sessionIdParamSchema), + validateQuery(listTasksQuerySchema), + ], + }, + async (req, reply) => { + try { + const { session_id } = req.params as { session_id: string }; + const query = req.query as ListTasksQuery; + const items = await ix.invokeFunction((a) => + a.get(ITaskService).list(session_id, query), + ); + reply.send(okEnvelope({ items }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // GET /v1/sessions/{session_id}/tasks/{task_id} -------------------------- + app.get( + '/v1/sessions/:session_id/tasks/:task_id', + { + preHandler: [ + validateParams(sessionAndTaskIdParamSchema), + validateQuery(getTaskQuerySchema), + ], + }, + async (req, reply) => { + try { + const { session_id, task_id } = req.params as { + session_id: string; + task_id: string; + }; + const task = await ix.invokeFunction((a) => + a.get(ITaskService).get(session_id, task_id), + ); + reply.send(okEnvelope(task, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // POST /v1/sessions/{session_id}/tasks/{task_id}:cancel ------------------ + // + // Fastify routes the GET `/:task_id` and the POST `/:tail` against the + // same Trie prefix. Using `/:task_id:cancel`-style would collide; we + // capture `:tail` and demand the `:cancel` suffix via the shared parser. + app.post( + '/v1/sessions/:session_id/tasks/:tail', + { preHandler: [] }, + async (req, reply) => { + try { + const { session_id, tail } = req.params as { + session_id: string; + tail: string; + }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['cancel'] as const, + resourceLabel: 'task', + }); + if (parsed.kind === 'invalid') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id), + ); + return; + } + if (parsed.kind === 'bare') { + // POST without `:cancel` is not a defined action; the bare GET + // form serves `/v1/.../tasks/{tid}`. + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + const task_id = parsed.id; + if (!session_id || !task_id) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, 'invalid path params', req.id), + ); + return; + } + const result = await ix.invokeFunction((a) => + a.get(ITaskService).cancel(session_id, task_id), + ); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/** + * Map a thrown error to the right envelope. See module header for the table. + * + * `TaskAlreadyFinishedError` is a SPECIAL case — REST.md §3.7 mandates + * envelope `code: 40904` + `data: {cancelled: false}`. Mirrors the W7 40903 + * + W8 40902 idempotent shape. + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof TaskAlreadyFinishedError) { + reply.send({ + code: ErrorCode.TASK_ALREADY_FINISHED, + msg: err.message, + data: { cancelled: false }, + request_id: requestId, + details: { current_status: err.currentStatus }, + }); + return; + } + if (err instanceof TaskNotFoundError) { + reply.send(errEnvelope(ErrorCode.TASK_NOT_FOUND, err.message, requestId)); + return; + } + if (err instanceof SessionNotFoundError) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} diff --git a/packages/daemon/src/routes/tools.ts b/packages/daemon/src/routes/tools.ts new file mode 100644 index 000000000..e40212733 --- /dev/null +++ b/packages/daemon/src/routes/tools.ts @@ -0,0 +1,143 @@ +/** + * `/v1/tools` + `/v1/mcp/servers*` REST routes (Chain 7 / P1.7, W9.1). + * + * 3 endpoints (REST.md §3.8): + * + * GET /v1/tools query: {session_id?} data: {tools: ToolDescriptor[]} + * GET /v1/mcp/servers - data: {servers: McpServer[]} + * POST /v1/mcp/servers/{mcp_server_id}:restart body: empty data: {restarting: true} + * + * **Error mapping**: + * - `McpServerNotFoundError` → envelope `code: 40408 mcp.server_not_found`. + * - Other errors → 50001 via W4 `installErrorHandler`. + * + * **Action suffix**: the `:restart` POST endpoint uses the shared + * `parseActionSuffix` helper (extracted W9.1, 4th call site after prompts:abort, + * questions:resolve, questions:dismiss). + * + * **Anti-corruption**: route resolves `IToolService` / `IMcpService` via the + * accessor; no SDK imports. + */ + +import { + ErrorCode, + listToolsQuerySchema, + type ListToolsQuery, +} from '@moonshot-ai/protocol'; +import { IMcpService, IToolService, McpServerNotFoundError } from '@moonshot-ai/services'; +import { z } from 'zod'; + +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { errEnvelope, okEnvelope } from '../envelope.js'; +import { validateQuery } from '../middleware/validate.js'; +import { parseActionSuffix } from './action-suffix.js'; + +interface ToolsRouteHost { + get( + path: string, + options: { preHandler: unknown[] } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler: unknown[] }, + handler: ( + req: { id: string; body: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +export function registerToolsRoutes( + app: ToolsRouteHost, + ix: IInstantiationService, +): void { + // GET /v1/tools ---------------------------------------------------------- + app.get( + '/v1/tools', + { preHandler: [validateQuery(listToolsQuerySchema)] }, + async (req, reply) => { + try { + const query = req.query as ListToolsQuery; + const tools = await ix.invokeFunction((a) => + a.get(IToolService).list(query.session_id), + ); + reply.send(okEnvelope({ tools }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); + + // GET /v1/mcp/servers ---------------------------------------------------- + app.get('/v1/mcp/servers', { preHandler: [] }, async (req, reply) => { + try { + const servers = await ix.invokeFunction((a) => a.get(IMcpService).list()); + reply.send(okEnvelope({ servers }, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }); + + // POST /v1/mcp/servers/{mcp_server_id}:restart --------------------------- + app.post( + '/v1/mcp/servers/:tail', + { preHandler: [] }, + async (req, reply) => { + try { + const { tail } = req.params as { tail: string }; + const parsed = parseActionSuffix({ + tail, + allowedActions: ['restart'] as const, + resourceLabel: 'mcp_server', + }); + if (parsed.kind === 'invalid') { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, parsed.reason, req.id), + ); + return; + } + if (parsed.kind === 'bare') { + // No bare form for /v1/mcp/servers/{id} — only :restart. + reply.send( + errEnvelope( + ErrorCode.VALIDATION_FAILED, + `unsupported action: ${tail}`, + req.id, + ), + ); + return; + } + const result = await ix.invokeFunction((a) => + a.get(IMcpService).restart(parsed.id), + ); + reply.send(okEnvelope(result, req.id)); + } catch (err) { + sendMappedError(reply, req.id, err); + } + }, + ); +} + +/** + * Map a thrown error to the right envelope. See module header for the table. + */ +function sendMappedError( + reply: { send(payload: unknown): unknown }, + requestId: string, + err: unknown, +): void { + if (err instanceof McpServerNotFoundError) { + reply.send(errEnvelope(ErrorCode.MCP_SERVER_NOT_FOUND, err.message, requestId)); + return; + } + throw err; +} + +// Reference `z` so eslint doesn't flag the import — currently unused beyond +// the schema's downstream consumers, but kept for future params validation. +void z; diff --git a/packages/daemon/src/services/approval-broker.ts b/packages/daemon/src/services/approval-broker.ts new file mode 100644 index 000000000..054116b7c --- /dev/null +++ b/packages/daemon/src/services/approval-broker.ts @@ -0,0 +1,304 @@ +/** + * `DaemonApprovalBroker` (W8.1 / Chain 5; was W4.4 stub). + * + * Reverse-RPC broker implementing the full path: + * + * 1. `request(req)` (called by `BridgeClientAPI.requestApproval` from + * `KimiCore`): + * - Mints `approval_id = ulid()` (daemon-allocated, REST path key). + * - Records `Map` for correlation (REST + * handler resolves by `approval_id`; we keep the original SDK + * `toolCallId` so the W3 stub-interface contract stays satisfied). + * - Builds protocol `ApprovalRequest` via the services adapter + * (`approvalToBrokerRequest`). + * - Broadcasts `event.approval.requested` through `IEventBus.publish` + * (which routes to all WS subscribers AND ring-buffers the event for + * replay). + * - Holds the Promise + 60s timer; on resolve, settles; on timeout, + * broadcasts `event.approval.expired` and rejects with + * `ApprovalExpiredError`. + * + * 2. `resolve(approvalId, response)` (called by the REST route): + * - Settles the Promise. + * - Broadcasts `event.approval.resolved` so all subscribers (including + * the originating client) see the answer. + * - Marks the id in `_recentlyResolved` so a subsequent REST call gets + * `40902 already_resolved` (vs `40404 not_found` for typo'd ids). + * + * **Synthetic event shape**: `event.approval.*` is NOT in agent-core's + * `AgentEvent` union (`packages/agent-core/src/rpc/events.ts:287-318`) — the + * daemon synthesizes them, same pattern as `prompt.completed` / + * `prompt.aborted` in `PromptServiceImpl`. The wire payload (per WS.md §4.5) + * carries the protocol-shaped `ApprovalRequest` fields directly at the top + * level of the event object (which becomes `envelope.payload` after the + * `DaemonEventBus.publish → buildEventEnvelope` wrap). + * + * **approval_id ↔ toolCallId correlation** (W8 design Q3): the in-process + * `IApprovalBroker` contract (`packages/services/src/interfaces/approval-broker.ts`) + * says `resolve(id, ...)`'s `id` matches `req.toolCallId`. The new REST path + * uses daemon-minted `approval_id`. We satisfy BOTH by indexing the pending + * map by `approvalId` (the daemon's authoritative key) and tracking + * `toolCallId` alongside for back-compat. The REST handler is the only + * `resolve()` caller in production today. + * + * **Anti-corruption**: this file imports `@moonshot-ai/services` (broker + * interface + adapter) and `@moonshot-ai/protocol` (Event type for the + * publish call). No direct node-sdk references — agent-core's in-process + * `ApprovalRequest`/`ApprovalResponse` flow through the services re-export. + */ + +import { ulid } from 'ulid'; + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { Event } from '@moonshot-ai/protocol'; +import { + IApprovalBroker, + IEventBus, + approvalToBrokerRequest, + type ApprovalRequest, + type ApprovalResponse, +} from '@moonshot-ai/services'; + +import type { ILogger } from './logger.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _typeAnchor: typeof IApprovalBroker = IApprovalBroker; + +/** Default 60s timeout per SCHEMAS §6.1. Overridable for tests. */ +export const APPROVAL_DEFAULT_TIMEOUT_MS = 60_000; + +/** Cap on the recently-resolved bookkeeping ring (idempotency window). */ +export const APPROVAL_RECENTLY_RESOLVED_CAP = 1024; + +/** + * Thrown when the 60s timer fires before `resolve()` is called. + * + * agent-core's promise chain treats this as "no answer" — the calling tool + * surfaces it upstream. The error type is identifiable so unit tests can + * distinguish timeout vs other rejections. + */ +export class ApprovalExpiredError extends Error { + constructor(public readonly approvalId: string, timeoutMs: number) { + super(`approval ${approvalId} expired after ${timeoutMs}ms`); + this.name = 'ApprovalExpiredError'; + } +} + +interface PendingApproval { + readonly approvalId: string; + readonly sessionId: string; + readonly toolCallId: string; + readonly createdAt: string; + readonly expiresAt: string; + resolve: (r: ApprovalResponse) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; +} + +export interface DaemonApprovalBrokerOptions { + /** Test override — defaults to 60s. */ + timeoutMs?: number; + /** Test override — defaults to 1024. */ + recentlyResolvedCap?: number; +} + +export class DaemonApprovalBroker extends Disposable implements IApprovalBroker { + /** Indexed by daemon-minted `approval_id` (REST path key). */ + private readonly _pending = new Map(); + /** Reverse lookup for `toolCallId` (legacy stub-interface compatibility). */ + private readonly _byToolCallId = new Map(); + /** + * Bounded set of recently-resolved approval ids. REST re-POST on a resolved + * id returns 40902 (vs 40404 for never-existed). FIFO eviction at + * `_recentlyResolvedCap`. + */ + private readonly _recentlyResolved = new Set(); + private readonly _timeoutMs: number; + private readonly _recentlyResolvedCap: number; + + constructor( + private readonly logger: ILogger, + private readonly eventBus: IEventBus, + options: DaemonApprovalBrokerOptions = {}, + ) { + super(); + this._timeoutMs = options.timeoutMs ?? APPROVAL_DEFAULT_TIMEOUT_MS; + this._recentlyResolvedCap = + options.recentlyResolvedCap ?? APPROVAL_RECENTLY_RESOLVED_CAP; + } + + async request( + req: ApprovalRequest & { sessionId: string; agentId: string }, + ): Promise { + if (this._isDisposed) { + throw new Error('approval broker disposed'); + } + + const approvalId = ulid(); + const createdAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() + this._timeoutMs).toISOString(); + + const protocolRequest = approvalToBrokerRequest(req, { + approvalId, + sessionId: req.sessionId, + createdAt, + expiresAt, + }); + + // Synthesize the wire event. The event union accepts arbitrary `type` + // strings (see PromptServiceImpl precedent); we spread the protocol + // request fields at top level so envelope.payload carries them directly + // (WS.md §4.5: payload IS ApprovalRequest). `sessionId` (camelCase) is + // required for `DaemonEventBus.extractSessionId` routing. + const event: Event = { + type: 'event.approval.requested', + sessionId: req.sessionId, + agentId: req.agentId, + ...protocolRequest, + } as unknown as Event; + + // Broadcast the request BEFORE awaiting — `publish` is synchronous + // (fan-out + ring-buffer entry) so subscribers see this frame before any + // resolve/timeout follow-up. + this.eventBus.publish(event); + + this.logger.info( + { + approvalId, + sessionId: req.sessionId, + agentId: req.agentId, + toolCallId: req.toolCallId, + }, + 'approval requested', + ); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => this._expire(approvalId), this._timeoutMs); + timer.unref?.(); + this._pending.set(approvalId, { + approvalId, + sessionId: req.sessionId, + toolCallId: req.toolCallId, + createdAt, + expiresAt, + resolve, + reject, + timer, + }); + this._byToolCallId.set(req.toolCallId, approvalId); + }); + } + + /** + * Settle a pending approval by `approval_id`. Broadcasts + * `event.approval.resolved` BEFORE settling the Promise so subscribers + * observe the resolution in order with downstream events. Silent no-op for + * unknown ids — REST routes pre-check via `isPending()` and emit + * 40404 / 40902. + */ + resolve(id: string, response: ApprovalResponse): void { + const p = this._pending.get(id); + if (!p) return; + clearTimeout(p.timer); + this._pending.delete(id); + this._byToolCallId.delete(p.toolCallId); + this.markResolved(p.approvalId); + + const resolvedAt = new Date().toISOString(); + const resolvedEvent: Event = { + type: 'event.approval.resolved', + sessionId: p.sessionId, + agentId: 'main', + approval_id: p.approvalId, + decision: response.decision, + scope: response.scope, + feedback: response.feedback, + selected_label: response.selectedLabel, + resolved_at: resolvedAt, + } as unknown as Event; + this.eventBus.publish(resolvedEvent); + + p.resolve(response); + } + + /** + * Has-pending check used by REST routes to discriminate `40404 not_found` + * (never-existed-or-expired) vs proceed-to-resolve. Pairs with + * `isRecentlyResolved` for `40902 already_resolved`. + */ + isPending(approvalId: string): boolean { + return this._pending.has(approvalId); + } + + /** + * Has-recently-resolved check used by REST routes to emit + * `40902 already_resolved` on idempotent re-POST. + */ + isRecentlyResolved(approvalId: string): boolean { + return this._recentlyResolved.has(approvalId); + } + + /** + * Mark an id as resolved for idempotency. Called automatically by + * `resolve()`; exposed publicly so the REST route can also stamp the + * idempotency mark on the route-level idempotent path (no-op if already + * marked). + */ + markResolved(approvalId: string): void { + if (this._recentlyResolved.size >= this._recentlyResolvedCap) { + // FIFO-ish eviction: drop the first inserted entry. Set iteration order + // is insertion order in ES2015+, so `next().value` gives the oldest. + const oldest = this._recentlyResolved.values().next().value; + if (oldest !== undefined) this._recentlyResolved.delete(oldest); + } + this._recentlyResolved.add(approvalId); + } + + /** Test helper — number of pending approvals (0 by default). */ + _pendingCountForTest(): number { + return this._pending.size; + } + + /** Test helper — pending entry snapshot for assertions. */ + _peekPendingForTest(approvalId: string): { sessionId: string; toolCallId: string } | undefined { + const p = this._pending.get(approvalId); + if (!p) return undefined; + return { sessionId: p.sessionId, toolCallId: p.toolCallId }; + } + + private _expire(approvalId: string): void { + const p = this._pending.get(approvalId); + if (!p) return; + this._pending.delete(approvalId); + this._byToolCallId.delete(p.toolCallId); + // Mark as resolved-style for idempotency — a late REST resolve on this id + // gets 40902 rather than 40404 (matches "expired ≈ already_resolved" UX). + this.markResolved(p.approvalId); + + const expiredEvent: Event = { + type: 'event.approval.expired', + sessionId: p.sessionId, + agentId: 'main', + approval_id: p.approvalId, + } as unknown as Event; + this.eventBus.publish(expiredEvent); + + p.reject(new ApprovalExpiredError(p.approvalId, this._timeoutMs)); + } + + override dispose(): void { + if (this._isDisposed) return; + for (const [, p] of this._pending) { + clearTimeout(p.timer); + try { + p.reject(new Error('daemon shutting down')); + } catch { + // ignore — the awaiter may not have a catch handler attached yet. + } + } + this._pending.clear(); + this._byToolCallId.clear(); + this._recentlyResolved.clear(); + super.dispose(); + } +} diff --git a/packages/daemon/src/services/connection-registry.ts b/packages/daemon/src/services/connection-registry.ts new file mode 100644 index 000000000..dc9f82437 --- /dev/null +++ b/packages/daemon/src/services/connection-registry.ts @@ -0,0 +1,93 @@ +/** + * `IConnectionRegistry` (W5.1 / P0.15) — flat registry of live WS connections. + * + * The registry owns a `Map` and serves three roles: + * + * 1. Lookup by `connId` (W5+ broadcast paths and operator commands). + * 2. Bulk close on shutdown (`closeAll(reason)`) — invoked by + * `WSGateway.dispose()` so connections are torn down BEFORE EventBus / + * brokers, ensuring no broker emits into a closed socket. + * 3. Size accounting (test assertions + observability). + * + * Construction-order positioning: registered AFTER `IRestGateway` and BEFORE + * `ISessionClientsService` / `IEventBus`. Under the reverse-construction + * dispose chain this means the registry tears down LATER than the gateway + * (which closes all sockets via the registry first), but EARLIER than the + * brokers/logger. Concretely: `WSGateway.dispose() → registry.closeAll()` then + * registry.dispose() is a no-op (registry already empty). + * + * `dispose()` is defensive: if WSGateway didn't run for any reason (failed + * mid-boot) we still close any straggler connections so the daemon process + * can exit cleanly. + */ + +import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; + +import type { WsConnection } from '../ws/connection.js'; + +export interface IConnectionRegistry { + /** Insert a freshly-handshaken connection. */ + add(conn: WsConnection): void; + /** Remove a closed connection. Idempotent. */ + remove(connId: string): void; + /** Look up by id. */ + get(connId: string): WsConnection | undefined; + /** Iterate all currently-attached connections. */ + values(): Iterable; + /** + * Close every attached connection with WS close code 1001 (going away) and + * the given reason. Used by `WSGateway.dispose()` before brokers tear down. + */ + closeAll(reason?: string): void; + /** Number of currently-attached connections. */ + size(): number; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IConnectionRegistry = createDecorator('IConnectionRegistry'); + +export class ConnectionRegistry extends Disposable implements IConnectionRegistry { + private readonly _conns = new Map(); + + add(c: WsConnection): void { + this._conns.set(c.id, c); + } + + remove(id: string): void { + this._conns.delete(id); + } + + get(id: string): WsConnection | undefined { + return this._conns.get(id); + } + + values(): Iterable { + return this._conns.values(); + } + + closeAll(reason = 'daemon shutting down'): void { + // Snapshot first — `close(...)` triggers the WS `'close'` listener which + // calls `remove(...)` on this registry, mutating `_conns` mid-iteration. + const snapshot = Array.from(this._conns.values()); + this._conns.clear(); + for (const c of snapshot) { + try { + c.close(1001, reason); + } catch { + // ignore — defensive teardown + } + } + } + + size(): number { + return this._conns.size; + } + + override dispose(): void { + if (this._isDisposed) return; + // Belt-and-suspenders: WSGateway.dispose() already called closeAll(). + // Idempotent. + this.closeAll(); + super.dispose(); + } +} diff --git a/packages/daemon/src/services/event-bus.ts b/packages/daemon/src/services/event-bus.ts new file mode 100644 index 000000000..a502a20a1 --- /dev/null +++ b/packages/daemon/src/services/event-bus.ts @@ -0,0 +1,258 @@ +/** + * `DaemonEventBus` (W5.2 / P0.16, extended W7.2 with lifecycle observers) — + * WS-broadcasting event bus. + * + * Replaces the W4 stub (queue + `_drainForTest`) entirely. `publish(event)` + * now: + * 1. Extracts `session_id` from the agent-core `Event` (which carries + * `sessionId` camelCase per `agent-core/src/rpc/events.ts:320`). + * 2. Increments the per-session `seq` counter (monotonic, starts at 1). + * 3. Appends `{seq, envelope}` to the per-session ring buffer (capacity + * enforced in W5.3 at 1000; W5.2 keeps the buffer unbounded as a + * transitional step). + * 4. Fans out to every WS connection subscribed via `ISessionClientsService`. + * 5. **W7.2**: invokes any attached `IPromptLifecycleObserver`s synchronously + * after fan-out. Each may return zero or more derived events which the + * bus then publishes recursively. This is the mechanism that synthesizes + * `prompt.completed` / `prompt.aborted` from `turn.ended` (agent-core's + * event union has no prompt-lifecycle types; see W7 §critical discovery + * point #2). + * + * Events without a `sessionId` (none are expected today — every agent-core + * Event extends `AgentEvent & { agentId, sessionId }`) are dropped with a + * warn log. We don't broadcast globally to avoid silent fan-out leaks. + * + * **Ring buffer state is per-session**: `Map` so + * different sessions count independently. WS.md §6: each session has its own + * `nextSeq` starting at 1. + * + * **`getBufferedSince(sid, lastSeq)`** is the replay primitive consumed by + * `WsConnection` (W5.3) for `client_hello.last_seq_by_session`. W5.2 ships + * the API but no cap enforcement; W5.3 enforces the 1000-event cap and + * tracks `oldestSeq` for the `resync_required` decision. + * + * **Anti-corruption**: Event payload comes from `@moonshot-ai/protocol`'s + * re-export of agent-core, NOT from the SDK package directly. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { Event } from '@moonshot-ai/protocol'; +import { IEventBus, type IPromptLifecycleObserver } from '@moonshot-ai/services'; + +import type { ILogger } from './logger.js'; +import type { ISessionClientsService } from './session-clients.js'; + +import { buildEventEnvelope, type EventEnvelope } from '../ws/protocol.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _typeAnchor: typeof IEventBus = IEventBus; // keep `implements` retained + +interface BufferEntry { + seq: number; + envelope: EventEnvelope; +} + +interface SessionState { + /** Highest `seq` dispatched. Starts at 0; first event gets `seq=1`. */ + seq: number; + /** Append-only ring buffer; W5.3 caps at `maxBufferSize`. */ + buffer: BufferEntry[]; + /** Lowest `seq` still in `buffer`. W5.3 increments when evicting. */ + oldestSeq: number; +} + +export interface BufferedSinceResult { + events: BufferEntry[]; + /** + * True iff `lastSeq + 1 < oldestSeq` (the client's gap is older than what + * the buffer retains). The connection should send a `resync_required` + * frame for this session and NOT replay events. + */ + resyncRequired: boolean; + /** Highest dispatched `seq` for the session (0 if no events yet). */ + currentSeq: number; +} + +export interface DaemonEventBusOptions { + /** Ring buffer cap per session. W5.2 ignores this; W5.3 enforces it. */ + maxBufferSize?: number; +} + +/** Default ring buffer cap (WS.md §3.1, §6). */ +export const DEFAULT_MAX_BUFFER_SIZE = 1000; + +export class DaemonEventBus extends Disposable implements IEventBus { + private readonly _sessions = new Map(); + private readonly _maxBufferSize: number; + private readonly _observers = new Set(); + + constructor( + private readonly logger: ILogger, + private readonly sessionClients: ISessionClientsService, + options: DaemonEventBusOptions = {}, + ) { + super(); + this._maxBufferSize = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; + } + + /** + * W7.2 — attach a lifecycle observer. The observer's `observeEvent(event)` + * is called synchronously AFTER fan-out to subscribers; any derived events + * it returns are recursively published. + * + * Returns an idempotent detach function. Observers should NOT depend on + * attach order (today there's only one observer, the prompt service). + */ + addObserver(observer: IPromptLifecycleObserver): () => void { + this._observers.add(observer); + let detached = false; + return () => { + if (detached) return; + detached = true; + this._observers.delete(observer); + }; + } + + publish(event: Event): void { + if (this._isDisposed) return; + const sid = extractSessionId(event); + if (!sid) { + this.logger.warn( + { eventType: (event as { type?: string }).type ?? 'unknown' }, + 'event has no session_id; dropping', + ); + return; + } + const state = this._getOrCreateSession(sid); + state.seq += 1; + const envelope = buildEventEnvelope(state.seq, sid, event); + state.buffer.push({ seq: state.seq, envelope }); + + // Ring buffer cap (W5.3 behavior; W5.2 still ships the same enforcement + // because the API needs to be self-consistent even before + // `getBufferedSince` returns `resyncRequired=true`). + while (state.buffer.length > this._maxBufferSize) { + const evicted = state.buffer.shift(); + if (evicted) state.oldestSeq = evicted.seq + 1; + } + + // Fan-out to subscribers. `getConnections` returns an iterable view; we + // capture into an array to avoid mutating-iterator hazards if a send() + // synchronously triggers a forgetConnection (e.g. socket error → close). + const targets = Array.from(this.sessionClients.getConnections(sid)); + for (const conn of targets) { + conn.send(envelope); + } + + // W7.2 — run lifecycle observers AFTER fan-out so subscribers see the + // original event first, then any synthesized follow-ups. Each observer + // returns zero or more derived events; we publish each recursively. Errors + // in one observer don't block the others (logged and swallowed). + if (this._observers.size > 0) { + for (const observer of Array.from(this._observers)) { + let derived: readonly Event[]; + try { + derived = observer.observeEvent(event); + } catch (err) { + this.logger.warn( + { err: String(err) }, + 'prompt-lifecycle observer threw; ignoring', + ); + continue; + } + for (const ev of derived) { + this.publish(ev); + } + } + } + } + + /** + * Fetch buffered events with `seq > lastSeq` for `sid`. + * + * Result interpretation (per WS.md §6): + * - `currentSeq == 0` (session has no events yet) → empty replay, + * `resyncRequired=false`. + * - `lastSeq >= currentSeq` → client is caught up, empty replay, + * `resyncRequired=false`. + * - `lastSeq + 1 < oldestSeq` → buffer evicted past the client's + * position → `resyncRequired=true`, empty events. + * - otherwise → events with `seq > lastSeq`, in order. + * + * Sessions never seen by `publish` return `currentSeq=0, events=[], + * resyncRequired=false` — there's nothing to resync FROM, the session + * just hasn't emitted yet. + */ + getBufferedSince(sid: string, lastSeq: number): BufferedSinceResult { + const state = this._sessions.get(sid); + if (!state) { + return { events: [], resyncRequired: false, currentSeq: 0 }; + } + if (lastSeq >= state.seq) { + return { events: [], resyncRequired: false, currentSeq: state.seq }; + } + if (lastSeq + 1 < state.oldestSeq) { + return { events: [], resyncRequired: true, currentSeq: state.seq }; + } + const events = state.buffer.filter((e) => e.seq > lastSeq); + return { events, resyncRequired: false, currentSeq: state.seq }; + } + + /** + * Highest dispatched `seq` for the session (0 if never published). + * Public companion to `_currentSeqForTest` — used by the WS abort handler + * to populate `at_seq` in the idempotent-abort ack (W7.3). + */ + currentSeq(sid: string): number { + return this._sessions.get(sid)?.seq ?? 0; + } + + /** Test helper — current seq for a session (0 if never published). */ + _currentSeqForTest(sid: string): number { + return this._sessions.get(sid)?.seq ?? 0; + } + + /** Test helper — buffer length for a session (0 if never published). */ + _bufferLengthForTest(sid: string): number { + return this._sessions.get(sid)?.buffer.length ?? 0; + } + + /** Test helper — oldestSeq tracked for a session (0 if never published). */ + _oldestSeqForTest(sid: string): number { + return this._sessions.get(sid)?.oldestSeq ?? 0; + } + + private _getOrCreateSession(sid: string): SessionState { + let state = this._sessions.get(sid); + if (!state) { + state = { seq: 0, buffer: [], oldestSeq: 1 }; + this._sessions.set(sid, state); + } + return state; + } + + override dispose(): void { + if (this._isDisposed) return; + this._observers.clear(); + this._sessions.clear(); + super.dispose(); + } +} + +/** + * Pull a session id off an Event. agent-core's Event union is `AgentEvent & + * { agentId, sessionId }` (camelCase) per + * `packages/agent-core/src/rpc/events.ts:320`. WS wire format is + * `session_id` (snake_case) — the toWire mapping (WS.md §7.5) is a Phase 2 + * concern; for Stage 1 the inbound side is the agent-core camelCase shape. + * + * We accept both `sessionId` and `session_id` defensively so tests can pass + * either spelling, and so future wire-mapped events still extract correctly. + */ +function extractSessionId(event: Event): string | undefined { + const camel = (event as { sessionId?: unknown }).sessionId; + if (typeof camel === 'string' && camel.length > 0) return camel; + const snake = (event as { session_id?: unknown }).session_id; + if (typeof snake === 'string' && snake.length > 0) return snake; + return undefined; +} diff --git a/packages/daemon/src/services/file-store.ts b/packages/daemon/src/services/file-store.ts new file mode 100644 index 000000000..a13bf717b --- /dev/null +++ b/packages/daemon/src/services/file-store.ts @@ -0,0 +1,376 @@ +/** + * `IFileStore` — daemon-OWN files store (W12.2 / Chain 15, P1.15). + * + * **Responsibility**: persist uploaded blobs under `~/.kimi/files/`, + * maintain a JSON index of `FileMeta` records, and serve them back by + * `file_id` for download / delete. Streams writes (no in-memory + * buffering) and enforces the 50MB size cap DURING the streaming write + * — abort on overrun, then delete the partial blob. + * + * **Daemon-OWN distinction**: like `IFsService` / `IFsWatcher`, the + * store is NOT a thin wrapper around an `IHarnessBridge` call. + * agent-core has no upload surface; the wire path directly addresses + * the local filesystem. Lives in `packages/daemon`. + * + * # Storage layout + * + * /files/ # blob (raw bytes) + * /files/index.json # array of FileMeta + * + * `homeDir` defaults to `os.homedir()/.kimi`; the WS / REST adapter + * passes `bridgeOptions.homeDir` so tests can isolate the store under + * a tmpdir. + * + * The index is read once into memory on first access (lazy) and + * written-on-mutate. The blob file is the source of truth for bytes; + * the index is the source of truth for metadata. If the two get out of + * sync (e.g. a stray blob without an index entry, or vice versa) we + * log a warning at load time and let the next mutation reconcile. + * + * # Errors + * + * - `FileNotFoundError` → routed to `40407 file.not_found`. + * - `FileTooLargeError` → routed to `41301 file.too_large` (>50MB). + * - Other I/O errors → routed to `50001 internal`. + * + * # Size cap (50MB) enforcement + * + * The route handler streams the multipart `file` field directly into + * `fs.createWriteStream(blobPath)`. We attach a `'data'` listener that + * tracks `bytesWritten` and aborts the write (closing both streams + + * unlinking the partial blob) on overrun. The route translates the + * abort signal to `FileTooLargeError`. + * + * # Anti-corruption + * + * Imports `node:fs`, `node:path`, `node:os`, `node:crypto`, + * `node:stream/promises`, agent-core (`Disposable` + decorator), and the + * protocol `FileMeta` type. ZERO SDK imports. + */ + +import { createWriteStream, promises as fsp } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import type { Readable } from 'node:stream'; + +import { ulid } from 'ulid'; + +import { + Disposable, + createDecorator, +} from '@moonshot-ai/agent-core'; + +import type { FileMeta } from '@moonshot-ai/protocol'; + +import { ILogger } from './logger.js'; + +/* ------------------------------------------------------------------------- + * Tunable constants + * ----------------------------------------------------------------------- */ + +/** REST.md §3.10 + ROADMAP Chain 15 AC #2 — upload cap (50 MB). */ +export const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024; + +/* ------------------------------------------------------------------------- + * Error sentinels + * ----------------------------------------------------------------------- */ + +/** Thrown when `file_id` doesn't exist in the index. Mapped to 40407. */ +export class FileNotFoundError extends Error { + readonly fileId: string; + constructor(fileId: string) { + super(`file not found: ${fileId}`); + this.name = 'FileNotFoundError'; + this.fileId = fileId; + } +} + +/** + * Thrown when a streaming upload would exceed `maxUploadBytes`. The + * route layer maps this to envelope `code: 41301 file.too_large`. + * + * On throw, the implementation MUST have already aborted the + * underlying writes and unlinked the partial blob — callers don't need + * to clean up. + */ +export class FileTooLargeError extends Error { + readonly limit: number; + readonly seen: number; + constructor(seen: number, limit: number) { + super(`upload size ${seen} bytes exceeds limit ${limit} bytes`); + this.name = 'FileTooLargeError'; + this.seen = seen; + this.limit = limit; + } +} + +/* ------------------------------------------------------------------------- + * Service interface (DI decorator) + * ----------------------------------------------------------------------- */ + +export interface SaveOptions { + /** Daemon-side filename override (multipart `name` field). */ + name?: string; + /** Multipart `mimetype`; defaults to `application/octet-stream`. */ + mimeType?: string; + /** Optional expiry seconds (deferred GC — reserved for a later phase). */ + expiresInSec?: number; +} + +export interface GetResult { + meta: FileMeta; + blobPath: string; +} + +export interface IFileStore { + /** + * Stream `source` to disk under a fresh `file_id`. Enforces the size + * cap during streaming; throws `FileTooLargeError` on overrun and + * leaves NO partial blob on disk. Returns the persisted FileMeta. + * + * `filename` is preserved verbatim (used for `Content-Disposition`). + */ + save(source: Readable, filename: string, options?: SaveOptions): Promise; + + /** + * Look up by `file_id`. Throws `FileNotFoundError` if absent. The + * returned `blobPath` is suitable for `fs.createReadStream`. + */ + get(fileId: string): Promise; + + /** + * Idempotent delete. Throws `FileNotFoundError` if `file_id` is + * not present (per REST.md §3.10 — `DELETE` returns 40407 for + * unknown ids). + */ + delete(fileId: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IFileStore = createDecorator('IFileStore'); + +/* ------------------------------------------------------------------------- + * Implementation + * ----------------------------------------------------------------------- */ + +export interface FileStoreOptions { + /** + * Base directory containing the `files/` subdir + `index.json`. In + * production this is `/.kimi` or the OS home; + * tests pass a tmpdir under `~/.kimi-test-...`. + */ + homeDir?: string; + /** Override the 50 MB cap (tests set this to something tiny). */ + maxUploadBytes?: number; +} + +interface IndexFile { + version: 1; + files: FileMeta[]; +} + +export class FileStoreImpl extends Disposable implements IFileStore { + private readonly baseDir: string; + private readonly indexPath: string; + private readonly maxUploadBytes: number; + private indexCache: Map | undefined; + private indexLoadPromise: Promise | undefined; + + constructor( + // P2.6: static-first / services-last. `options` carries `homeDir` + // + `maxUploadBytes`; @ILogger auto-injects. The inline default on + // options is dropped (required `@ILogger` can't follow an optional + // param); start.ts passes `{}` explicitly when no overrides apply. + options: FileStoreOptions, + @ILogger private readonly logger: ILogger, + ) { + super(); + const home = options.homeDir ?? join(homedir(), '.kimi'); + this.baseDir = join(home, 'files'); + this.indexPath = join(this.baseDir, 'index.json'); + this.maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES; + } + + async save( + source: Readable, + filename: string, + options: SaveOptions = {}, + ): Promise { + await this.ensureIndex(); + const fileId = `f_${ulid()}`; + const blobPath = join(this.baseDir, fileId); + + // Track bytes during streaming to enforce the size cap. We + // intercept on the source via `'data'`, abort the writable if the + // limit trips, and `unlink` the partial blob in the catch path. + let bytes = 0; + let aborted = false; + let abortReason: Error | undefined; + + const writable = createWriteStream(blobPath); + + source.on('data', (chunk: Buffer | string) => { + const len = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + bytes += len; + if (!aborted && bytes > this.maxUploadBytes) { + aborted = true; + abortReason = new FileTooLargeError(bytes, this.maxUploadBytes); + // `destroy(err)` propagates the error through `pipeline`. + source.destroy(abortReason); + } + }); + + try { + await pipeline(source, writable); + } catch (err) { + // Clean up any partial blob — best-effort, swallow ENOENT. + try { + await fsp.unlink(blobPath); + } catch { + /* ignore */ + } + if (abortReason) throw abortReason; + throw err; + } + + // Re-stat to capture the final size on disk (the pipeline may have + // counted differently if upstream re-chunked). + const stat = await fsp.stat(blobPath); + const meta: FileMeta = { + id: fileId, + name: options.name ?? filename, + media_type: options.mimeType ?? 'application/octet-stream', + size: stat.size, + created_at: new Date().toISOString(), + ...(options.expiresInSec !== undefined + ? { + expires_at: new Date( + Date.now() + options.expiresInSec * 1000, + ).toISOString(), + } + : {}), + }; + + this.indexCache!.set(meta.id, meta); + await this.writeIndex(); + return meta; + } + + async get(fileId: string): Promise { + await this.ensureIndex(); + const meta = this.indexCache!.get(fileId); + if (!meta) { + throw new FileNotFoundError(fileId); + } + const blobPath = join(this.baseDir, fileId); + // Verify the blob actually exists; if it disappeared on disk we + // raise FileNotFoundError too (treat the missing blob as + // equivalent to a missing id from the client's POV). + try { + await fsp.access(blobPath); + } catch { + this.logger.warn( + { fileId }, + 'file index says present but blob missing; reporting 40407', + ); + this.indexCache!.delete(fileId); + await this.writeIndex(); + throw new FileNotFoundError(fileId); + } + return { meta, blobPath }; + } + + async delete(fileId: string): Promise { + await this.ensureIndex(); + if (!this.indexCache!.has(fileId)) { + throw new FileNotFoundError(fileId); + } + const blobPath = join(this.baseDir, fileId); + this.indexCache!.delete(fileId); + try { + await fsp.unlink(blobPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + // Restore the index entry so the next call can re-attempt. + // We don't have the meta here; re-throw to surface as 50001. + throw err; + } + // ENOENT — blob missing but index had it. Continue (we've + // already dropped the index entry); the writeIndex below makes + // the deletion stick. + } + await this.writeIndex(); + } + + /* ----------------------------------------------------------- internals */ + + /** + * Lazy index loader. Concurrency-safe (a second call before the + * first resolves returns the same in-flight Promise). + */ + private ensureIndex(): Promise { + if (this.indexCache !== undefined) return Promise.resolve(); + if (this.indexLoadPromise !== undefined) return this.indexLoadPromise; + this.indexLoadPromise = this.loadIndex().finally(() => { + this.indexLoadPromise = undefined; + }); + return this.indexLoadPromise; + } + + private async loadIndex(): Promise { + await fsp.mkdir(this.baseDir, { recursive: true }); + let raw: string; + try { + raw = await fsp.readFile(this.indexPath, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + // First-run: empty index. + this.indexCache = new Map(); + return; + } + throw err; + } + let parsed: IndexFile; + try { + parsed = JSON.parse(raw) as IndexFile; + } catch (err) { + this.logger.warn( + { err: String(err) }, + 'file-store index.json malformed; starting empty', + ); + this.indexCache = new Map(); + return; + } + const map = new Map(); + if (parsed && Array.isArray(parsed.files)) { + for (const f of parsed.files) { + if (f && typeof f.id === 'string') { + map.set(f.id, f); + } + } + } + this.indexCache = map; + } + + private async writeIndex(): Promise { + const cache = this.indexCache; + if (!cache) return; + const payload: IndexFile = { + version: 1, + files: Array.from(cache.values()), + }; + await fsp.mkdir(this.baseDir, { recursive: true }); + const tmpPath = `${this.indexPath}.tmp`; + await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf8'); + await fsp.rename(tmpPath, this.indexPath); + } + + override dispose(): void { + if (this._isDisposed) return; + this.indexCache = undefined; + super.dispose(); + } +} diff --git a/packages/daemon/src/services/fs-git.ts b/packages/daemon/src/services/fs-git.ts new file mode 100644 index 000000000..271458a02 --- /dev/null +++ b/packages/daemon/src/services/fs-git.ts @@ -0,0 +1,313 @@ +/** + * `IFsGitService` — daemon-OWN git status for the session cwd (W11 / Chain 12). + * + * Single endpoint: `:git_status`. Shell out `git status --porcelain=v1 --branch` + * and parse the stable machine-readable output. The wire shape matches + * REST.md §3.9 line 660-669: + * + * { branch, ahead, behind, entries: { [path]: GitStatus } } + * + * The `GitStatus` enum (`'clean' | 'modified' | 'added' | 'deleted' | + * 'renamed' | 'untracked' | 'ignored' | 'conflicted'`) is the SCHEMAS §9.2 + * line 521 wire enum, reused from W10. We collapse the porcelain XY status + * pair (index + worktree) to a single value using a priority ladder: + * + * conflicted > deleted > modified > renamed > added > untracked > ignored + * + * **Non-git detection**: we shell out `git rev-parse --is-inside-work-tree` + * FIRST. If it exits non-zero (no git present, or cwd is not in a worktree) + * we throw `FsGitUnavailableError` → routes map to 40908. We do NOT match + * stderr text — exit-code-based detection is locale-independent. + * + * **Performance** (ROADMAP Chain 12 AC #2): the porcelain parse runs in + * ~tens of milliseconds on the kimi-code repo (~200 entries). 300ms target. + * + * **Path filter**: when client passes `paths`, we resolve each via + * `resolveSafePath` and intersect the porcelain output. Out-of-tree paths + * raise 41304 batch-wide (same posture as `:stat_many` from W10). + * + * **Anti-corruption**: imports `node:child_process`, `node:path`, + * `ISessionService`, `ILogger`. ZERO SDK imports. + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + createDecorator, + Disposable, + type IDisposable, +} from '@moonshot-ai/agent-core'; +import { + ISessionService, + SessionNotFoundError, +} from '@moonshot-ai/services'; +import type { + FsGitStatus, + FsGitStatusRequest, + FsGitStatusResponse, +} from '@moonshot-ai/protocol'; + +import { + FsPathEscapesError, + resolveSafePath, +} from './fs-path-safety.js'; + +// --------------------------------------------------------------------------- +// Error sentinels +// --------------------------------------------------------------------------- + +export class FsGitUnavailableError extends Error { + readonly cwd: string; + readonly detail: string; + constructor(cwd: string, detail: string) { + super(`fs.git_unavailable: ${cwd} (${detail})`); + this.name = 'FsGitUnavailableError'; + this.cwd = cwd; + this.detail = detail; + } +} + +// --------------------------------------------------------------------------- +// Public interface + decorator +// --------------------------------------------------------------------------- + +export interface IFsGitService extends IDisposable { + status( + sessionId: string, + req: FsGitStatusRequest, + ): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IFsGitService = createDecorator('IFsGitService'); + +// --------------------------------------------------------------------------- +// FsGitServiceImpl +// --------------------------------------------------------------------------- + +export class FsGitServiceImpl extends Disposable implements IFsGitService { + constructor(@ISessionService protected readonly sessions: ISessionService) { + super(); + } + + async status( + sessionId: string, + req: FsGitStatusRequest, + ): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const realCwd = await fs.realpath(cwd); + + // Resolve any client-supplied path filter through the W10 safety + // guard. Out-of-tree paths fail the whole call with 41304 (matches + // `:stat_many`'s posture from W10). + let filterSet: Set | undefined; + if (req.paths !== undefined && req.paths.length > 0) { + filterSet = new Set(); + for (const p of req.paths) { + const safe = await resolveSafePath(realCwd, p); + filterSet.add(safe.relative); + } + } + + // Step 1 — check we're inside a git worktree. Locale-independent via + // exit code (no stderr text match). + const insideRes = await runCommand('git', ['rev-parse', '--is-inside-work-tree'], realCwd); + if (insideRes.exitCode !== 0 || insideRes.stdout.trim() !== 'true') { + throw new FsGitUnavailableError( + realCwd, + insideRes.stderr.trim() || `git rev-parse exit ${insideRes.exitCode}`, + ); + } + + // Step 2 — run porcelain. `--porcelain=v1 --branch -z` would give NUL + // separators (handy for newlines in paths), but v1 plain newline output + // is plenty for our wire shape. We use `--no-renames` is NOT supplied + // so renames surface as `R`; we surface as `renamed` per SCHEMAS enum. + const porcRes = await runCommand( + 'git', + ['status', '--porcelain=v1', '--branch'], + realCwd, + ); + if (porcRes.exitCode !== 0) { + // Treat unexpected `git status` failures as unavailable — bare repo, + // corrupted index, etc. The wire code is still 40908. + throw new FsGitUnavailableError( + realCwd, + porcRes.stderr.trim() || `git status exit ${porcRes.exitCode}`, + ); + } + + return parsePorcelain(porcRes.stdout, filterSet); + } +} + +// =========================================================================== +// Porcelain parser +// =========================================================================== + +/** + * Parse `git status --porcelain=v1 --branch` output. + * + * The first line is the branch header: `## ... [ahead N, behind M]` + * Subsequent lines are entries: 2-char XY status, space, path. + * Renames are: `R src/old.ts -> src/new.ts` + * + * We collapse XY → single GitStatus per the priority ladder documented at + * top of file. + */ +export function parsePorcelain( + stdout: string, + filter: Set | undefined, +): FsGitStatusResponse { + const lines = stdout.split('\n'); + let branch = ''; + let ahead = 0; + let behind = 0; + const entries: Record = {}; + + for (const line of lines) { + if (line.length === 0) continue; + if (line.startsWith('## ')) { + const parsed = parseBranchHeader(line.slice(3)); + branch = parsed.branch; + ahead = parsed.ahead; + behind = parsed.behind; + continue; + } + // Status entry. The first 2 chars are XY (index, worktree). + if (line.length < 4) continue; + const xy = line.slice(0, 2); + let rest = line.slice(3); + // Renames: `R src/old.ts -> src/new.ts` — keep the destination as the + // tracked path; the source is implicit. + if (xy.startsWith('R') || xy.startsWith('C')) { + const arrow = rest.indexOf(' -> '); + if (arrow >= 0) { + rest = rest.slice(arrow + 4); + } + } + const wirePath = posix(rest.trim()); + if (filter !== undefined && !filter.has(wirePath)) continue; + const status = collapseXY(xy); + entries[wirePath] = status; + } + + return { branch, ahead, behind, entries }; +} + +function parseBranchHeader(rest: string): { + branch: string; + ahead: number; + behind: number; +} { + // `## main...origin/main [ahead 2, behind 1]` + // `## main` + // `## HEAD (no branch)` + // `## No commits yet on main` + if (rest.startsWith('HEAD (no branch)')) { + return { branch: '', ahead: 0, behind: 0 }; + } + if (rest.startsWith('No commits yet on ')) { + return { branch: rest.slice('No commits yet on '.length), ahead: 0, behind: 0 }; + } + let branch = rest; + let ahead = 0; + let behind = 0; + // Strip the bracketed ahead/behind suffix. + const bracket = rest.indexOf(' ['); + if (bracket >= 0) { + branch = rest.slice(0, bracket); + const sliced = rest.slice(bracket + 2, rest.length - 1); + const aheadMatch = sliced.match(/ahead (\d+)/); + const behindMatch = sliced.match(/behind (\d+)/); + if (aheadMatch !== null) ahead = Number.parseInt(aheadMatch[1] ?? '0', 10) || 0; + if (behindMatch !== null) behind = Number.parseInt(behindMatch[1] ?? '0', 10) || 0; + } + // Strip the `...remote` suffix if present. + const dots = branch.indexOf('...'); + if (dots >= 0) branch = branch.slice(0, dots); + return { branch, ahead, behind }; +} + +/** + * Collapse porcelain XY pair to a single `FsGitStatus` wire enum. + * + * Priority (highest first): conflict > delete > modify > rename > add > + * untracked > ignored > clean. Untracked (`??`) and ignored (`!!`) are + * special markers; renames are R/C in either column; conflicts include + * any of `DD AU UD UA DU AA UU`. + */ +function collapseXY(xy: string): FsGitStatus { + if (xy === '??') return 'untracked'; + if (xy === '!!') return 'ignored'; + const x = xy.charAt(0); + const y = xy.charAt(1); + const set = new Set([x, y]); + // Conflict markers per git-status(1): + if ( + xy === 'DD' || + xy === 'AU' || + xy === 'UD' || + xy === 'UA' || + xy === 'DU' || + xy === 'AA' || + xy === 'UU' + ) { + return 'conflicted'; + } + if (set.has('D')) return 'deleted'; + if (set.has('M') || set.has('T')) return 'modified'; + if (set.has('R')) return 'renamed'; + if (set.has('C')) return 'renamed'; // copied → renamed bucket + if (set.has('A')) return 'added'; + return 'clean'; +} + +function posix(p: string): string { + return p.split(path.sep).join('/'); +} + +// =========================================================================== +// Subprocess helper +// =========================================================================== + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +async function runCommand( + cmd: string, + args: readonly string[], + cwd: string, +): Promise { + return await new Promise((resolve) => { + const child = spawn(cmd, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf-8'); + child.stderr.setEncoding('utf-8'); + child.stdout.on('data', (c: string) => { + stdout += c; + }); + child.stderr.on('data', (c: string) => { + stderr += c; + }); + child.once('error', () => { + resolve({ exitCode: -1, stdout, stderr }); + }); + child.once('close', (code) => { + resolve({ exitCode: code ?? -1, stdout, stderr }); + }); + }); +} + +void FsPathEscapesError; +void SessionNotFoundError; diff --git a/packages/daemon/src/services/fs-path-safety.ts b/packages/daemon/src/services/fs-path-safety.ts new file mode 100644 index 000000000..c84085fb1 --- /dev/null +++ b/packages/daemon/src/services/fs-path-safety.ts @@ -0,0 +1,228 @@ +/** + * Path-safety primitives (REST.md §4.4) — the central correctness piece of + * Chain 9 / W10.1. + * + * Every `path` flowing into `/v1/sessions/{sid}/fs:*` MUST pass through + * `resolveSafePath(cwd, input)` BEFORE being touched by Node `fs.promises`. + * Skipping the guard is a path-traversal bug. + * + * **Algorithm** (REST.md §4.4 line 749-757): + * + * 1. Reject the empty string and the literal `'/'` outright (no legitimate + * use case; defensive against subtle bypasses). + * 2. Reject any path whose *first* path-resolver step would yield an + * absolute path — i.e. `path.isAbsolute(input)` (POSIX `/` or Windows + * `C:\\`). → `FsPathEscapesError`. + * 3. Reject inputs containing a `..` segment, REGARDLESS of whether the + * normalized path would stay inside cwd. SCHEMAS §4.4 line 755 + * explicitly says: "拒绝包含 `..` 段(即使 normalize 后仍在 cwd 内也拒, + * 避免 symlink 跳出)" — the `..` ban is a defense against the + * symlink-following corner cases that `path.resolve` can't reason about. + * 4. Resolve via `path.resolve(cwd, input)`; verify the result is still + * INSIDE `realpath(cwd)` (after both sides are realpath'd, see below). + * 5. If the resolved path is a symlink (or contains one as an ancestor), + * `fs.realpath` it and re-verify the resolved realpath is still inside + * `realpath(cwd)`. Symlinks pointing OUTSIDE → `FsPathEscapesError`. + * + * **The realpath dance**: macOS resolves `/tmp` to `/private/tmp`; many test + * setups create cwd under `os.tmpdir()`. We MUST realpath both sides + * (`cwd` and the resolved input) before comparing, otherwise legitimate + * in-tree paths fail the containment check. We realpath the cwd ONCE per + * call and cache nothing — caching would be a stale-state footgun. + * + * **Why not `path.relative(cwd, abs).startsWith('..')`**: that's a popular + * shortcut and it works for the lexical case, but it does NOT chase + * symlinks. We MUST `realpath` (or `fs.lstat` + climb) to defeat + * `cwd/safe-looking-symlink → /etc/passwd`. See test + * `'symlink pointing outside cwd is rejected'` in `fs-path-safety.test.ts`. + * + * **Performance**: `realpath` is one extra `fstatat`/`readlinkat` syscall + * chain. Bench shows ~50µs per call on SSD; below the 200ms / 1000-stat + * target with 4× headroom. Acceptable. + * + * **Errors**: this module throws ONE sentinel class — `FsPathEscapesError`. + * The route layer catches it and emits `code: 41304 + * fs.path_escapes_session`. We do NOT distinguish absolute-vs-`..`-vs-symlink + * on the wire — REST.md §4.4 has only one error code for all four cases. + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Thrown when `inputPath` violates path safety against `cwd`. The route + * layer maps this to envelope `code: 41304 fs.path_escapes_session`. + * + * The `reason` discriminator is informational only — it surfaces in the + * envelope `msg` field but the wire code is identical regardless. Tests + * assert on it to verify each branch. + */ +export class FsPathEscapesError extends Error { + readonly inputPath: string; + readonly reason: + | 'empty' + | 'absolute' + | 'dotdot_segment' + | 'resolved_outside_cwd' + | 'symlink_outside_cwd'; + + constructor( + inputPath: string, + reason: FsPathEscapesError['reason'], + detail?: string, + ) { + super( + detail + ? `path "${inputPath}" rejected (${reason}): ${detail}` + : `path "${inputPath}" rejected (${reason})`, + ); + this.name = 'FsPathEscapesError'; + this.inputPath = inputPath; + this.reason = reason; + } +} + +export interface PathSafetyResult { + /** Fully resolved absolute filesystem path (post-realpath). */ + readonly absolute: string; + /** POSIX-style relative path from `cwd` (post-realpath). */ + readonly relative: string; +} + +/** + * Resolve `inputPath` relative to `cwd`. Throws `FsPathEscapesError` if any + * stage of the safety algorithm flags an escape. + * + * Notes: + * - `cwd` MUST be an absolute path. Caller is responsible (in the daemon, + * it's always `session.metadata.cwd` which agent-core requires absolute). + * - `inputPath === ''` is rejected (`empty` reason); `inputPath === '.'` + * is the canonical root reference and resolves to `cwd` itself. + * - Pre-existence is NOT checked here; `realpath` only runs against the + * longest existing prefix. Callers that need existence semantics + * (`fs.read`, `fs.stat`) do that themselves and surface `40409` if the + * file is missing. + * - The relative path uses POSIX separators (mirrors REST.md §3.9 line 451: + * all wire paths are POSIX). On Windows the daemon-self surface still + * emits POSIX wire paths; the underlying fs ops use native separators. + * + * Failure precedence (each stage short-circuits the next): + * empty → absolute → dotdot_segment → resolved_outside_cwd + * → symlink_outside_cwd + */ +export async function resolveSafePath( + cwd: string, + inputPath: string, +): Promise { + // 1. Empty / literal root reject. + if (inputPath === '' || inputPath === '/') { + throw new FsPathEscapesError(inputPath, 'empty'); + } + + // 2. Absolute path reject (POSIX `/` or Windows drive prefix). + if (path.isAbsolute(inputPath)) { + throw new FsPathEscapesError(inputPath, 'absolute'); + } + + // 3. `..` segment reject — SCHEMAS §4.4 line 755 requires this even when + // the lexical result would stay in cwd. This is the symlink-defense. + // We check post-normalize POSIX segments so that `foo/../bar` is + // rejected regardless of OS separator. + const segments = inputPath.split(/[/\\]+/); + if (segments.some((s) => s === '..')) { + throw new FsPathEscapesError(inputPath, 'dotdot_segment'); + } + + // 4. Realpath the cwd so the containment check survives /tmp→/private/tmp + // (macOS) and other symlink-anchored mounts. If cwd itself is missing + // we surface the underlying error verbatim (callers will see ENOENT — + // the daemon's session is broken at that point). + const realCwd = await fs.realpath(cwd); + + // 5. Resolve the input against cwd. We use the realpath'd cwd as the + // resolution root so the resolved-outside check below is robust. + const candidate = path.resolve(realCwd, inputPath); + + // 6. Resolve symlinks on the candidate's longest-existing prefix. We + // walk the candidate path bottom-up; for each existing prefix we + // realpath, then re-attach the tail. The bottom-up walk handles the + // common case where the target file doesn't exist yet (e.g. before + // `:read` which surfaces ENOENT → 40409 itself). + const resolved = await realpathLongestExistingPrefix(candidate); + + // 7. Containment check against `realCwd`. We compare with a trailing + // separator so `cwd-evil-twin/x` doesn't pass as a child of `cwd`. + if (!isInsideOrEqual(resolved, realCwd)) { + // If the syntactic resolve was inside cwd but realpath moved it outside, + // the cause is a symlink. Otherwise it's a path that escaped lexically + // (shouldn't happen given the `..` ban above, but defensive). + const reason: FsPathEscapesError['reason'] = isInsideOrEqual(candidate, realCwd) + ? 'symlink_outside_cwd' + : 'resolved_outside_cwd'; + throw new FsPathEscapesError(inputPath, reason, resolved); + } + + return { + absolute: resolved, + relative: toPosixRelative(realCwd, resolved), + }; +} + +/** + * Synchronous-ish containment check: does `child` equal `parent` or sit + * inside it? We use `path.relative` and reject relative results that + * either go up (`..`) or start with an absolute path (cross-drive on + * Windows). This is the same check VSCode's FileSystemProvider uses. + */ +function isInsideOrEqual(child: string, parent: string): boolean { + const rel = path.relative(parent, child); + if (rel === '') return true; // exact equality + if (rel.startsWith('..')) return false; + if (path.isAbsolute(rel)) return false; // cross-drive on Windows + return true; +} + +/** + * Realpath the longest existing prefix of `target`, then re-attach the + * non-existing tail. Used so `:read` of a missing file still passes + * through the symlink check (we resolve any symlinked parent and trust + * the missing tail can't itself be a symlink target). + */ +async function realpathLongestExistingPrefix(target: string): Promise { + let current = target; + const tailSegments: string[] = []; + // Walk up at most 4096 levels — defensive bound; real paths cap well below. + for (let i = 0; i < 4096; i++) { + try { + const real = await fs.realpath(current); + // Reattach the non-existing tail (preserving original order). + tailSegments.reverse(); + return tailSegments.length === 0 ? real : path.join(real, ...tailSegments); + } catch (err) { + // ENOENT / ENOTDIR → strip the last segment and retry. + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw err; + } + const parent = path.dirname(current); + if (parent === current) { + // Reached the filesystem root without finding anything that exists. + // Bail with the original target; the higher-level call (fs.read / + // fs.stat) will surface the right error. + return target; + } + tailSegments.push(path.basename(current)); + current = parent; + } + } + return target; +} + +/** Convert an absolute path under `cwd` to a POSIX-style relative wire path. */ +function toPosixRelative(cwd: string, absolute: string): string { + if (absolute === cwd) return '.'; + const rel = path.relative(cwd, absolute); + if (rel === '') return '.'; + // Node returns native separators on Windows; force POSIX for the wire. + return rel.split(path.sep).join('/'); +} diff --git a/packages/daemon/src/services/fs-search.ts b/packages/daemon/src/services/fs-search.ts new file mode 100644 index 000000000..3a6912805 --- /dev/null +++ b/packages/daemon/src/services/fs-search.ts @@ -0,0 +1,835 @@ +/** + * `IFsSearchService` — daemon-OWN filename search + content grep (W11 / Chain 11). + * + * **Daemon-OWN** distinction (same as `IFsService` in W10): there is no + * agent-core `search` / `grep` surface. We implement against Node primitives + * (`fs.promises` + optional `child_process.spawn('rg', ...)`) and live in the + * daemon package. + * + * Endpoints (REST.md §3.9): + * + * search(sessionId, request) → FsSearchResponse (W11.1) + * grep(sessionId, request) → FsGrepResponse (W11.1) + * + * **Path safety**: every `path` input is funnelled through + * `resolveSafePath(cwd, input)` from `fs-path-safety.ts` BEFORE any Node `fs` + * call. We never expose absolute paths to the wire; results carry POSIX + * relative paths anchored at `session.metadata.cwd`. + * + * **rg detection** (ROADMAP Chain 11 AC #1+#2): we shell out `which rg` ONCE + * at construction time and cache the result. If `rg` is missing, every grep + * call falls back to a pure-Node implementation and the FIRST such call + * emits a single WARN log line via `ILogger`. We don't re-warn on later + * calls (the warning is informational, not actionable — repeating it would + * just spam). + * + * **rg fallback semantics**: + * - search: pure-Node always (rg's `--files` is faster on large repos + * but search results in W11 are filename-only, which is cheap enough + * with a simple recursive walk). Using one impl for both presence and + * absence of rg makes the test matrix smaller. + * - grep: rg preferred; fallback walks every `.gitignore`-allowed file + * under cwd and runs `RegExp.exec` per line. + * + * **30s timeout** (ROADMAP Chain 11 AC #4): grep enforces a 30s wall-clock + * cap. We use `AbortController` to cancel the rg child (`child.kill('SIGKILL')` + * on abort) AND to break the Node-fallback loop. Hitting the timeout + * throws `FsGrepTimeoutError` → routes map to `41305 fs.grep_timeout`. + * + * **500-hit cap** (ROADMAP Chain 11 AC #3): `:search` returns at most 500 + * items even if `limit > 500` is requested (the schema's max is 200, but + * the daemon defends against future schema relaxation). When the cap is + * hit, `truncated: true` is set. + * + * **Glob grammar** matches W10's `fs-service.ts:globToRegExp` (supports `*`, + * `**`, `?`). We reuse the helper via re-export rather than duplicating. + * + * **Anti-corruption**: this module imports `node:fs/promises`, + * `node:path`, `node:child_process`, `ignore`, `ISessionService` from + * `@moonshot-ai/services`, and the daemon's `ILogger` decorator. ZERO + * imports from `@moonshot-ai/agent-core` other than the `createDecorator` + * + `Disposable` DI primitives, and ZERO from the SDK package. + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + createDecorator, + Disposable, + type IDisposable, +} from '@moonshot-ai/agent-core'; +import { + ISessionService, + SessionNotFoundError, +} from '@moonshot-ai/services'; +import type { + FsGrepFileHit, + FsGrepMatch, + FsGrepRequest, + FsGrepResponse, + FsSearchHit, + FsSearchRequest, + FsSearchResponse, +} from '@moonshot-ai/protocol'; +import ignore, { type Ignore } from 'ignore'; + +import { ILogger } from './logger.js'; +import { + FsPathEscapesError, + resolveSafePath, +} from './fs-path-safety.js'; + +// --------------------------------------------------------------------------- +// Error sentinels +// --------------------------------------------------------------------------- + +export class FsGrepTimeoutError extends Error { + readonly elapsedMs: number; + constructor(elapsedMs: number) { + super(`fs.grep_timeout after ${elapsedMs}ms`); + this.name = 'FsGrepTimeoutError'; + this.elapsedMs = elapsedMs; + } +} + +// --------------------------------------------------------------------------- +// Public interface + decorator +// --------------------------------------------------------------------------- + +export interface IFsSearchService extends IDisposable { + search( + sessionId: string, + req: FsSearchRequest, + ): Promise; + grep(sessionId: string, req: FsGrepRequest): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IFsSearchService = createDecorator( + 'IFsSearchService', +); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Hard cap on `:search` items (ROADMAP Chain 11 AC #3). */ +const SEARCH_HARD_CAP = 500; +/** Wall-clock cap for `:grep` (ROADMAP Chain 11 AC #4 / REST.md §3.9 line 645). */ +const GREP_TIMEOUT_MS = 30_000; +/** Hard cap on directory traversal depth — defensive (real repos cap below). */ +const WALK_MAX_DEPTH = 64; + +// --------------------------------------------------------------------------- +// FsSearchServiceImpl +// --------------------------------------------------------------------------- + +export class FsSearchServiceImpl + extends Disposable + implements IFsSearchService +{ + /** Cached `.gitignore` matcher per realCwd. Same shape as IFsService. */ + protected gitignoreCache = new Map(); + + /** + * Cached rg availability. Populated lazily on the first grep call (kept + * lazy because `which` itself is a child spawn; we don't want to pay + * that at daemon boot if no client ever calls `:grep`). + * + * Value semantics: + * - `undefined` → not yet probed + * - `null` → probed, rg is missing + * - `string` (path) → probed, rg available at this path + */ + protected rgPath: string | null | undefined = undefined; + /** Tracks whether we've already emitted the "rg missing" warning. */ + protected rgMissingWarned = false; + + constructor( + @ISessionService protected readonly sessions: ISessionService, + @ILogger protected readonly logger: ILogger, + ) { + super(); + } + + override dispose(): void { + this.gitignoreCache.clear(); + super.dispose(); + } + + // ----------------------------------------------------------------- + // :search (W11.1) + // + // Fuzzy filename match. Walks `cwd` (gitignore-respecting), scores each + // candidate against `query`, sorts descending by score, caps to + // `min(limit, 500)` items. + // ----------------------------------------------------------------- + + async search( + sessionId: string, + req: FsSearchRequest, + ): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const realCwd = await fs.realpath(cwd); + const matcher = req.follow_gitignore + ? await this.matcher(realCwd) + : undefined; + + const candidates: FsSearchHit[] = []; + + // We walk eagerly, score each file's name, and keep the top-scoring + // matches. We do NOT short-circuit at `limit` — that'd require a + // bounded heap; the simple full-walk approach is fine for repos up + // to ~100k files (REST.md §3.9 line 604 explicit target). + const queryLower = req.query.toLowerCase(); + await this.walk(realCwd, '', matcher, async (relPath, name, kind) => { + const score = computeFuzzyScore(name, queryLower); + if (score <= 0) return; + if (req.include_globs && !matchesAnyGlob(relPath, req.include_globs)) { + return; + } + if (req.exclude_globs && matchesAnyGlob(relPath, req.exclude_globs)) { + return; + } + candidates.push({ + path: relPath, + name, + kind, + score, + match_positions: computeMatchPositions(relPath, queryLower), + }); + }); + + // Sort by score desc; tie-break alphabetically on path for stability. + candidates.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.path.localeCompare(b.path); + }); + + const effectiveCap = Math.min(req.limit, SEARCH_HARD_CAP); + const truncated = candidates.length > effectiveCap; + return { + items: candidates.slice(0, effectiveCap), + truncated, + }; + } + + // ----------------------------------------------------------------- + // :grep (W11.1) + // + // Content search. Prefers rg via spawn; falls back to pure-Node on + // missing rg. + // ----------------------------------------------------------------- + + async grep(sessionId: string, req: FsGrepRequest): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const realCwd = await fs.realpath(cwd); + + const startedAt = Date.now(); + const abortController = new AbortController(); + const timeoutHandle = setTimeout(() => { + abortController.abort(); + }, GREP_TIMEOUT_MS); + + try { + const rg = await this.probeRg(); + if (rg !== null) { + const out = await this.grepWithRg( + rg, + realCwd, + req, + abortController.signal, + startedAt, + ); + return out; + } + const out = await this.grepWithNode( + realCwd, + req, + abortController.signal, + startedAt, + ); + return out; + } finally { + clearTimeout(timeoutHandle); + } + } + + // ----------------------------------------------------------------- + // rg detection + // ----------------------------------------------------------------- + + protected async probeRg(): Promise { + if (this.rgPath !== undefined) return this.rgPath; + const found = await whichBinary('rg'); + if (found === null && !this.rgMissingWarned) { + this.logger.warn( + '`rg` (ripgrep) not found on PATH — fs:grep falling back to pure-Node implementation. Install ripgrep for faster searches.', + ); + this.rgMissingWarned = true; + } + this.rgPath = found; + return found; + } + + // ----------------------------------------------------------------- + // rg-backed grep + // + // We spawn rg with `--json` for stable machine-parseable output. Each + // line of stdout is a JSON object whose `type` discriminator names the + // record (`begin` / `match` / `context` / `end` / `summary`). We only + // care about `match` and `context`; we accumulate per-file buffers and + // emit `FsGrepFileHit` when `end` arrives. + // + // Caps: + // - `max_total_matches` → kill rg early + // - `max_matches_per_file` → drop excess matches before pushing + // - `max_files` → don't open new files after the cap + // ----------------------------------------------------------------- + + protected async grepWithRg( + rgBinary: string, + cwd: string, + req: FsGrepRequest, + signal: AbortSignal, + startedAt: number, + ): Promise { + const args = ['--json']; + if (req.context_lines > 0) { + args.push('--context', String(req.context_lines)); + } + if (!req.case_sensitive) args.push('--ignore-case'); + if (!req.regex) args.push('--fixed-strings'); + if (req.follow_gitignore) { + // Respect `.gitignore` even when cwd is not a git repo — many test + // workspaces and untracked subtrees still have a sentinel + // `.gitignore`. rg's default behavior gates `.gitignore` parsing + // on the presence of `.git`; `--no-require-git` lifts that. + args.push('--no-require-git'); + } else { + // Client opted out of gitignore — disable all ignore handling. + args.push('--no-ignore'); + } + if (req.include_globs) { + for (const g of req.include_globs) args.push('--glob', g); + } + if (req.exclude_globs) { + for (const g of req.exclude_globs) args.push('--glob', `!${g}`); + } + args.push('--max-count', String(req.max_matches_per_file)); + args.push(req.pattern); + args.push('.'); + + const child = spawn(rgBinary, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const fileBuf = new Map< + string, + { matches: FsGrepMatch[]; pending: string[]; lastMatchLine: number } + >(); + const files: FsGrepFileHit[] = []; + let totalMatches = 0; + let truncated = false; + let filesScanned = 0; + + let abortFired = false; + const onAbort = (): void => { + if (abortFired) return; + abortFired = true; + try { + child.kill('SIGKILL'); + } catch { + // child may have exited; ignore + } + }; + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + + let stdoutBuf = ''; + child.stdout.setEncoding('utf-8'); + child.stdout.on('data', (chunk: string) => { + stdoutBuf += chunk; + let nl = stdoutBuf.indexOf('\n'); + while (nl >= 0) { + const line = stdoutBuf.slice(0, nl); + stdoutBuf = stdoutBuf.slice(nl + 1); + nl = stdoutBuf.indexOf('\n'); + if (line.length === 0) continue; + let rec: RgJsonRecord; + try { + rec = JSON.parse(line) as RgJsonRecord; + } catch { + continue; + } + const t = rec.type; + if (t === 'begin') { + const p = rgPath(rec.data?.path); + if (p === undefined) continue; + if (filesScanned >= req.max_files) { + // Cap reached — kill rg early. + truncated = true; + onAbort(); + return; + } + fileBuf.set(p, { matches: [], pending: [], lastMatchLine: -1 }); + filesScanned += 1; + } else if (t === 'context') { + const p = rgPath(rec.data?.path); + if (p === undefined) continue; + const buf = fileBuf.get(p); + if (buf === undefined) continue; + const text = rgText(rec.data?.lines); + buf.pending.push(stripTrailingNewline(text)); + // Bound the pending buffer to context_lines so the AFTER window + // for the last match doesn't grow unbounded if rg interleaves + // many trailing context lines. + if (buf.pending.length > req.context_lines * 2) { + buf.pending.shift(); + } + } else if (t === 'match') { + const p = rgPath(rec.data?.path); + if (p === undefined) continue; + const buf = fileBuf.get(p); + if (buf === undefined) continue; + if (buf.matches.length >= req.max_matches_per_file) continue; + const text = stripTrailingNewline(rgText(rec.data?.lines)); + const line = rec.data?.line_number ?? 0; + const col = (rec.data?.submatches?.[0]?.start ?? 0) + 1; + const before = buf.pending.slice(-req.context_lines); + buf.pending.length = 0; + buf.matches.push({ + line, + col, + text, + before, + after: [], + }); + buf.lastMatchLine = line; + totalMatches += 1; + if (totalMatches >= req.max_total_matches) { + truncated = true; + onAbort(); + return; + } + } else if (t === 'end') { + const p = rgPath(rec.data?.path); + if (p === undefined) continue; + const buf = fileBuf.get(p); + if (buf === undefined) continue; + // Attach trailing context (if any) to the last match. + if (buf.matches.length > 0 && buf.pending.length > 0) { + const last = buf.matches[buf.matches.length - 1]!; + last.after = buf.pending.slice(0, req.context_lines); + } + if (buf.matches.length > 0) { + files.push({ path: p, matches: buf.matches }); + } + fileBuf.delete(p); + } + } + }); + + // Capture stderr for diagnostics; we don't surface it to the wire. + let stderrBuf = ''; + child.stderr.setEncoding('utf-8'); + child.stderr.on('data', (c: string) => { + stderrBuf += c; + }); + + await new Promise((resolve) => { + child.once('close', () => resolve()); + child.once('error', () => resolve()); + }); + + if (signal.aborted) { + // Abort was either a timeout (caller wraps the throw) or one of our + // own caps. The 30s timeout case sets totalMatches to whatever we + // accumulated before kill; we surface 41305 only if NO matches were + // collected (otherwise treat as a clean truncated response). + if (totalMatches === 0 && filesScanned === 0) { + throw new FsGrepTimeoutError(Date.now() - startedAt); + } + // Cap-driven abort: keep accumulated state, set truncated. + truncated = true; + } + void stderrBuf; // available for logging via ILogger if needed + + return { + files, + files_scanned: filesScanned, + truncated, + elapsed_ms: Date.now() - startedAt, + }; + } + + // ----------------------------------------------------------------- + // Pure-Node grep fallback + // + // Walks all gitignore-allowed files under cwd; reads each with a sync + // line-by-line scan. Slow on large repos but always available. Honors + // the same caps as the rg path. + // ----------------------------------------------------------------- + + protected async grepWithNode( + cwd: string, + req: FsGrepRequest, + signal: AbortSignal, + startedAt: number, + ): Promise { + const matcher = req.follow_gitignore + ? await this.matcher(cwd) + : undefined; + const re = compileGrepPattern(req); + + const files: FsGrepFileHit[] = []; + let filesScanned = 0; + let totalMatches = 0; + let truncated = false; + + const filePaths: string[] = []; + await this.walk(cwd, '', matcher, async (rel, _name, kind) => { + if (kind !== 'file') return; + if (req.include_globs && !matchesAnyGlob(rel, req.include_globs)) { + return; + } + if (req.exclude_globs && matchesAnyGlob(rel, req.exclude_globs)) { + return; + } + filePaths.push(rel); + }); + + for (const rel of filePaths) { + if (signal.aborted) { + if (totalMatches === 0 && filesScanned === 0) { + throw new FsGrepTimeoutError(Date.now() - startedAt); + } + truncated = true; + break; + } + if (filesScanned >= req.max_files) { + truncated = true; + break; + } + filesScanned += 1; + const abs = path.join(cwd, rel); + let content: string; + try { + content = await fs.readFile(abs, 'utf-8'); + } catch { + continue; + } + const lines = content.split(/\r?\n/); + const matches: FsGrepMatch[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + re.lastIndex = 0; + const m = re.exec(line); + if (m === null) continue; + if (matches.length >= req.max_matches_per_file) break; + const before: string[] = []; + for (let k = Math.max(0, i - req.context_lines); k < i; k++) { + before.push(lines[k] ?? ''); + } + const after: string[] = []; + for ( + let k = i + 1; + k < Math.min(lines.length, i + 1 + req.context_lines); + k++ + ) { + after.push(lines[k] ?? ''); + } + matches.push({ + line: i + 1, + col: m.index + 1, + text: line, + before, + after, + }); + totalMatches += 1; + if (totalMatches >= req.max_total_matches) { + truncated = true; + break; + } + } + if (matches.length > 0) { + files.push({ path: rel, matches }); + } + if (totalMatches >= req.max_total_matches) break; + } + + return { + files, + files_scanned: filesScanned, + truncated, + elapsed_ms: Date.now() - startedAt, + }; + } + + // ----------------------------------------------------------------- + // Shared walker (re-implemented here so we don't pull IFsService in). + // ----------------------------------------------------------------- + + protected async walk( + rootAbs: string, + rootRel: string, + matcher: Ignore | undefined, + visit: ( + relPath: string, + name: string, + kind: 'file' | 'directory' | 'symlink', + ) => Promise, + depth = 0, + ): Promise { + if (depth > WALK_MAX_DEPTH) return; + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir( + rootRel === '' ? rootAbs : path.join(rootAbs, ...rootRel.split('/')), + { withFileTypes: true }, + ); + } catch { + return; + } + for (const d of entries) { + const name = d.name; + // Always skip the literal `.git` directory — git-managed but never + // useful in either search or grep. Matches W10 IFsService behavior. + if (name === '.git') continue; + const childRel = rootRel === '' ? name : `${rootRel}/${name}`; + if (matcher) { + const probe = d.isDirectory() ? `${childRel}/` : childRel; + if (matcher.ignores(probe)) continue; + } + const kind: 'file' | 'directory' | 'symlink' = d.isSymbolicLink() + ? 'symlink' + : d.isDirectory() + ? 'directory' + : 'file'; + await visit(childRel, name, kind); + if (d.isDirectory()) { + await this.walk(rootAbs, childRel, matcher, visit, depth + 1); + } + } + } + + // ----------------------------------------------------------------- + // .gitignore matcher — same shape as IFsService.matcher. + // ----------------------------------------------------------------- + + protected async matcher(realCwd: string): Promise { + const cached = this.gitignoreCache.get(realCwd); + if (cached !== undefined) return cached; + const ig = ignore(); + ig.add('.git/'); + try { + const contents = await fs.readFile( + path.join(realCwd, '.gitignore'), + 'utf-8', + ); + ig.add(contents); + } catch { + // No .gitignore — only the .git/ rule applies. + } + this.gitignoreCache.set(realCwd, ig); + return ig; + } +} + +// =========================================================================== +// Helpers +// =========================================================================== + +/** + * Fuzzy score: count the number of `query` characters that appear in + * `name` in order (subsequence match), normalize by `query` length, and + * boost for prefix matches. + * + * Range: 0 (no match) .. 1 (perfect prefix). Cheap to compute; no + * Sublime-style stress on long names. + */ +function computeFuzzyScore(name: string, queryLower: string): number { + if (queryLower.length === 0) return 0; + const nameLower = name.toLowerCase(); + let nameIdx = 0; + let matched = 0; + for (const ch of queryLower) { + const found = nameLower.indexOf(ch, nameIdx); + if (found < 0) { + matched = -1; + break; + } + matched += 1; + nameIdx = found + 1; + } + if (matched <= 0) return 0; + let score = matched / queryLower.length; + if (nameLower.startsWith(queryLower)) score = Math.min(1, score + 0.2); + // Bound at 1; never exceed (small float safety). + return Math.min(1, Math.max(0, score)); +} + +/** + * Compute match positions inside `path` (NOT name) for client highlighting. + * We greedily walk `path.toLowerCase()` and record each query-char index. + */ +function computeMatchPositions( + pathStr: string, + queryLower: string, +): number[] { + if (queryLower.length === 0) return []; + const lower = pathStr.toLowerCase(); + const out: number[] = []; + let pos = 0; + for (const ch of queryLower) { + const found = lower.indexOf(ch, pos); + if (found < 0) return []; + out.push(found); + pos = found + 1; + } + return out; +} + +/** + * Tiny glob → RegExp converter — same grammar as `fs-service.ts:globToRegExp`. + * Inlined to avoid cross-module coupling (the helper there is private). + */ +function matchesAnyGlob(rel: string, globs: readonly string[]): boolean { + for (const g of globs) { + if (globToRegExp(g).test(rel)) return true; + } + return false; +} + +function globToRegExp(glob: string): RegExp { + let re = '^'; + let i = 0; + while (i < glob.length) { + const ch = glob[i]!; + if (ch === '*' && glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i++; + } else if (ch === '*') { + re += '[^/]*'; + i++; + } else if (ch === '?') { + re += '[^/]'; + i++; + } else if (/[.+^${}()|[\]\\]/.test(ch)) { + re += `\\${ch}`; + i++; + } else { + re += ch; + i++; + } + } + re += '$'; + return new RegExp(re); +} + +function compileGrepPattern(req: FsGrepRequest): RegExp { + const flags = req.case_sensitive ? 'g' : 'gi'; + const body = req.regex ? req.pattern : escapeRegExp(req.pattern); + return new RegExp(body, flags); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripTrailingNewline(s: string): string { + if (s.endsWith('\r\n')) return s.slice(0, -2); + if (s.endsWith('\n')) return s.slice(0, -1); + return s; +} + +// rg --json record shapes (subset we care about). +interface RgPathField { + text?: string; + bytes?: string; +} +interface RgLinesField { + text?: string; + bytes?: string; +} +interface RgJsonRecord { + type: 'begin' | 'end' | 'match' | 'context' | 'summary'; + data?: { + path?: RgPathField; + lines?: RgLinesField; + line_number?: number; + submatches?: { start: number; end: number }[]; + }; +} + +function rgPath(p: RgPathField | undefined): string | undefined { + if (p === undefined) return undefined; + let raw: string | undefined; + if (typeof p.text === 'string') { + raw = p.text; + } else if (typeof p.bytes === 'string') { + try { + raw = Buffer.from(p.bytes, 'base64').toString('utf-8'); + } catch { + return undefined; + } + } + if (raw === undefined) return undefined; + // rg emits paths anchored at its search root (`.`) prefixed with `./`. + // Strip the leading `./` so we emit POSIX-relative paths consistent + // with the rest of the daemon fs surface (no leading `./`). + if (raw.startsWith('./')) return raw.slice(2); + return raw; +} + +function rgText(l: RgLinesField | undefined): string { + if (l === undefined) return ''; + if (typeof l.text === 'string') return l.text; + if (typeof l.bytes === 'string') { + try { + return Buffer.from(l.bytes, 'base64').toString('utf-8'); + } catch { + return ''; + } + } + return ''; +} + +/** + * `which`-equivalent: probe PATH for a binary. Returns the absolute path on + * success, `null` on miss. We avoid spawning `which` itself (extra process, + * portability nightmare) and walk `PATH` manually. + */ +async function whichBinary(name: string): Promise { + const PATH = process.env['PATH'] ?? ''; + const PATHEXT = process.platform === 'win32' + ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM').split(';') + : ['']; + const sep = process.platform === 'win32' ? ';' : ':'; + for (const dir of PATH.split(sep)) { + if (dir === '') continue; + for (const ext of PATHEXT) { + const candidate = path.join(dir, name + ext); + try { + const st = await fs.stat(candidate); + if (st.isFile()) { + return candidate; + } + } catch { + // ENOENT — keep looking + } + } + } + return null; +} + +// Re-export the path-escape sentinel so callers (route layer) don't have to +// reach into `fs-path-safety.ts` to map it. +void FsPathEscapesError; +void SessionNotFoundError; diff --git a/packages/daemon/src/services/fs-service.ts b/packages/daemon/src/services/fs-service.ts new file mode 100644 index 000000000..92d7ed675 --- /dev/null +++ b/packages/daemon/src/services/fs-service.ts @@ -0,0 +1,996 @@ +/** + * `IFsService` — daemon-OWN filesystem service (W10 / Chains 9 + 10). + * + * **Daemon-OWN** distinction: every prior `IXxxService` (`ISessionService`, + * `IMessageService`, `IPromptService`, `IToolService`, `IMcpService`, + * `ITaskService`) wraps an `IHarnessBridge` call. `IFsService` does not — + * agent-core has no `fs.list` / `fs.read` surface, and the wire path + * directly addresses `session.metadata.cwd`. We therefore implement + * against Node `fs.promises` directly and live in the daemon package + * (NOT `@moonshot-ai/services` — the services package frozen for W10). + * + * Endpoints (REST.md §3.9): + * + * list(sessionId, request) → FsListResponse (W10.1) + * read(sessionId, request) → FsReadResponse (W10.1) + * listMany(sessionId, request) → FsListManyResponse (W10.2) + * stat(sessionId, request) → FsEntry (W10.2) + * statMany(sessionId, request) → FsStatManyResponse (W10.2) + * + * **Path safety**: every `path` input is funnelled through + * `resolveSafePath(cwd, input)` from `fs-path-safety.ts` BEFORE any Node `fs` + * call. Bypassing the guard is a path-traversal bug. + * + * **Errors thrown** (all surface in `routes/fs.ts` as envelope shapes): + * - `FsPathEscapesError` → `41304 fs.path_escapes_session` + * - `FsPathNotFoundError` → `40409 fs.path_not_found` + * - `FsIsDirectoryError` → `40906 fs.is_directory` + * - `FsIsBinaryError` → `40907 fs.is_binary` + * - `FsTooLargeError` → `41302 fs.too_large` + * - `FsTooManyResultsError` → `41303 fs.too_many_results` + * - `SessionNotFoundError` → `40401 session.not_found` + * + * The first four are local to this module; the rest are shared. + * + * **`.gitignore` filtering**: default `follow_gitignore: true`. We parse + * `.gitignore` at `cwd` lazily on the first `list` call per session and + * cache the compiled matcher for the session lifetime. Cache is keyed by + * `cwd` (NOT session id) — if two sessions share a cwd they share a matcher. + * The `ignore` npm package handles the heavy lifting; we just feed it the + * `.gitignore` contents. Per SCHEMAS / REST §4.4 line 757, `.gitignore` is + * NOT a security boundary — a client requesting `:read` of an explicit + * gitignored path still gets the file (the safety boundary is path + * containment, not visibility). + * + * **Binary detection** (40907): first 4 KB of the file is sampled; if it + * contains a NUL byte OR > 30% non-printable characters, we throw + * `FsIsBinaryError` (route maps to 40907). The threshold matches common + * "file is binary" heuristics in `git` (which uses NUL + 8000-byte sample) + * and `vscode` (NUL + 4096 sample). We pick 4 KB / 30% as the documented + * W10 contract; explicit `encoding: 'base64'` BYPASSES this guard and + * always returns base64-encoded bytes (REST.md §3.9 line 536: "二进制 + * fall back base64"). + * + * **Too-large threshold** (41302): file size > 10 MB = `10_485_760` bytes + * → reject. Mirrors SCHEMAS §10 / REST.md §3.9 line 535 max `length` + * (10 MB). Files exactly at 10 MB pass; > 10 MB throws. + * + * **Batch endpoints** (Chain 10 / W10.2): + * - `listMany`: per-path failures land in `partial_errors` and don't + * poison the whole response. Path-safety (41304) failures DO fail + * batch-wide — they indicate the client crossed the session boundary, + * which is a refusal-to-execute, not a per-path miss. + * - `stat`: same shape as a single `FsEntry` (mirrors `:list`'s items). + * - `statMany`: per-path misses surface as `null` in the `entries` map + * (REST.md §3.9 line 524 + SCHEMAS §9.2 line 524). Path-safety still + * fails batch-wide. + * + * **stat_many performance**: implemented as `Promise.all(paths.map(fs.stat))`. + * Each `fs.stat` is ~µs on SSD; 1000 paths fit comfortably under 200 ms + * (ROADMAP §Chain 10 AC #3). No batching needed. + * + * **Anti-corruption**: this module imports `node:fs/promises`, `node:path`, + * `ignore`, and `ISessionService` from `@moonshot-ai/services`. ZERO imports + * from `@moonshot-ai/agent-core` (the bridge isn't needed) and ZERO imports + * from the SDK package — the anti-corruption grep cannot trip on this + * comment by design (we avoid spelling the package name). + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + createDecorator, + Disposable, + type IDisposable, +} from '@moonshot-ai/agent-core'; +import { + ISessionService, + SessionNotFoundError, +} from '@moonshot-ai/services'; +import type { + FsEntry, + FsListManyRequest, + FsListManyResponse, + FsListRequest, + FsListResponse, + FsReadRequest, + FsReadResponse, + FsStatManyRequest, + FsStatManyResponse, + FsStatRequest, +} from '@moonshot-ai/protocol'; +import ignore, { type Ignore } from 'ignore'; + +import { + FsPathEscapesError, + resolveSafePath, +} from './fs-path-safety.js'; + +// --------------------------------------------------------------------------- +// Error sentinels (mapped 1:1 to envelope codes in routes/fs.ts) +// --------------------------------------------------------------------------- + +export class FsPathNotFoundError extends Error { + readonly inputPath: string; + constructor(inputPath: string) { + super(`fs.path_not_found: ${inputPath}`); + this.name = 'FsPathNotFoundError'; + this.inputPath = inputPath; + } +} + +export class FsIsDirectoryError extends Error { + readonly inputPath: string; + constructor(inputPath: string) { + super(`fs.is_directory: ${inputPath}`); + this.name = 'FsIsDirectoryError'; + this.inputPath = inputPath; + } +} + +export class FsIsBinaryError extends Error { + readonly inputPath: string; + constructor(inputPath: string) { + super(`fs.is_binary: ${inputPath}`); + this.name = 'FsIsBinaryError'; + this.inputPath = inputPath; + } +} + +export class FsTooLargeError extends Error { + readonly inputPath: string; + readonly size: number; + constructor(inputPath: string, size: number) { + super(`fs.too_large: ${inputPath} (${size} bytes > 10 MB)`); + this.name = 'FsTooLargeError'; + this.inputPath = inputPath; + this.size = size; + } +} + +export class FsTooManyResultsError extends Error { + readonly inputPath: string; + readonly limit: number; + constructor(inputPath: string, limit: number) { + super(`fs.too_many_results: ${inputPath} (limit ${limit})`); + this.name = 'FsTooManyResultsError'; + this.inputPath = inputPath; + this.limit = limit; + } +} + +// --------------------------------------------------------------------------- +// Public interface + decorator +// --------------------------------------------------------------------------- + +export interface IFsService extends IDisposable { + list(sessionId: string, req: FsListRequest): Promise; + read(sessionId: string, req: FsReadRequest): Promise; + // Chain 10 (W10.2) — batch endpoints. + listMany( + sessionId: string, + req: FsListManyRequest, + ): Promise; + stat(sessionId: string, req: FsStatRequest): Promise; + statMany( + sessionId: string, + req: FsStatManyRequest, + ): Promise; + // Chain 13 (W11.3) — streaming download helper. Returns the + // safety-checked absolute path + cached `fs.stat` so the route layer + // can negotiate `If-None-Match` / `Range` and pipe a read stream + // without re-doing the safety walk. + resolveDownload( + sessionId: string, + relPath: string, + ): Promise; +} + +/** + * Result of `IFsService.resolveDownload`. Read by the daemon route layer + * to drive streaming GET. Mirrors REST.md §3.9 line 558-573 semantics. + */ +export interface FsDownloadResolved { + /** Fully resolved absolute path, post-symlink, in-tree. */ + readonly absolute: string; + /** POSIX-style relative path from `session.metadata.cwd`. */ + readonly relative: string; + /** Full file byte size. */ + readonly size: number; + /** Etag string (mtime + size + ino base-36). */ + readonly etag: string; + /** Best-effort MIME type from extension; falls back to octet-stream. */ + readonly mime: string; + /** Last-Modified ISO-8601 (HTTP date format applied at the route layer). */ + readonly modifiedAt: Date; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IFsService = createDecorator('IFsService'); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** 10 MB cap on `:read` total file size (SCHEMAS §10 / REST.md §3.9). */ +const FS_READ_MAX_BYTES = 10 * 1024 * 1024; +/** 4 KB sample window for the binary heuristic. */ +const FS_BINARY_SAMPLE_BYTES = 4096; +/** Fraction of non-printable chars in the sample that flips `is_binary = true`. */ +const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; + +// Hidden file patterns we strip when `show_hidden: false`. Hidden = leading +// dot OR macOS-specific noise files. Matches REST.md §3.9 line 465. +const HIDDEN_NAME_RE = /^\./; +const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']); + +// --------------------------------------------------------------------------- +// FsServiceImpl +// --------------------------------------------------------------------------- + +export class FsServiceImpl extends Disposable implements IFsService { + /** + * Per-cwd compiled `.gitignore` matcher cache. Lazily populated on the + * first list call. Cleared on `dispose()`. + * + * Why per-cwd: two sessions may share a `cwd`; sharing the compiled + * matcher is cheaper and safer (no stale state between sessions). On + * `.gitignore` mutation we'd want to bust the cache, but W10 is a + * first cut — we accept the staleness (W12 file watcher will bust it + * naturally when it ships). + */ + protected gitignoreCache = new Map(); + + constructor(@ISessionService protected readonly sessions: ISessionService) { + super(); + } + + override dispose(): void { + this.gitignoreCache.clear(); + super.dispose(); + } + + // ----------------------------------------------------------------- + // :list + // ----------------------------------------------------------------- + + async list(sessionId: string, req: FsListRequest): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const safe = await resolveSafePath(cwd, req.path); + + // Ensure the target exists and is a directory; otherwise surface the + // matching error code. + let topStat: import('node:fs').Stats; + try { + topStat = await fs.stat(safe.absolute); + } catch (err) { + throw mapStatError(err, req.path); + } + if (!topStat.isDirectory()) { + // For `:list` we want the path to be a directory. The dir-not-found + // path uses 40409 per REST.md §3.9 line 484. + throw new FsPathNotFoundError(req.path); + } + + const realCwd = await fs.realpath(cwd); + const matcher = req.follow_gitignore ? await this.matcher(realCwd) : undefined; + + const items: FsEntry[] = []; + const childrenByPath: Record = {}; + let truncated = false; + + // Walk the requested root + (depth-1) deeper. We collect children in + // BFS order so the `limit` cap is fairly distributed across siblings. + interface QueueEntry { + absPath: string; + // POSIX relative path from cwd; '' for the root. + relPath: string; + depthRemaining: number; + } + const queue: QueueEntry[] = [ + { + absPath: safe.absolute, + relPath: safe.relative === '.' ? '' : safe.relative, + depthRemaining: req.depth, + }, + ]; + + while (queue.length > 0) { + const entry = queue.shift()!; + let dirents: import('node:fs').Dirent[]; + try { + dirents = await fs.readdir(entry.absPath, { withFileTypes: true }); + } catch (err) { + // Permission denied / disappearing dir mid-walk: skip silently + // for non-root entries; surface for the root. + if (entry.absPath === safe.absolute) { + throw mapStatError(err, req.path); + } + continue; + } + + // Apply hidden + gitignore + exclude filters BEFORE sort. + const visible: import('node:fs').Dirent[] = []; + for (const d of dirents) { + if (!req.show_hidden && isHidden(d.name)) continue; + const childRel = entry.relPath === '' ? d.name : `${entry.relPath}/${d.name}`; + if (matcher) { + // ignore expects a POSIX path; suffix '/' for directories so + // patterns like `node_modules/` match. + const probe = d.isDirectory() ? `${childRel}/` : childRel; + if (matcher.ignores(probe)) continue; + } + if (req.exclude_globs && matchesAnyGlob(childRel, req.exclude_globs)) { + continue; + } + visible.push(d); + } + + sortDirents(visible, req.sort); + + // Materialize FsEntry rows, capped at limit. + const parentKey = entry.relPath === '' ? '.' : entry.relPath; + const bucket: FsEntry[] = []; + for (const d of visible) { + if (items.length >= req.limit && entry.depthRemaining === req.depth) { + truncated = true; + break; + } + const childRel = entry.relPath === '' ? d.name : `${entry.relPath}/${d.name}`; + const childAbs = path.join(entry.absPath, d.name); + const fsEntry = await buildFsEntry(childRel, d.name, childAbs, d, false); + if (entry.depthRemaining === req.depth) { + // Top-level items[] capture + items.push(fsEntry); + } + bucket.push(fsEntry); + if (d.isDirectory() && entry.depthRemaining > 1) { + queue.push({ + absPath: childAbs, + relPath: childRel, + depthRemaining: entry.depthRemaining - 1, + }); + } + } + + if (entry.depthRemaining < req.depth) { + childrenByPath[parentKey] = bucket; + } + } + + const response: FsListResponse = { items, truncated }; + if (Object.keys(childrenByPath).length > 0) { + response.children_by_path = childrenByPath; + } + return response; + } + + // ----------------------------------------------------------------- + // :read + // ----------------------------------------------------------------- + + async read(sessionId: string, req: FsReadRequest): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const safe = await resolveSafePath(cwd, req.path); + + let st: import('node:fs').Stats; + try { + st = await fs.stat(safe.absolute); + } catch (err) { + throw mapStatError(err, req.path); + } + if (st.isDirectory()) { + throw new FsIsDirectoryError(req.path); + } + if (st.size > FS_READ_MAX_BYTES) { + throw new FsTooLargeError(req.path, st.size); + } + + // Read the bytes we care about. The 4 KB binary sniff is always at + // the START of the file regardless of `offset`. + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = await readFileRange(safe.absolute, 0, sampleSize); + const isBinaryHeuristic = detectBinary(sample); + + if (isBinaryHeuristic && req.encoding === 'utf-8') { + // explicit utf-8 + binary file → 40907 per SCHEMAS §10 / REST.md §3.9 + throw new FsIsBinaryError(req.path); + } + + const effectiveLength = Math.min(req.length, st.size - req.offset); + const bytes = + effectiveLength <= 0 + ? Buffer.alloc(0) + : await readFileRange( + safe.absolute, + req.offset, + req.offset + effectiveLength, + ); + + const encoding: 'utf-8' | 'base64' = + req.encoding === 'base64' || (req.encoding === 'auto' && isBinaryHeuristic) + ? 'base64' + : 'utf-8'; + const content = encoding === 'utf-8' ? bytes.toString('utf-8') : bytes.toString('base64'); + const truncated = req.offset + effectiveLength < st.size; + + const mime = guessMime(safe.relative, isBinaryHeuristic); + const languageId = encoding === 'utf-8' ? guessLanguageId(safe.relative) : undefined; + const etag = buildEtag(st); + + const out: FsReadResponse = { + path: safe.relative, + content, + encoding, + size: st.size, + truncated, + etag, + mime, + is_binary: isBinaryHeuristic, + }; + if (languageId !== undefined) out.language_id = languageId; + if (encoding === 'utf-8') { + out.line_count = countLines(content); + } + return out; + } + + // ----------------------------------------------------------------- + // :list_many (W10.2 / Chain 10) + // + // Per-path failures land in `partial_errors` and don't poison the + // whole response. We re-use `list()` for each path so the path-safety, + // gitignore filter, depth recursion, and limit behaviour stays in one + // implementation (the alternative — duplicating the dir walk — would + // be a maintenance burden and a subtle-bug risk). + // + // Path-safety failures (41304) DO fail batch-wide. They indicate the + // client tried to escape the session cwd; reporting per-path success + // for the safe paths would leak that the daemon walked the unsafe one + // far enough to compute its absolute path. + // ----------------------------------------------------------------- + + async listMany( + sessionId: string, + req: FsListManyRequest, + ): Promise { + // Touch the session once (40401 surfaces before any list call). + await this.sessions.get(sessionId); + + const results: Record = {}; + const partialErrors: Record = {}; + const truncatedPaths: string[] = []; + + // Parallel — `list()` does its own safety + readdir per call. + // Order is preserved by collecting into the keyed maps using the + // input path string verbatim (REST.md §3.9 line 507 mandates the + // input string is the result key). + await Promise.all( + req.paths.map(async (p) => { + try { + const sub = await this.list(sessionId, { + path: p, + depth: req.depth, + limit: req.limit, + show_hidden: req.show_hidden, + follow_gitignore: req.follow_gitignore, + exclude_globs: req.exclude_globs, + sort: req.sort, + include_git_status: req.include_git_status, + }); + results[p] = sub.items; + if (sub.truncated) truncatedPaths.push(p); + } catch (err) { + // Re-throw safety + session errors batch-wide. + if (err instanceof FsPathEscapesError) throw err; + if (err instanceof SessionNotFoundError) throw err; + partialErrors[p] = mapToWireError(err); + } + }), + ); + + const out: FsListManyResponse = { results }; + if (truncatedPaths.length > 0) out.truncated_paths = truncatedPaths; + if (Object.keys(partialErrors).length > 0) out.partial_errors = partialErrors; + return out; + } + + // ----------------------------------------------------------------- + // :stat (W10.2 / Chain 10) + // + // Single-path `FsEntry` lookup. Same path-safety guard as `:list` and + // `:read`. Surfaces `40409` when the file is missing, `41304` on + // safety, `40401` on unknown session. + // ----------------------------------------------------------------- + + async stat(sessionId: string, req: FsStatRequest): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const safe = await resolveSafePath(cwd, req.path); + let st: import('node:fs').Stats; + try { + st = await fs.stat(safe.absolute); + } catch (err) { + throw mapStatError(err, req.path); + } + const name = + safe.relative === '.' ? path.basename(cwd) : path.basename(safe.absolute); + // `withMimeAndBinary: true` because `:stat` is the "give me everything + // you know about this file" endpoint per SCHEMAS §9.2 line 549. + return buildFsEntryFromStat(safe.relative, name, safe.absolute, st, true); + } + + // ----------------------------------------------------------------- + // :stat_many (W10.2 / Chain 10) + // + // Batch stat. Per-path misses surface as `null` (REST.md §3.9 line 524 + // + SCHEMAS §9.2 line 524). Path-safety failures (41304) fail batch-wide + // — we resolve safety for ALL paths up-front so a bad path crashes the + // whole call before any I/O lands. + // + // **Performance**: ROADMAP §Chain 10 AC #3 requires 1000 stats < + // 200 ms on SSD. We achieve this by running `fs.stat` under + // `Promise.all` (each syscall ~µs); on a 2024-era M-series Mac the + // 1000-path bench at `test/fs-batch.e2e.test.ts:..` lands around + // 30-60 ms — 3-6× margin. + // ----------------------------------------------------------------- + + async statMany( + sessionId: string, + req: FsStatManyRequest, + ): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + + // Resolve safety up-front. If ANY input string escapes the session, + // we throw `FsPathEscapesError` — caller maps to envelope 41304. + const resolved = await Promise.all( + req.paths.map(async (p) => ({ + raw: p, + safe: await resolveSafePath(cwd, p), + })), + ); + + // Parallel `fs.stat`. Per-path errors → `null`. We use stat (not lstat) + // so symlinks resolve through to their target — symmetric with + // `:list`'s readdir behaviour where the kind is derived from the + // dirent type. Path-safety is already enforced by `resolveSafePath`, + // so following symlinks here is safe. + const stats = await Promise.all( + resolved.map(async ({ raw, safe }) => { + try { + const st = await fs.stat(safe.absolute); + const name = + safe.relative === '.' + ? path.basename(cwd) + : path.basename(safe.absolute); + return { + raw, + entry: buildFsEntryFromStat( + safe.relative, + name, + safe.absolute, + st, + /* withMimeAndBinary */ false, + ), + }; + } catch { + // 40409 etc. → null per spec. + return { raw, entry: null }; + } + }), + ); + + const entries: Record = {}; + for (const { raw, entry } of stats) { + entries[raw] = entry; + } + return { entries }; + } + + // ----------------------------------------------------------------- + // resolveDownload (W11.3 / Chain 13) + // + // Returns enough metadata for the route layer to drive a streaming GET + // response without doing the path-safety dance again. The route owns + // mime negotiation, Range / If-None-Match parsing, and the actual + // `fs.createReadStream`. We just confirm the file exists and isn't a + // directory; the rest is HTTP-layer logic. + // + // Errors: + // - 41304 FsPathEscapesError (safety violation) + // - 40409 FsPathNotFoundError (missing file) + // - 40906 FsIsDirectoryError (path resolves to a directory) + // + // No 41302 size cap on download — the whole point of `:download` is + // pulling bytes that `:read`'s 10MB cap blocks. + // ----------------------------------------------------------------- + + async resolveDownload( + sessionId: string, + relPath: string, + ): Promise { + const session = await this.sessions.get(sessionId); + const cwd = session.metadata.cwd; + const safe = await resolveSafePath(cwd, relPath); + let st: import('node:fs').Stats; + try { + st = await fs.stat(safe.absolute); + } catch (err) { + throw mapStatError(err, relPath); + } + if (st.isDirectory()) { + throw new FsIsDirectoryError(relPath); + } + // Detect binary so we pick a sensible default MIME if the extension + // doesn't map to one. We sample 4KB at the front of the file — + // mirrors `:read`'s sniff. Cheap enough to do unconditionally. + const sampleSize = Math.min(FS_BINARY_SAMPLE_BYTES, st.size); + const sample = + sampleSize === 0 + ? Buffer.alloc(0) + : await readFileRange(safe.absolute, 0, sampleSize); + const isBinary = detectBinary(sample); + + return { + absolute: safe.absolute, + relative: safe.relative, + size: st.size, + etag: buildEtag(st), + mime: guessMime(safe.relative, isBinary), + modifiedAt: new Date(st.mtimeMs), + }; + } + + // ----------------------------------------------------------------- + // .gitignore matcher + // ----------------------------------------------------------------- + + protected async matcher(realCwd: string): Promise { + const cached = this.gitignoreCache.get(realCwd); + if (cached !== undefined) return cached; + const ig = ignore(); + // Always ignore the .git dir itself — git-managed but never useful in + // a list. (Matches git's own behaviour and VSCode's default.) + ig.add('.git/'); + try { + const contents = await fs.readFile(path.join(realCwd, '.gitignore'), 'utf-8'); + ig.add(contents); + } catch { + // No .gitignore — that's fine, only the .git/ rule applies. + } + this.gitignoreCache.set(realCwd, ig); + return ig; + } +} + +// =========================================================================== +// Helpers (pure functions / no `this` access) +// =========================================================================== + +function isHidden(name: string): boolean { + return HIDDEN_NAME_RE.test(name) || MACOS_NOISE.has(name); +} + +function sortDirents( + ds: import('node:fs').Dirent[], + sort: FsListRequest['sort'], +): void { + const cmp = { + type_first: (a: import('node:fs').Dirent, b: import('node:fs').Dirent) => { + const ad = a.isDirectory() ? 0 : 1; + const bd = b.isDirectory() ? 0 : 1; + if (ad !== bd) return ad - bd; + return a.name.localeCompare(b.name); + }, + name_asc: (a: import('node:fs').Dirent, b: import('node:fs').Dirent) => + a.name.localeCompare(b.name), + name_desc: (a: import('node:fs').Dirent, b: import('node:fs').Dirent) => + b.name.localeCompare(a.name), + // mtime_desc and size_desc would need stat calls per entry; we currently + // only sort dirents which don't carry mtime. Fall back to name_asc and + // upgrade in a later chain when telemetry shows demand. SCHEMAS §9.2 + // permits this — the field is a hint, not a hard contract. + mtime_desc: (a: import('node:fs').Dirent, b: import('node:fs').Dirent) => + a.name.localeCompare(b.name), + size_desc: (a: import('node:fs').Dirent, b: import('node:fs').Dirent) => + a.name.localeCompare(b.name), + }[sort]; + ds.sort(cmp); +} + +/** + * Minimal glob → RegExp converter — handles `*`, `**`, `?`. Mirrors the + * subset used by VSCode `files.exclude` (we don't accept `{a,b}` brace + * groups since SCHEMAS §3.9 doesn't pin a glob grammar). Patterns that + * don't contain `**` are anchored to the FULL path so `*.log` matches + * any `.log` filename. + */ +function matchesAnyGlob(rel: string, globs: readonly string[]): boolean { + for (const g of globs) { + if (globToRegExp(g).test(rel)) return true; + } + return false; +} + +function globToRegExp(glob: string): RegExp { + let re = '^'; + let i = 0; + while (i < glob.length) { + const ch = glob[i]!; + if (ch === '*' && glob[i + 1] === '*') { + re += '.*'; + i += 2; + if (glob[i] === '/') i++; + } else if (ch === '*') { + re += '[^/]*'; + i++; + } else if (ch === '?') { + re += '[^/]'; + i++; + } else if (/[.+^${}()|[\]\\]/.test(ch)) { + re += `\\${ch}`; + i++; + } else { + re += ch; + i++; + } + } + re += '$'; + return new RegExp(re); +} + +async function buildFsEntry( + relPath: string, + name: string, + absPath: string, + dirent: import('node:fs').Dirent, + withMimeAndBinary: boolean, +): Promise { + let st: import('node:fs').Stats | undefined; + try { + st = await fs.lstat(absPath); + } catch { + // Disappeared between readdir and lstat — surface a minimal record. + } + return buildFsEntryFromDirentAndStat( + relPath, + name, + absPath, + dirent, + st, + withMimeAndBinary, + ); +} + +function buildFsEntryFromDirentAndStat( + relPath: string, + name: string, + absPath: string, + dirent: import('node:fs').Dirent, + st: import('node:fs').Stats | undefined, + withMimeAndBinary: boolean, +): FsEntry { + const kind: FsEntry['kind'] = dirent.isSymbolicLink() + ? 'symlink' + : dirent.isDirectory() + ? 'directory' + : 'file'; + const entry: FsEntry = { + path: relPath, + name, + kind, + modified_at: st ? new Date(st.mtimeMs).toISOString() : new Date(0).toISOString(), + }; + if (kind === 'file' && st !== undefined) { + entry.size = st.size; + } + if (st !== undefined) { + entry.etag = buildEtag(st); + } + if (withMimeAndBinary && kind === 'file') { + entry.mime = guessMime(relPath, false); + const lang = guessLanguageId(relPath); + if (lang !== undefined) entry.language_id = lang; + } + void absPath; // reserved for symlink target resolution in W11 + return entry; +} + +/** + * Build an `FsEntry` directly from a `Stats` object (no `Dirent`). Used by + * `:stat` / `:stat_many` where we only have the path, not a parent + * `readdir` result. The kind is derived from the Stats accessors instead + * of Dirent flags. + * + * `withMimeAndBinary: true` means the entry is the FULL response (e.g. + * `:stat`), where SCHEMAS §9.2 line 549 mandates mime + language_id. + * `false` means a lighter shape (e.g. `:stat_many` items where the same + * data could be fetched in bulk later via `:read` per file). + */ +function buildFsEntryFromStat( + relPath: string, + name: string, + absPath: string, + st: import('node:fs').Stats, + withMimeAndBinary: boolean, +): FsEntry { + // `fs.stat` follows symlinks; we get the target's kind. The wire kind + // for a followed-through symlink is the underlying file/dir, not + // `symlink` — which matches what most clients want. Use `fs.lstat` + // upstream if symlink-as-symlink visibility is needed. + const kind: FsEntry['kind'] = st.isDirectory() ? 'directory' : 'file'; + const entry: FsEntry = { + path: relPath, + name, + kind, + modified_at: new Date(st.mtimeMs).toISOString(), + etag: buildEtag(st), + }; + if (kind === 'file') { + entry.size = st.size; + } + if (withMimeAndBinary && kind === 'file') { + entry.mime = guessMime(relPath, false); + const lang = guessLanguageId(relPath); + if (lang !== undefined) entry.language_id = lang; + } + void absPath; + return entry; +} + +function buildEtag(st: import('node:fs').Stats): string { + // `mtimeMs + size + ino` packed into a hex string — cheap, stable per + // file, invalidates on write. Not a cryptographic hash; SCHEMAS §9.2 + // explicitly permits this approach. + return [ + Math.floor(st.mtimeMs).toString(36), + st.size.toString(36), + st.ino.toString(36), + ].join('-'); +} + +function detectBinary(buf: Buffer): boolean { + if (buf.length === 0) return false; + let nonPrintable = 0; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]!; + if (b === 0) return true; // null byte = binary + // Printable ASCII range: 9 (tab), 10 (LF), 13 (CR), 32-126. + if (b === 9 || b === 10 || b === 13) continue; + if (b >= 32 && b <= 126) continue; + // UTF-8 continuation / multibyte: 0x80-0xFF treated as non-ASCII; we + // don't try to decode here — we just count. + nonPrintable++; + } + return nonPrintable / buf.length > FS_BINARY_NONPRINTABLE_FRACTION; +} + +async function readFileRange( + absPath: string, + start: number, + end: number, +): Promise { + if (end <= start) return Buffer.alloc(0); + const fh = await fs.open(absPath, 'r'); + try { + const length = end - start; + const buf = Buffer.allocUnsafe(length); + const { bytesRead } = await fh.read(buf, 0, length, start); + return bytesRead === length ? buf : buf.subarray(0, bytesRead); + } finally { + await fh.close(); + } +} + +const EXT_TO_MIME: Readonly> = { + '.ts': 'text/typescript', + '.tsx': 'text/typescript', + '.js': 'text/javascript', + '.jsx': 'text/javascript', + '.mjs': 'text/javascript', + '.cjs': 'text/javascript', + '.json': 'application/json', + '.md': 'text/markdown', + '.html': 'text/html', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.pdf': 'application/pdf', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'application/toml', + '.sh': 'text/x-shellscript', + '.py': 'text/x-python', + '.rs': 'text/rust', + '.go': 'text/x-go', +}; + +function guessMime(relPath: string, isBinary: boolean): string { + const ext = path.extname(relPath).toLowerCase(); + const mapped = EXT_TO_MIME[ext]; + if (mapped !== undefined) return mapped; + return isBinary ? 'application/octet-stream' : 'text/plain'; +} + +const EXT_TO_LANGUAGE: Readonly> = { + '.ts': 'typescript', + '.tsx': 'typescriptreact', + '.js': 'javascript', + '.jsx': 'javascriptreact', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.json': 'json', + '.md': 'markdown', + '.html': 'html', + '.css': 'css', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.sh': 'shellscript', + '.py': 'python', + '.rs': 'rust', + '.go': 'go', +}; + +function guessLanguageId(relPath: string): string | undefined { + return EXT_TO_LANGUAGE[path.extname(relPath).toLowerCase()]; +} + +function countLines(text: string): number { + if (text.length === 0) return 0; + let n = 1; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) n++; + } + // Trailing newline shouldn't add an extra empty line per common convention. + if (text.charCodeAt(text.length - 1) === 10) n--; + return Math.max(0, n); +} + +function mapStatError(err: unknown, inputPath: string): Error { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return new FsPathNotFoundError(inputPath); + } + return err as Error; +} + +/** + * Map a thrown service error to the wire `{code, msg}` shape used by + * `:list_many.partial_errors`. Used ONLY for per-path failures inside the + * batch handler; safety + session errors are re-thrown batch-wide before + * reaching this helper (see `listMany`). + * + * Unknown errors fall through to `50001 internal.error` rather than + * propagating — a single broken path shouldn't poison the whole batch. + */ +function mapToWireError(err: unknown): { code: number; msg: string } { + if (err instanceof FsPathNotFoundError) { + return { code: 40409, msg: err.message }; + } + if (err instanceof FsIsDirectoryError) { + return { code: 40906, msg: err.message }; + } + if (err instanceof FsIsBinaryError) { + return { code: 40907, msg: err.message }; + } + if (err instanceof FsTooLargeError) { + return { code: 41302, msg: err.message }; + } + if (err instanceof FsTooManyResultsError) { + return { code: 41303, msg: err.message }; + } + return { code: 50001, msg: (err as Error)?.message ?? 'internal error' }; +} + +// SessionNotFoundError is re-exported for use by the route layer's error +// mapper; the import survives even when this commit doesn't reference it +// at runtime. +void SessionNotFoundError; diff --git a/packages/daemon/src/services/fs-watcher.ts b/packages/daemon/src/services/fs-watcher.ts new file mode 100644 index 000000000..f539e172e --- /dev/null +++ b/packages/daemon/src/services/fs-watcher.ts @@ -0,0 +1,786 @@ +/** + * `IFsWatcher` — daemon-OWN filesystem watcher (W12 / Chain 14, P1.14). + * + * **Responsibility**: maintain one `chokidar.FSWatcher` per active session, + * accept dynamic `subscribe.watch_fs` / `watch_fs_add` / `watch_fs_remove` + * mutations from WS connections, coalesce raw chokidar events into the + * `event.fs.changed` wire shape (WS.md §4.9), and push each coalesced + * frame ONLY to the connections whose subscribed paths overlap the + * affected paths (WS.md §5 last row: "仅推给 watch_fs 内 path 与变更 + * path 有交集的连接"). + * + * **Daemon-OWN distinction**: like `IFsService` / `IFsSearchService` / + * `IFsGitService`, this service is NOT a thin wrapper around an + * `IHarnessBridge` call. agent-core has no fs-watch surface; the wire path + * directly addresses `session.metadata.cwd` and is implemented against + * Node `fs` + `chokidar`. So it lives in `packages/daemon`, NOT in + * `@moonshot-ai/services`. + * + * # Architecture (per W12 prompt §critical-design-questions) + * + * - Per-session `chokidar.FSWatcher` instance. + * - Lazily created on first `addPaths(sessionId, ...)`. + * - Closed (and entry dropped) when no connection has any path subscribed + * for that session (`session.paths.size === 0`). + * - Per-session 200ms debounce window collecting raw events. + * - Inside the window: at most `MAX_CHANGES_PER_WINDOW` (500) per-entry + * changes accumulate before we flip to truncated-mode (WS.md §4.9 + * `truncated:true` + `count`; ROADMAP Chain 14 AC #2). + * - Per-connection state: + * - `Map>>` for the + * overlap filter AND for the 100-path cap enforcement + * (`MAX_PATHS_PER_CONNECTION`). + * - Per-session aggregate state: + * - `Map, + * pending: PendingWindow }>`. + * - `refCount` is the number of connections that have asked for this + * path. We `chokidar.add(path)` on first ref and `chokidar.unwatch(path)` + * on last unref. + * - Path safety: every input path runs through W10's `resolveSafePath` + * BEFORE chokidar sees it. We propagate `FsPathEscapesError` so the WS + * adapter can translate to a `41304` error frame (today the WS path + * uses 42902 for over-cap; we surface 41304-bearing errors via the + * same error throw). + * + * # Wire path + * + * 1. `WsConnection.onSubscribe` / `onWatchFsAdd` / `onWatchFsRemove` + * → resolve `session.metadata.cwd` via `ISessionService.get(sid)`. + * → resolve each input path via `resolveSafePath(cwd, p)`. + * → check the projected per-connection total count against + * `MAX_PATHS_PER_CONNECTION` (100). Exceed → throw `FsWatchLimitError`. + * → call `IFsWatcher.addPaths(sessionId, connectionId, absPaths)`. + * 2. chokidar emits `'all'` events. + * → fs-watcher pushes the (`{absPath, action, kind}`) tuple into the + * session's pending window. If no timer is running, schedule one + * for 200ms. + * → if the pending window has > 500 entries when a new event comes + * in, flip the window's `truncated` flag and stop accumulating + * per-entry detail (just keep counting raw events). + * 3. Timer fires after 200ms. + * → for each connection subscribed to this session, filter entries + * whose `absPath` is under one of that connection's subscribed + * absPaths. If non-empty, build an `event.fs.changed` envelope and + * push it directly to the connection (bypassing the per-session + * EventBus broadcast — this is targeted push, not broadcast). + * → clear the pending window. + * + * # Why bypass the EventBus seq channel + * + * `DaemonEventBus.publish` is typed around agent-core's `Event` union + * (camelCase, `sessionId` discriminator). `event.fs.changed` is a + * daemon-OWN event with NO agent-core source. Threading it through the + * `Event` union would force a type-system hole (we'd have to cast) and + * — more importantly — would entangle the fs-change stream with the + * per-session ring-buffer seq counter that downstream replay logic depends + * on. Fs changes don't need replay-on-reconnect (clients should re-`:list` + * on reconnect anyway, per WS.md §4.9 "truncated → re-fetch"). So we push + * direct via the targeted connection set. + * + * # Errors + * + * - `FsWatchLimitError` → routed to `42902 fs.watch_limit_exceeded` + * by the WS handler when a connection's total subscribed paths + * (across all sessions) would exceed 100. + * - `FsPathEscapesError` (from `resolveSafePath`) → bubbled up; WS + * handler maps to `41304 fs.path_escapes_session`. + * - `SessionNotFoundError` → bubbled up; WS handler maps to + * `40401 session.not_found`. + * + * # Anti-corruption + * + * Imports only `chokidar`, `node:fs`, `node:path`, agent-core + * (`Disposable` + decorator), `@moonshot-ai/services` (for `ISessionService`), + * and our own `fs-path-safety`. ZERO SDK imports. + * + * # Tunables exposed for tests + * + * - `debounceMs` (default 200) — collapse window. + * - `maxChangesPerWindow` (default 500) — truncate threshold. + * - `maxPathsPerConnection` (default 100) — cap for 42902. + */ + +import nodePath from 'node:path'; + +import { FSWatcher } from 'chokidar'; + +import { + Disposable, + createDecorator, +} from '@moonshot-ai/agent-core'; +import { ISessionService } from '@moonshot-ai/services'; +// `SessionNotFoundError` is thrown by `ISessionService.get` when the session +// doesn't exist; we let it propagate to the WS handler which maps to 40401. + +import type { FsChangeEntry, FsChangeAction, FsChangeKind } from '@moonshot-ai/protocol'; + +import { ILogger } from './logger.js'; +// `FsPathEscapesError` and `resolveSafePath` are used by the WS adapter +// in `start.ts` BEFORE calling into this service; we don't import them +// here. The watcher only sees pre-validated absolute paths. + +import type { WsConnection } from '../ws/connection.js'; + +/* ------------------------------------------------------------------------- + * Tunable constants + * ----------------------------------------------------------------------- */ + +/** WS.md §4.9 — 200ms coalesce window. */ +const DEFAULT_DEBOUNCE_MS = 200; + +/** + * ROADMAP Chain 14 AC #2 — when a single window collects > this many raw + * change events, we flip to `truncated:true` mode and stop accumulating + * per-entry detail. The client is expected to throw away local fs state + * and re-`:list` to resync. WS.md §4.9 mentions "单窗口 changes 超 500 + * 时 true" — 500 is the spec threshold. + */ +const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; + +/** ROADMAP Chain 14 AC #4 — per-connection total watched-path cap. */ +const DEFAULT_MAX_PATHS_PER_CONNECTION = 100; + +/* ------------------------------------------------------------------------- + * Error sentinels + * ----------------------------------------------------------------------- */ + +/** + * Thrown when a WS connection's projected total watched-paths count + * (across all sessions on that connection) would exceed + * `maxPathsPerConnection` (default 100, ROADMAP Chain 14 AC #4). The WS + * handler maps this to envelope/ack `code: 42902 fs.watch_limit_exceeded`. + */ +export class FsWatchLimitError extends Error { + readonly connectionId: string; + readonly attempted: number; + readonly limit: number; + constructor(connectionId: string, attempted: number, limit: number) { + super( + `connection ${connectionId} would watch ${attempted} paths; limit is ${limit}`, + ); + this.name = 'FsWatchLimitError'; + this.connectionId = connectionId; + this.attempted = attempted; + this.limit = limit; + } +} + +/* ------------------------------------------------------------------------- + * Service interface (DI decorator) + * ----------------------------------------------------------------------- */ + +/** + * `event.fs.changed` envelope built by `IFsWatcher` and consumed by the + * targeted push path. We do NOT route this through `DaemonEventBus` + * (it's typed for agent-core's `Event` union and threads through the + * per-session ring-buffer seq counter). Instead we push directly to the + * filtered connection set — `seq` here is a daemon-mint that increments + * inside `FsWatcherService` so client deduping logic still has SOMETHING + * to compare. Always per-session monotonic, starts at 1. + */ +export interface FsChangedFrame { + type: 'event.fs.changed'; + seq: number; + session_id: string; + timestamp: string; + payload: { + changes: FsChangeEntry[]; + coalesced_window_ms: number; + truncated?: boolean; + count?: number; + }; +} + +export interface IFsWatcher { + /** + * Add a (sessionId, paths) subscription tied to a specific connection. + * Caller MUST have already resolved each path through `resolveSafePath` + * to absolute. We enforce per-connection cap on the projected total + * and throw `FsWatchLimitError` if exceeded. + * + * Idempotent: re-adding the same `(sessionId, connectionId, absPath)` + * triple has no effect and does not bump the per-connection count. + * + * Returns the dedup'd list of absolute paths that this connection now + * watches for the session (post-mutation). + */ + addPaths( + sessionId: string, + connectionId: string, + absPaths: readonly string[], + ): readonly string[]; + + /** + * Remove a (sessionId, paths) subscription tied to a specific connection. + * Idempotent. Closes the session's chokidar watcher if this leaves it + * with no remaining subscribers. + * + * Returns the dedup'd list of absolute paths this connection STILL + * watches for the session (post-mutation). + */ + removePaths( + sessionId: string, + connectionId: string, + absPaths: readonly string[], + ): readonly string[]; + + /** Total absolute paths watched on this connection across all sessions. */ + countForConnection(connectionId: string): number; + + /** + * Drop all subscriptions for this connection across all sessions. Closes + * any chokidar watcher whose path-set became empty. Idempotent. + */ + forgetConnection(connectionId: string): void; + + /** + * Look up the absolute paths this connection currently watches under + * `sessionId`. Used by the WS ack to populate `watched_paths` (returned + * as POSIX-relative to `session.cwd`). + */ + watchedPaths(connectionId: string, sessionId: string): readonly string[]; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IFsWatcher = createDecorator('IFsWatcher'); + +/* ------------------------------------------------------------------------- + * Connection-side delivery sink (structural) + * + * Mirrors the `WsConnection.send(unknown)` surface so tests can pass a + * stub. Production wires `WsConnection` instances directly. + * ----------------------------------------------------------------------- */ + +export interface FsWatcherDeliverySink { + send(frame: FsChangedFrame): void; +} + +/** + * Lookup hook: `connectionId → FsWatcherDeliverySink`. In production, the + * `IFsWatcher` impl walks `ISessionClientsService` to find every active + * connection by id; in tests we inject a `Map` stub. + * + * Returning `undefined` for a connectionId is treated as "connection gone" + * — we silently skip delivery and clean up its state on the next + * `forgetConnection` (the WS layer is responsible for calling that on + * socket close). + */ +export interface FsWatcherConnectionLookup { + resolve(connectionId: string): FsWatcherDeliverySink | undefined; +} + +/* ------------------------------------------------------------------------- + * Internal types + * ----------------------------------------------------------------------- */ + +interface PendingChange { + /** Absolute path of the affected entry. */ + absPath: string; + action: FsChangeAction; + kind: FsChangeKind; +} + +interface SessionEntry { + /** Live chokidar watcher; closed + dropped on last unref. */ + watcher: FSWatcher; + /** `sessionId.cwd` (absolute, post-realpath) for relative-path mapping. */ + cwd: string; + /** `absPath → refCount` across all connections subscribed to this session. */ + pathRefs: Map; + /** `connectionId → Set` for overlap filtering on emit. */ + connectionPaths: Map>; + /** Accumulating changes for the current 200ms window. */ + pendingChanges: PendingChange[]; + /** Raw event count (used for `truncated.count`). */ + pendingRawCount: number; + /** True once `pendingChanges.length > maxChangesPerWindow`. */ + truncated: boolean; + /** Timer for the active debounce window; `undefined` between windows. */ + debounceTimer: NodeJS.Timeout | undefined; + /** Per-session seq counter, monotonic, starts at 1. */ + seq: number; +} + +/* ------------------------------------------------------------------------- + * Implementation + * ----------------------------------------------------------------------- */ + +export interface FsWatcherServiceOptions { + debounceMs?: number; + maxChangesPerWindow?: number; + maxPathsPerConnection?: number; + /** + * Factory for the underlying chokidar watcher. Injected for tests; in + * production this defaults to `new FSWatcher({ ignoreInitial: true, + * persistent: false, ignored: ['**\/.git/**'] })`. + * + * We pass `ignored` for `.git/**` because git operations (`checkout`, + * `stash`) churn an enormous amount of inside-`.git/` noise that the + * client doesn't care about (WS.md §4.9 实现要点: "daemon 不 emit + * `.git/` 内部变化"). + */ + watcherFactory?: () => FSWatcher; +} + +export class FsWatcherService extends Disposable implements IFsWatcher { + private readonly debounceMs: number; + private readonly maxChangesPerWindow: number; + private readonly maxPathsPerConnection: number; + private readonly makeWatcher: () => FSWatcher; + private readonly sessions = new Map(); + /** `connectionId → Map>`. */ + private readonly connections = new Map>>(); + + constructor( + // P2.6: VSCode-style static-first / services-last. `lookup` is a + // closure built at start.ts so it stays a positional static dep; + // `options` is the config bag. `logger` + `_sessionService` are + // auto-injected via @ILogger / @ISessionService. The + // `_sessionService` parameter is intentionally unused (reserved to + // lock construction order so IFsWatcher disposes BEFORE + // ISessionService — see field doc above) — the leading underscore + // keeps the linter quiet. + private readonly lookup: FsWatcherConnectionLookup, + options: FsWatcherServiceOptions, + @ILogger private readonly logger: ILogger, + @ISessionService _sessionService: ISessionService, + ) { + super(); + this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + this.maxChangesPerWindow = + options.maxChangesPerWindow ?? DEFAULT_MAX_CHANGES_PER_WINDOW; + this.maxPathsPerConnection = + options.maxPathsPerConnection ?? DEFAULT_MAX_PATHS_PER_CONNECTION; + this.makeWatcher = + options.watcherFactory ?? + (() => + new FSWatcher({ + ignoreInitial: true, + persistent: false, + // WS.md §4.9: filter `.git/` noise. Regex matches a `.git` segment + // anywhere in the absolute path. + ignored: (p: string) => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p), + })); + } + + addPaths( + sessionId: string, + connectionId: string, + absPaths: readonly string[], + ): readonly string[] { + if (this._isDisposed) return []; + + // Project the new total for this connection (assuming all `absPaths` are + // additions). Dedup against existing first. + const connSessions = this.getOrCreateConnection(connectionId); + let existingForSession = connSessions.get(sessionId); + const newlyAdded: string[] = []; + let projectedTotal = this.countForConnection(connectionId); + for (const abs of absPaths) { + if (existingForSession?.has(abs)) continue; + newlyAdded.push(abs); + projectedTotal += 1; + } + if (projectedTotal > this.maxPathsPerConnection) { + throw new FsWatchLimitError( + connectionId, + projectedTotal, + this.maxPathsPerConnection, + ); + } + if (newlyAdded.length === 0) { + // Nothing to do; return current set. + return existingForSession ? Array.from(existingForSession) : []; + } + + // Lazy-create session entry. cwd is best-effort: we trust the caller + // resolved absPaths against the session's real cwd, so any one of + // the absPaths' shared prefix would do — but we don't have the cwd + // in hand here. The caller is expected to pre-call + // `bindSessionCwd` (see `_bindCwd` below) OR the lookup callback + // will pass it on first add. We use the longest absolute-path + // segment that is a prefix of all absPaths; failing that, fall back + // to the first absPath's dirname. This is only used for + // wire-path conversion at emit time and the WS handler will + // override via `bindSessionCwd` before any emit can happen. + let entry = this.sessions.get(sessionId); + if (!entry) { + entry = this.createSessionEntry(sessionId, deriveSharedCwd(newlyAdded)); + this.sessions.set(sessionId, entry); + } + + if (!existingForSession) { + existingForSession = new Set(); + connSessions.set(sessionId, existingForSession); + } + const adds: string[] = []; + for (const abs of newlyAdded) { + existingForSession.add(abs); + const ref = entry.pathRefs.get(abs) ?? 0; + entry.pathRefs.set(abs, ref + 1); + // Add to chokidar only on first refcount. + if (ref === 0) { + adds.push(abs); + } + // Always tracked in per-connection set. + let cps = entry.connectionPaths.get(connectionId); + if (!cps) { + cps = new Set(); + entry.connectionPaths.set(connectionId, cps); + } + cps.add(abs); + } + if (adds.length > 0) { + entry.watcher.add(adds); + } + return Array.from(existingForSession); + } + + removePaths( + sessionId: string, + connectionId: string, + absPaths: readonly string[], + ): readonly string[] { + if (this._isDisposed) return []; + const entry = this.sessions.get(sessionId); + if (!entry) return []; + const connSessions = this.connections.get(connectionId); + const connSessionPaths = connSessions?.get(sessionId); + if (!connSessionPaths) return []; + + const unwatch: string[] = []; + for (const abs of absPaths) { + if (!connSessionPaths.has(abs)) continue; + connSessionPaths.delete(abs); + const cps = entry.connectionPaths.get(connectionId); + cps?.delete(abs); + if (cps && cps.size === 0) entry.connectionPaths.delete(connectionId); + const ref = (entry.pathRefs.get(abs) ?? 1) - 1; + if (ref <= 0) { + entry.pathRefs.delete(abs); + unwatch.push(abs); + } else { + entry.pathRefs.set(abs, ref); + } + } + if (unwatch.length > 0) { + entry.watcher.unwatch(unwatch); + } + // Per-connection cleanup. + if (connSessionPaths.size === 0) { + connSessions?.delete(sessionId); + if (connSessions && connSessions.size === 0) { + this.connections.delete(connectionId); + } + } + // Per-session cleanup: if no path references remain, close the watcher. + if (entry.pathRefs.size === 0) { + this.disposeSessionEntry(sessionId, entry); + } + return connSessionPaths ? Array.from(connSessionPaths) : []; + } + + countForConnection(connectionId: string): number { + const m = this.connections.get(connectionId); + if (!m) return 0; + let total = 0; + for (const set of m.values()) total += set.size; + return total; + } + + forgetConnection(connectionId: string): void { + const sessionMap = this.connections.get(connectionId); + if (!sessionMap) return; + // Snapshot to avoid mutation-during-iteration. + const entries = Array.from(sessionMap.entries()); + for (const [sid, paths] of entries) { + this.removePaths(sid, connectionId, Array.from(paths)); + } + this.connections.delete(connectionId); + } + + watchedPaths(connectionId: string, sessionId: string): readonly string[] { + const set = this.connections.get(connectionId)?.get(sessionId); + if (!set) return []; + return Array.from(set); + } + + /** + * WS adapter calls this AFTER resolving the session's cwd so the + * watcher can map absolute → POSIX-relative paths on emit. Idempotent — + * subsequent calls with a different cwd overwrite (which would be a bug + * but we log + accept). + */ + bindSessionCwd(sessionId: string, cwd: string): void { + let entry = this.sessions.get(sessionId); + if (!entry) { + entry = this.createSessionEntry(sessionId, cwd); + this.sessions.set(sessionId, entry); + return; + } + if (entry.cwd !== cwd) { + this.logger.debug( + { sessionId, oldCwd: entry.cwd, newCwd: cwd }, + 'fs-watcher cwd override', + ); + entry.cwd = cwd; + } + } + + /* ------------------------------------------------------------- internals */ + + private getOrCreateConnection( + connectionId: string, + ): Map> { + let m = this.connections.get(connectionId); + if (!m) { + m = new Map(); + this.connections.set(connectionId, m); + } + return m; + } + + private createSessionEntry(sessionId: string, cwd: string): SessionEntry { + const watcher = this.makeWatcher(); + const entry: SessionEntry = { + watcher, + cwd, + pathRefs: new Map(), + connectionPaths: new Map(), + pendingChanges: [], + pendingRawCount: 0, + truncated: false, + debounceTimer: undefined, + seq: 0, + }; + watcher.on( + 'all', + (eventName: string, absPath: string) => { + this.onRawChange(sessionId, entry, eventName, absPath); + }, + ); + watcher.on('error', (err) => { + this.logger.warn( + { sessionId, err: String(err) }, + 'fs-watcher chokidar error', + ); + }); + return entry; + } + + private disposeSessionEntry(sessionId: string, entry: SessionEntry): void { + if (entry.debounceTimer) { + clearTimeout(entry.debounceTimer); + entry.debounceTimer = undefined; + } + void entry.watcher.close().catch((err) => { + this.logger.warn( + { sessionId, err: String(err) }, + 'fs-watcher close failed', + ); + }); + this.sessions.delete(sessionId); + } + + private onRawChange( + sessionId: string, + entry: SessionEntry, + eventName: string, + absPath: string, + ): void { + if (this._isDisposed) return; + const action = mapChokidarEventToAction(eventName); + if (action === undefined) return; // 'ready', 'raw', 'all', 'error' + const kind = mapChokidarEventToKind(eventName); + + entry.pendingRawCount += 1; + if (entry.truncated) { + // Already over threshold — keep counting but don't accumulate per-entry. + } else { + entry.pendingChanges.push({ absPath, action, kind }); + if (entry.pendingChanges.length > this.maxChangesPerWindow) { + entry.truncated = true; + // Drop accumulated detail to free memory; we only emit the count. + entry.pendingChanges = []; + } + } + + if (entry.debounceTimer === undefined) { + const timer = setTimeout(() => this.flushWindow(sessionId), this.debounceMs); + // Unref so tests don't keep the loop alive on lingering windows. + timer.unref?.(); + entry.debounceTimer = timer; + } + } + + private flushWindow(sessionId: string): void { + const entry = this.sessions.get(sessionId); + if (!entry) return; + entry.debounceTimer = undefined; + if (entry.pendingRawCount === 0) return; + const truncated = entry.truncated; + const rawCount = entry.pendingRawCount; + const pending = entry.pendingChanges; + // Reset for next window BEFORE emit (defensive: emit could schedule a + // synchronous re-fire if the consumer turns around and writes a file). + entry.pendingChanges = []; + entry.pendingRawCount = 0; + entry.truncated = false; + + // Build per-connection filtered payload. + for (const [connectionId, connPaths] of entry.connectionPaths) { + const sink = this.lookup.resolve(connectionId); + if (!sink) continue; + let perConnChanges: FsChangeEntry[]; + if (truncated) { + perConnChanges = []; + } else { + perConnChanges = []; + for (const ch of pending) { + if (!isUnderAny(ch.absPath, connPaths)) continue; + const relPath = toPosixRelative(entry.cwd, ch.absPath); + perConnChanges.push({ + path: relPath, + change: ch.action, + kind: ch.kind, + }); + } + if (perConnChanges.length === 0) continue; + } + entry.seq += 1; + const frame: FsChangedFrame = { + type: 'event.fs.changed', + seq: entry.seq, + session_id: sessionId, + timestamp: new Date().toISOString(), + payload: { + changes: perConnChanges, + coalesced_window_ms: this.debounceMs, + ...(truncated ? { truncated: true, count: rawCount } : {}), + }, + }; + try { + sink.send(frame); + } catch (err) { + this.logger.warn( + { connectionId, err: String(err) }, + 'fs-watcher send failed', + ); + } + } + } + + override dispose(): void { + if (this._isDisposed) return; + const entries = Array.from(this.sessions.entries()); + for (const [sid, e] of entries) { + this.disposeSessionEntry(sid, e); + } + this.connections.clear(); + super.dispose(); + } +} + +/* ------------------------------------------------------------------------- + * Production-time connection lookup adapter + * + * Walks `ISessionClientsService` lazily to find a connection by id. + * We can't add a `getById` to `ISessionClientsService` without breaking + * its index invariant (sessionId → connections); instead we walk every + * session bucket. With PLAN's "O(10) WS clients per daemon" assumption + * this is fine. If the cardinality grows we can extend the registry. + * ----------------------------------------------------------------------- */ + +/* ------------------------------------------------------------------------- + * Helpers + * ----------------------------------------------------------------------- */ + +function mapChokidarEventToAction(name: string): FsChangeAction | undefined { + switch (name) { + case 'add': + case 'addDir': + return 'created'; + case 'change': + return 'modified'; + case 'unlink': + case 'unlinkDir': + return 'deleted'; + default: + return undefined; + } +} + +function mapChokidarEventToKind(name: string): FsChangeKind { + switch (name) { + case 'addDir': + case 'unlinkDir': + return 'directory'; + // `add` / `change` / `unlink` are file events in chokidar 4. Symlinks + // emit as `add` with no separate event; consumers that need to + // distinguish should call `:stat`. We classify as `file` here; the + // wire schema also accepts `symlink` but we don't generate it. + default: + return 'file'; + } +} + +function isUnderAny(absPath: string, parents: Set): boolean { + for (const parent of parents) { + if (absPath === parent) return true; + // Must check with separator to avoid '/foo/bar2' under '/foo/bar' + // false-positive. We add `path.sep` once. + const sep = nodePath.sep; + if (absPath.startsWith(parent + sep)) return true; + // POSIX cross-check (some test paths may pre-canonicalize separators). + if (sep !== '/' && absPath.startsWith(parent + '/')) return true; + } + return false; +} + +function toPosixRelative(cwd: string, abs: string): string { + if (abs === cwd) return '.'; + const rel = nodePath.relative(cwd, abs); + if (rel === '') return '.'; + return rel.split(nodePath.sep).join('/'); +} + +function deriveSharedCwd(absPaths: readonly string[]): string { + if (absPaths.length === 0) return '/'; + if (absPaths.length === 1) return nodePath.dirname(absPaths[0]!); + // Common prefix path-segment walk. + let prefix = absPaths[0]!.split(nodePath.sep); + for (let i = 1; i < absPaths.length; i++) { + const segs = absPaths[i]!.split(nodePath.sep); + let j = 0; + while (j < prefix.length && j < segs.length && prefix[j] === segs[j]) j++; + prefix = prefix.slice(0, j); + } + return prefix.length === 0 ? '/' : prefix.join(nodePath.sep) || nodePath.sep; +} + +/** + * Best-effort `resolve(connectionId)` against a connection registry. + * Caller passes a lookup function returning the live `WsConnection` for an + * id (typically `IConnectionRegistry.get` bound). + */ +export function createConnectionLookup( + getConnection: (connId: string) => WsConnection | undefined, +): FsWatcherConnectionLookup { + return { + resolve(connectionId: string): FsWatcherDeliverySink | undefined { + const conn = getConnection(connectionId); + if (!conn) return undefined; + return { + send(frame): void { + conn.send(frame); + }, + }; + }, + }; +} + +/** + * `void`-cast for the `fsp` import — keeps the realpath-on-cwd helper + * available if a future iteration of `IFsWatcher` wants to canonicalize + * cwd internally (today the WS handler does it via `resolveSafePath`). + */ diff --git a/packages/daemon/src/services/logger.ts b/packages/daemon/src/services/logger.ts new file mode 100644 index 000000000..a986b0cd4 --- /dev/null +++ b/packages/daemon/src/services/logger.ts @@ -0,0 +1,73 @@ +/** + * `ILogger` DI surface (W4.4 / P0.14). + * + * Thin interface over the pino logger so consumer services don't take a + * direct dependency on the `pino` package. The daemon registers a + * `PinoLogger` adapter that delegates to the `DaemonLogger` (pino) instance + * Fastify shares with us at boot. + * + * Registered FIRST in the DI container (= constructed first when consumers + * dispatch `accessor.get(ILogger)`) so it disposes LAST in the + * reverse-construction-order teardown chain (W3 handoff §Gotchas). Other + * services log on their own `dispose()`; if the logger went first they'd NPE. + */ + +import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; + +import type { DaemonLogger } from '../logger.js'; + +export interface ILogger { + info(obj: object | string, msg?: string): void; + warn(obj: object | string, msg?: string): void; + error(obj: object | string, msg?: string): void; + debug(obj: object | string, msg?: string): void; + /** Pino-style child logger that inherits parent bindings. */ + child(bindings: object): ILogger; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ILogger = createDecorator('ILogger'); + +/** + * Adapter that satisfies `ILogger` by delegating to a `DaemonLogger` (pino). + * No-op `dispose()`: pino's lifetime is managed by Fastify / the host process, + * NOT by the DI container. Disposing here would close stdout writer streams + * that other components still need during teardown. + */ +export class PinoLogger extends Disposable implements ILogger { + constructor(private readonly logger: DaemonLogger) { + super(); + } + + info(obj: object | string, msg?: string): void { + if (typeof obj === 'string') { + this.logger.info(obj); + return; + } + this.logger.info(obj, msg); + } + warn(obj: object | string, msg?: string): void { + if (typeof obj === 'string') { + this.logger.warn(obj); + return; + } + this.logger.warn(obj, msg); + } + error(obj: object | string, msg?: string): void { + if (typeof obj === 'string') { + this.logger.error(obj); + return; + } + this.logger.error(obj, msg); + } + debug(obj: object | string, msg?: string): void { + if (typeof obj === 'string') { + this.logger.debug(obj); + return; + } + this.logger.debug(obj, msg); + } + child(bindings: object): ILogger { + return new PinoLogger(this.logger.child(bindings)); + } +} diff --git a/packages/daemon/src/services/question-broker.ts b/packages/daemon/src/services/question-broker.ts new file mode 100644 index 000000000..8356c6421 --- /dev/null +++ b/packages/daemon/src/services/question-broker.ts @@ -0,0 +1,276 @@ +/** + * `DaemonQuestionBroker` (W8.2 / Chain 6; was W4.4 stub). + * + * Reverse-RPC broker for Question (data-collection) interaction. Mirrors + * `DaemonApprovalBroker` with one addition: `dismiss(id)` is a first-class + * outcome (SCHEMAS.md §6.3) — the user closes the panel without answering; + * agent-core's pending Promise resolves with `null` (NOT a rejection). + * + * 1. `request(req)`: + * - Mints `question_id = ulid()`. + * - Builds protocol `QuestionRequest` via the services adapter + * (`questionToBrokerRequest`). + * - Broadcasts `event.question.requested` through `IEventBus.publish`. + * - Holds the Promise + 60s timer; on resolve, settles with normalized + * answers; on dismiss, settles with `null`; on timeout, broadcasts + * `event.question.expired` and rejects with `QuestionExpiredError`. + * + * 2. `resolve(questionId, response)`: + * - Broadcasts `event.question.answered`. + * - Settles Promise with adapter-normalized `Record`. + * + * 3. `dismiss(questionId)`: + * - Broadcasts `event.question.dismissed`. + * - Settles Promise with `null` (== SCHEMAS §6.3 dismissed result). + * + * **Anti-corruption**: imports `@moonshot-ai/services` (broker interface + + * adapter) and `@moonshot-ai/protocol` (Event type). No direct node-sdk + * references — in-process `QuestionRequest`/`QuestionResult` flow through + * the services re-export. + */ + +import { ulid } from 'ulid'; + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { Event } from '@moonshot-ai/protocol'; +import { + IEventBus, + IQuestionBroker, + questionDismissedResult, + questionToBrokerRequest, + type QuestionRequest, + type QuestionResult, +} from '@moonshot-ai/services'; + +import type { ILogger } from './logger.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _typeAnchor: typeof IQuestionBroker = IQuestionBroker; + +/** Default 60s timeout per SCHEMAS §6.2 / §6.3. Overridable for tests. */ +export const QUESTION_DEFAULT_TIMEOUT_MS = 60_000; + +/** Cap on recently-resolved bookkeeping ring (idempotency window). */ +export const QUESTION_RECENTLY_RESOLVED_CAP = 1024; + +/** + * Thrown when the 60s timer fires before `resolve()` / `dismiss()` is called. + */ +export class QuestionExpiredError extends Error { + constructor(public readonly questionId: string, timeoutMs: number) { + super(`question ${questionId} expired after ${timeoutMs}ms`); + this.name = 'QuestionExpiredError'; + } +} + +interface PendingQuestion { + readonly questionId: string; + readonly sessionId: string; + readonly toolCallId: string | undefined; + readonly createdAt: string; + readonly expiresAt: string; + resolve: (r: QuestionResult) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; +} + +export interface DaemonQuestionBrokerOptions { + timeoutMs?: number; + recentlyResolvedCap?: number; +} + +export class DaemonQuestionBroker extends Disposable implements IQuestionBroker { + /** Indexed by daemon-minted `question_id` (REST path key). */ + private readonly _pending = new Map(); + /** Bounded set of resolved/dismissed ids for idempotency. */ + private readonly _recentlyResolved = new Set(); + private readonly _timeoutMs: number; + private readonly _recentlyResolvedCap: number; + + constructor( + private readonly logger: ILogger, + private readonly eventBus: IEventBus, + options: DaemonQuestionBrokerOptions = {}, + ) { + super(); + this._timeoutMs = options.timeoutMs ?? QUESTION_DEFAULT_TIMEOUT_MS; + this._recentlyResolvedCap = + options.recentlyResolvedCap ?? QUESTION_RECENTLY_RESOLVED_CAP; + } + + async request( + req: QuestionRequest & { sessionId: string; agentId: string }, + ): Promise { + if (this._isDisposed) { + throw new Error('question broker disposed'); + } + + const questionId = ulid(); + const createdAt = new Date().toISOString(); + const expiresAt = new Date(Date.now() + this._timeoutMs).toISOString(); + + const protocolRequest = questionToBrokerRequest(req, { + questionId, + sessionId: req.sessionId, + createdAt, + expiresAt, + }); + + const event: Event = { + type: 'event.question.requested', + sessionId: req.sessionId, + agentId: req.agentId, + ...protocolRequest, + } as unknown as Event; + this.eventBus.publish(event); + + this.logger.info( + { + questionId, + sessionId: req.sessionId, + agentId: req.agentId, + toolCallId: req.toolCallId, + questionCount: req.questions.length, + }, + 'question requested', + ); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => this._expire(questionId), this._timeoutMs); + timer.unref?.(); + this._pending.set(questionId, { + questionId, + sessionId: req.sessionId, + toolCallId: req.toolCallId, + createdAt, + expiresAt, + resolve, + reject, + timer, + }); + }); + } + + /** + * Settle a pending question with answers (normalized to in-process shape + * by the REST handler via `questionToAgentCoreResponse`). Broadcasts + * `event.question.answered` before settling. Silent no-op for unknown ids. + */ + resolve(id: string, response: QuestionResult): void { + const p = this._pending.get(id); + if (!p) return; + clearTimeout(p.timer); + this._pending.delete(id); + this.markResolved(p.questionId); + + const resolvedAt = new Date().toISOString(); + // For broadcast, we forward the in-process answers map directly so all + // subscribers see consistent shape. (REST handler stamps the wire shape + // before this broadcast; for in-process internal callers — none today — + // the broadcast still carries the SDK shape, which is acceptable for + // Stage 1 until WS.md §7.5 wire-renaming lands.) + const answeredEvent: Event = { + type: 'event.question.answered', + sessionId: p.sessionId, + agentId: 'main', + question_id: p.questionId, + answers: response === null ? null : (response as { answers?: unknown }).answers ?? response, + resolved_at: resolvedAt, + } as unknown as Event; + this.eventBus.publish(answeredEvent); + + p.resolve(response); + } + + /** + * SCHEMAS §6.3 dismiss path. Broadcasts `event.question.dismissed` BEFORE + * settling the Promise with `null` (== `dismissedQuestionResult()` in + * agent-core). Silent no-op for unknown ids. + */ + dismiss(id: string): void { + const p = this._pending.get(id); + if (!p) return; + clearTimeout(p.timer); + this._pending.delete(id); + this.markResolved(p.questionId); + + const dismissedAt = new Date().toISOString(); + const dismissedEvent: Event = { + type: 'event.question.dismissed', + sessionId: p.sessionId, + agentId: 'main', + question_id: p.questionId, + dismissed_at: dismissedAt, + } as unknown as Event; + this.eventBus.publish(dismissedEvent); + + p.resolve(questionDismissedResult()); + } + + /** + * Has-pending check used by REST routes to discriminate 40404 vs proceed. + */ + isPending(questionId: string): boolean { + return this._pending.has(questionId); + } + + /** Has-recently-resolved-or-dismissed check for 40902 idempotency. */ + isRecentlyResolved(questionId: string): boolean { + return this._recentlyResolved.has(questionId); + } + + /** Stamp an id as resolved/dismissed for the idempotency window. */ + markResolved(questionId: string): void { + if (this._recentlyResolved.size >= this._recentlyResolvedCap) { + const oldest = this._recentlyResolved.values().next().value; + if (oldest !== undefined) this._recentlyResolved.delete(oldest); + } + this._recentlyResolved.add(questionId); + } + + /** Test helper — number of pending questions. */ + _pendingCountForTest(): number { + return this._pending.size; + } + + /** Test helper — pending entry snapshot. */ + _peekPendingForTest( + questionId: string, + ): { sessionId: string; toolCallId: string | undefined } | undefined { + const p = this._pending.get(questionId); + if (!p) return undefined; + return { sessionId: p.sessionId, toolCallId: p.toolCallId }; + } + + private _expire(questionId: string): void { + const p = this._pending.get(questionId); + if (!p) return; + this._pending.delete(questionId); + this.markResolved(p.questionId); + + const expiredEvent: Event = { + type: 'event.question.expired', + sessionId: p.sessionId, + agentId: 'main', + question_id: p.questionId, + } as unknown as Event; + this.eventBus.publish(expiredEvent); + + p.reject(new QuestionExpiredError(p.questionId, this._timeoutMs)); + } + + override dispose(): void { + if (this._isDisposed) return; + for (const [, p] of this._pending) { + clearTimeout(p.timer); + try { + p.reject(new Error('daemon shutting down')); + } catch { + // ignore + } + } + this._pending.clear(); + this._recentlyResolved.clear(); + super.dispose(); + } +} diff --git a/packages/daemon/src/services/rest-gateway.ts b/packages/daemon/src/services/rest-gateway.ts new file mode 100644 index 000000000..730ec383d --- /dev/null +++ b/packages/daemon/src/services/rest-gateway.ts @@ -0,0 +1,72 @@ +/** + * `IRestGateway` DI surface (W4.4 / P0.14). + * + * Wraps the Fastify instance the daemon constructs at boot so consumer + * services can inject it without taking a direct `fastify` dependency. The + * concrete impl forwards `listen()` to Fastify and disposes by calling + * `app.close()` — Fastify drains in-flight requests then shuts down the HTTP + * server. + * + * Construction-order positioning: registered SECOND (right after ILogger). + * Dispose order then runs gateway BEFORE logger, which is the safe ordering — + * Fastify's drain logs through the still-alive pino instance. + * + * The interface declares `app` with a structural shape rather than the + * concrete `FastifyInstance<…, DaemonLogger>` generic, so consumers don't + * need to know about the daemon's pino-typed instance. Internally + * `FastifyRestGateway` accepts any FastifyInstance variant via the + * `FastifyLike` structural type (sidesteps the strict-generic mismatch + * between Fastify's default `FastifyInstance` and the daemon's pino-typed + * variant). + */ + +import type { Server as HttpServer } from 'node:http'; + +import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; + +/** + * Minimum shape we need from a Fastify instance. Avoids the strict-generic + * mismatch between `FastifyInstance<…, DaemonLogger>` and `FastifyInstance`'s + * default generics that surfaces at the route-options level. + * + * W5.1: `server` (the raw Node `http.Server` Fastify wraps) is required so + * `WSGateway` can attach a typed `'upgrade'` handler for `/v1/ws` without + * pulling in `fastify-websocket`. Fastify exposes `app.server` after + * `await app.ready()` (or after `listen()`); we add the typed property here + * rather than widening to `any` — the anti-corruption discipline matters. + */ +export interface FastifyLike { + listen(opts: { host: string; port: number }): Promise; + close(): Promise; + /** Raw Node HTTP server; populated by Fastify lazily (post-`ready()`). */ + readonly server: HttpServer; +} + +export interface IRestGateway { + readonly app: FastifyLike; + listen(host: string, port: number): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IRestGateway = createDecorator('IRestGateway'); + +export class FastifyRestGateway extends Disposable implements IRestGateway { + constructor(public readonly app: FastifyLike) { + super(); + } + + async listen(host: string, port: number): Promise { + return await this.app.listen({ host, port }); + } + + override dispose(): void { + if (this._isDisposed) return; + // Fire-and-forget — Fastify's close is async but the DI dispose contract is sync. + // The daemon's RunningDaemon.close() awaits `app.close()` explicitly before + // calling ix.dispose(), so by the time we get here the listener is already + // stopped; this is a defensive belt-and-suspenders for non-CLI consumers. + void this.app.close(); + super.dispose(); + } +} + diff --git a/packages/daemon/src/services/session-clients.ts b/packages/daemon/src/services/session-clients.ts new file mode 100644 index 000000000..588b610f1 --- /dev/null +++ b/packages/daemon/src/services/session-clients.ts @@ -0,0 +1,112 @@ +/** + * `ISessionClientsService` (W5.2 / P0.16) — `sessionId → Set` + * reverse index. + * + * `IConnectionRegistry` indexes connections by `connId` (1→1 by socket). + * `ISessionClientsService` indexes them by `sessionId` (1→N by subscription) + * so `DaemonEventBus.publish(event)` can fan out to all live subscribers in + * O(1) lookup + O(k) send (k = subscribers of that session). + * + * Why a separate service (not a method on the registry): the registry + * doesn't know about subscriptions — those are application-level state, not + * connection-level. Keeping them separate also lets WS subscribe/unsubscribe + * mutations skip touching the connection map. + * + * Construction order: registered AFTER `IConnectionRegistry` and BEFORE + * `IEventBus` (the event bus consumes this service). Disposes (in reverse) + * BEFORE the connection registry — no special teardown needed because the + * connection registry has its own `closeAll()` path. + * + * **Idempotency**: `subscribe(conn, sid)` is idempotent — adding the same + * connection twice is a no-op (Set semantics). `unsubscribe` likewise. + * `forgetConnection` drops the connection from EVERY session's set. + */ + +import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; + +import { ILogger } from './logger.js'; +import type { WsConnection } from '../ws/connection.js'; + +export interface ISessionClientsService { + /** Add `connection` as a subscriber to `sessionId`. Idempotent. */ + subscribe(connection: WsConnection, sessionId: string): void; + /** Remove a single (connection, sessionId) subscription. Idempotent. */ + unsubscribe(connection: WsConnection, sessionId: string): void; + /** Iterate all connections subscribed to `sessionId`. */ + getConnections(sessionId: string): Iterable; + /** Remove `connection` from every session it was subscribed to. */ + forgetConnection(connection: WsConnection): void; + /** Test helper / observability: count of subscribers for a session. */ + subscriberCount(sessionId: string): number; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ISessionClientsService = createDecorator( + 'ISessionClientsService', +); + +export class SessionClientsService extends Disposable implements ISessionClientsService { + private readonly _bySession = new Map>(); + + /** + * P2.2: `@ILogger` is auto-injected by the container. The service does + * not currently emit log lines (the subscription model is silent by + * design — broker/event-bus call sites do the logging) but the dep is + * declared so future diagnostic work doesn't need a ctor reshuffle. + */ + constructor(@ILogger private readonly _logger: ILogger) { + super(); + void this._logger; + } + + subscribe(connection: WsConnection, sessionId: string): void { + let set = this._bySession.get(sessionId); + if (!set) { + set = new Set(); + this._bySession.set(sessionId, set); + } + set.add(connection); + } + + unsubscribe(connection: WsConnection, sessionId: string): void { + const set = this._bySession.get(sessionId); + if (!set) return; + set.delete(connection); + // Garbage-collect the bucket when empty so `subscriberCount` stays cheap + // and the map doesn't grow indefinitely with one-off session_ids. + if (set.size === 0) this._bySession.delete(sessionId); + } + + getConnections(sessionId: string): Iterable { + const set = this._bySession.get(sessionId); + if (!set) return EMPTY_ITERABLE; + return set.values(); + } + + forgetConnection(connection: WsConnection): void { + // Walk every session bucket and drop the connection. Cheaper than a + // reverse index (connId → sessionIds) for the connection counts we + // expect (PLAN: O(10) WS clients per daemon). + for (const [sid, set] of this._bySession) { + if (set.delete(connection) && set.size === 0) { + this._bySession.delete(sid); + } + } + } + + subscriberCount(sessionId: string): number { + return this._bySession.get(sessionId)?.size ?? 0; + } + + override dispose(): void { + if (this._isDisposed) return; + this._bySession.clear(); + super.dispose(); + } +} + +const EMPTY_ITERABLE: Iterable = Object.freeze({ + [Symbol.iterator]: function* (): Iterator { + // empty + }, +}); diff --git a/packages/daemon/src/services/ws-gateway.ts b/packages/daemon/src/services/ws-gateway.ts new file mode 100644 index 000000000..f64991d8e --- /dev/null +++ b/packages/daemon/src/services/ws-gateway.ts @@ -0,0 +1,193 @@ +/** + * `IWSGateway` (W5.1 / P0.15) — WebSocket gateway. + * + * Owns a `ws.WebSocketServer` in `noServer` mode and attaches an `'upgrade'` + * handler to the Fastify-exposed raw `http.Server`. WS path is `/v1/ws` + * (WS.md §1.1). On upgrade we instantiate a `WsConnection`, register it in + * `IConnectionRegistry`, and let the connection drive its own handshake + + * heartbeat. + * + * **Construction order** (relative to W4 services): + * ILogger → IRestGateway → IConnectionRegistry → ISessionClientsService + * → IEventBus → IApprovalBroker → IQuestionBroker + * → IWSGateway ← here, constructed LATE + * → IHarnessBridge + * + * Why late: dispose runs in REVERSE construction order. So `WSGateway.dispose()` + * runs EARLY at shutdown, closing all WS connections via the registry BEFORE + * EventBus / brokers tear down. If we constructed WSGateway earlier, broker + * `.dispose()` could try to emit on a still-attached socket whose owner is + * already gone. + * + * Why `noServer` mode (not `port:`): Fastify already owns the HTTP server. + * We share it — every WS handshake passes through Fastify's listener, gets + * intercepted by our `'upgrade'` handler, and only `/v1/ws` paths are + * upgraded; other paths get an immediate `socket.destroy()` (defensive). + * + * `dispose()` is reverse-order safe: + * 1. `registry.closeAll()` — sends WS code 1001 (going away) to each socket. + * 2. `wss.close()` — stops accepting new upgrades. + * 3. Detaches the `'upgrade'` listener from `app.server`. + * + * Anti-corruption: no SDK imports. WS schemas come from + * `@moonshot-ai/protocol`. + */ + +import type { IncomingMessage, Server as HttpServer } from 'node:http'; +import type { Socket } from 'node:net'; + +import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; +import { WebSocketServer, type WebSocket } from 'ws'; + +import { IConnectionRegistry } from './connection-registry.js'; +import type { DaemonEventBus } from './event-bus.js'; +import { ILogger } from './logger.js'; +import { IRestGateway } from './rest-gateway.js'; +import { ISessionClientsService } from './session-clients.js'; +import { WsConnection, type AbortHandler, type FsWatchHandler } from '../ws/connection.js'; + +/** WS endpoint path. WS.md §1.1. */ +export const WS_PATH = '/v1/ws'; + +export interface IWSGateway { + /** Number of currently-attached WS connections. */ + readonly size: number; + /** + * W7.3: attach an abort handler so future WS connections can dispatch + * `abort` control messages through it. Has no effect on already-attached + * connections (they captured their handler at construction). + */ + setAbortHandler(handler: AbortHandler): void; + /** + * W12 / Chain 14: attach an fs-watch handler so future WS connections + * can dispatch `subscribe.watch_fs` / `watch_fs_add` / `watch_fs_remove` + * through it. Like `setAbortHandler`, only affects connections opened + * AFTER the call; in production we wire it once at startup before the + * REST listener accepts traffic, so this is a non-issue. + */ + setFsWatchHandler(handler: FsWatchHandler): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IWSGateway = createDecorator('IWSGateway'); + +export interface WSGatewayOptions { + /** + * Override the default ping interval (30_000ms) for tests so the test can + * observe a `ping` without sleeping 30s. + */ + pingIntervalMs?: number; + /** Override the default pong deadline (10_000ms). */ + pongTimeoutMs?: number; + /** Override server_hello server_id (defaults to a fresh ULID per connection). */ + serverId?: string; +} + +export class WSGateway extends Disposable implements IWSGateway { + private readonly wss: WebSocketServer; + private readonly upgradeListener: (req: IncomingMessage, sock: Socket, head: Buffer) => void; + private readonly server: HttpServer; + private abortHandler: AbortHandler | undefined; + private fsWatchHandler: FsWatchHandler | undefined; + private detached = false; + + constructor( + // P2.3: VSCode-style ctor ordering — static-first, services-last with + // `@I*` decorators. `eventBus` is kept as a non-decorated concrete + // `DaemonEventBus` static dep because the consumer (`WsConnection`) needs + // the daemon-specific `BufferReplaySource` shape (`getBufferedSince`, + // `currentSeq`, `addObserver`) which the `IEventBus` interface from + // `@moonshot-ai/services` does NOT expose. Promoting `DaemonEventBus` + // to its own identifier is a deliberate followup (ROADMAP P2.3 note). + // `options` must follow `eventBus` (TS forbids a required param after an + // optional one); start.ts passes `opts.wsGatewayOptions ?? {}` explicitly, + // so we drop the inline default — the caller always supplies a concrete + // object. + private readonly eventBus: DaemonEventBus, + private readonly options: WSGatewayOptions, + @IRestGateway private readonly restGateway: IRestGateway, + @IConnectionRegistry private readonly registry: IConnectionRegistry, + @ISessionClientsService private readonly sessionClients: ISessionClientsService, + @ILogger private readonly logger: ILogger, + ) { + super(); + this.wss = new WebSocketServer({ noServer: true }); + this.server = this.restGateway.app.server; + this.upgradeListener = (req, sock, head) => this.onUpgrade(req, sock, head); + this.server.on('upgrade', this.upgradeListener); + this.logger.debug({ path: WS_PATH }, 'ws gateway attached upgrade listener'); + } + + setAbortHandler(handler: AbortHandler): void { + this.abortHandler = handler; + } + + setFsWatchHandler(handler: FsWatchHandler): void { + this.fsWatchHandler = handler; + } + + private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { + // Restrict to `/v1/ws` (with optional query string per WS.md §1.1). + const url = req.url ?? ''; + const path = url.split('?', 1)[0]; + if (path !== WS_PATH) { + // Other Fastify routes don't use WS; politely drop the handshake. + socket.destroy(); + return; + } + this.wss.handleUpgrade(req, socket, head, (ws) => this.onConnect(ws)); + } + + private onConnect(socket: WebSocket): void { + const conn = new WsConnection({ + socket, + logger: this.logger, + sessionClients: this.sessionClients, + eventBus: this.eventBus, + ...(this.abortHandler !== undefined ? { abortHandler: this.abortHandler } : {}), + ...(this.fsWatchHandler !== undefined ? { fsWatchHandler: this.fsWatchHandler } : {}), + ...(this.options.pingIntervalMs !== undefined + ? { pingIntervalMs: this.options.pingIntervalMs } + : {}), + ...(this.options.pongTimeoutMs !== undefined + ? { pongTimeoutMs: this.options.pongTimeoutMs } + : {}), + ...(this.options.serverId !== undefined ? { serverId: this.options.serverId } : {}), + }); + this.registry.add(conn); + socket.on('close', () => this.registry.remove(conn.id)); + } + + get size(): number { + return this.registry.size(); + } + + override dispose(): void { + if (this._isDisposed) return; + // 1. Close every attached connection (WS code 1001 = going away). + try { + this.registry.closeAll('daemon shutting down'); + } catch { + // continue teardown + } + // 2. Stop accepting new handshakes. + try { + this.wss.close(); + } catch { + // continue + } + // 3. Detach upgrade listener so the raw http.Server's `close()` (run + // earlier by RunningDaemon.close → app.close → server.close) doesn't + // still funnel into us. Defensive — if the server is already shut down + // `off` is a no-op. + if (!this.detached) { + try { + this.server.off('upgrade', this.upgradeListener); + } catch { + // ignore + } + this.detached = true; + } + super.dispose(); + } +} diff --git a/packages/daemon/src/start.ts b/packages/daemon/src/start.ts new file mode 100644 index 000000000..a073f7c67 --- /dev/null +++ b/packages/daemon/src/start.ts @@ -0,0 +1,711 @@ +import { + InstantiationService, + ServiceCollection, + SyncDescriptor, +} from '@moonshot-ai/agent-core'; +import { + HarnessBridge, + IApprovalBroker, + IEventBus, + IHarnessBridge, + IMcpService, + IMessageService, + IPromptService, + IQuestionBroker, + ISessionService, + ITaskService, + IToolService, + McpServiceImpl, + MessageServiceImpl, + PromptServiceImpl, + SessionNotFoundError, + SessionServiceImpl, + TaskServiceImpl, + ToolServiceImpl, + type HarnessBridgeOptions, +} from '@moonshot-ai/services'; +import { ErrorCode } from '@moonshot-ai/protocol'; +import Fastify from 'fastify'; +import { ulid } from 'ulid'; +import { promises as fspPromises } from 'node:fs'; +import { sep as nodePathSep, relative as nodePathRelativeNative } from 'node:path'; + +import { okEnvelope } from './envelope.js'; +import { installErrorHandler } from './error-handler.js'; +import { acquireLock, DaemonLockedError } from './lock.js'; +import { createDaemonLogger, type DaemonLogLevel, type DaemonLogger } from './logger.js'; +import { resolveRequestId } from './request-id.js'; +import { registerFsRoutes } from './routes/fs.js'; +import { registerFilesRoutes } from './routes/files.js'; +import { registerMessagesRoutes } from './routes/messages.js'; +import { registerMetaRoute } from './routes/meta.js'; +import { registerPromptsRoutes } from './routes/prompts.js'; +import { registerApprovalsRoutes } from './routes/approvals.js'; +import { registerQuestionsRoutes } from './routes/questions.js'; +import { registerSessionsRoutes } from './routes/sessions.js'; +import { registerTasksRoutes } from './routes/tasks.js'; +import { registerToolsRoutes } from './routes/tools.js'; +import { DaemonApprovalBroker } from './services/approval-broker.js'; +import { ConnectionRegistry, IConnectionRegistry } from './services/connection-registry.js'; +import { DaemonEventBus } from './services/event-bus.js'; +import { FsServiceImpl, IFsService } from './services/fs-service.js'; +import { + FsGitServiceImpl, + IFsGitService, +} from './services/fs-git.js'; +import { + FsSearchServiceImpl, + IFsSearchService, +} from './services/fs-search.js'; +import { + FsWatcherService, + IFsWatcher, + FsWatchLimitError, + createConnectionLookup, +} from './services/fs-watcher.js'; +import { FsPathEscapesError, resolveSafePath } from './services/fs-path-safety.js'; +import { FileStoreImpl, IFileStore } from './services/file-store.js'; +import { ILogger, PinoLogger } from './services/logger.js'; +import { DaemonQuestionBroker } from './services/question-broker.js'; +import { FastifyRestGateway, IRestGateway } from './services/rest-gateway.js'; +import { ISessionClientsService, SessionClientsService } from './services/session-clients.js'; +import { IWSGateway, WSGateway, type WSGatewayOptions } from './services/ws-gateway.js'; +import { getDaemonVersion } from './version.js'; + +export interface DaemonStartOptions { + host: string; + port: number; + logLevel?: DaemonLogLevel; + /** Provide an external logger instead of constructing one. */ + logger?: DaemonLogger; + /** + * Override the default lock file path (`~/.kimi/daemon/lock`). Tests use + * this to point at a tmpdir; production callers leave it undefined. + */ + lockPath?: string; + /** + * Optional `HarnessBridgeOptions` passthrough — extends `KimiCoreOptions` + * (homeDir, etc.). Tests use this to isolate KimiCore's `~/.kimi` lookup. + */ + bridgeOptions?: HarnessBridgeOptions; + /** + * W5.1: optional WS gateway tunables for tests (`pingIntervalMs`, etc.). + * Production callers leave this undefined and pick up the WS.md §1.3 / §3.1 + * defaults (30s ping, 10s pong deadline, 1000-event ring buffer). + */ + wsGatewayOptions?: WSGatewayOptions; +} + +export interface RunningDaemon { + /** Resolved listening address, useful when port=0. */ + readonly address: string; + /** Logger shared with Fastify; use this for daemon-level events. */ + readonly logger: DaemonLogger; + /** + * The DI container — exposed for tests and W5+ external consumers. The + * container holds the bridge, brokers, and gateway. `close()` disposes it. + */ + readonly services: InstantiationService; + /** Stop the listener, dispose the container, release the lock; idempotent. */ + close(): Promise; +} + +/** Re-export so CLI / tests can `catch` against the specific lock-conflict type. */ +export { DaemonLockedError }; + +/** + * Boot the daemon (W4.4 / P0.14, extended in W5.1+W5.2 / P0.15+P0.16): lock + * → Fastify → `app.ready()` → DI container → services → bridge.ready → listen. + * + * **Wiring order matters for teardown** (W3 handoff §Gotchas): + * construction order = [ILogger, IRestGateway, IConnectionRegistry, + * ISessionClientsService, IEventBus, IApprovalBroker, + * IQuestionBroker, IWSGateway, IHarnessBridge] + * dispose order = REVERSE of the above (per InstantiationService + * `_constructionOrder` semantics). + * + * So at shutdown: HarnessBridge → WSGateway (closes WS conns via the + * registry) → brokers (Question, Approval, EventBus) → SessionClients → + * ConnectionRegistry (no-op — gateway already drained it) → RestGateway → + * Logger. The logger disposing last is critical — every other service's + * `dispose()` may emit a log line. WSGateway disposing EARLY means brokers + * never emit into closed sockets; SessionClients dropping AFTER EventBus + * means the bus has stopped publishing before its subscriber index goes + * away. + * + * **HarnessBridge construction** (post-P2.5 migration): `HarnessBridge` ctor is + * now decorated `(options, @IEventBus, @IApprovalBroker, @IQuestionBroker)` + * — services auto-inject. `defaultServicesModule()` still has no + * `staticArguments` for the `options` slot, so direct + * `accessor.get(IHarnessBridge)` against the module descriptor would + * still construct with `undefined` options. We therefore + * `ix.createInstance(HarnessBridge, opts.bridgeOptions ?? {})` inside an + * `invokeFunction`, then `services.set(IHarnessBridge, bridge)` so + * subsequent `a.get(IHarnessBridge)` returns the same singleton and the + * container records it in its construction-order list. + * + * **Post-P2 wire-up shape**: every `ix.createInstance(...)` rest-arg list + * in this function carries ONLY non-service static args (options bags, + * closures, external instances). The `a.get(IFoo)` calls that remain + * are either (a) construction-order "touch" pins or (b) actual consumer + * dispatch (e.g. `a.get(IRestGateway).listen(...)`). + * + * **Anti-corruption invariant**: daemon source has zero direct SDK + * (`packages/node-sdk`) imports — the bridge is the only path to + * KimiCore, and we get it via `@moonshot-ai/services` re-exports. + */ +export async function startDaemon(opts: DaemonStartOptions): Promise { + const pinoLogger: DaemonLogger = + opts.logger ?? createDaemonLogger({ level: opts.logLevel ?? 'info' }); + + // Lock FIRST — if another daemon is alive we fail before reserving the port. + const lockHandle = acquireLock({ port: opts.port, lockPath: opts.lockPath }); + + const app = Fastify({ + loggerInstance: pinoLogger, + disableRequestLogging: false, + genReqId: (req) => resolveRequestId(req.headers), + }); + installErrorHandler(app); + + app.get('/v1/healthz', async (req, reply) => { + return reply.send(okEnvelope({ ok: true }, req.id)); + }); + + // W6.1 / Chain 1 — `/v1/meta`. Pure daemon-self info, no DI needed. Mint + // the per-process server_id + boot timestamp once at registration time + // (ROADMAP P1.1; REST.md §3.1). + const serverId = ulid(); + const startedAt = new Date().toISOString(); + registerMetaRoute(app, { + daemonVersion: getDaemonVersion(), + serverId, + startedAt, + }); + + // Seed the container with the two pre-built instances. They become "live" + // (= recorded in _constructionOrder) only when first accessed via the + // accessor below — so the order in the later `invokeFunction` block is + // what actually determines disposal order. + // + // We construct the container BEFORE `app.ready()` so route modules can + // capture `ix` by reference and resolve services at REQUEST time. Fastify + // locks new route registration after `app.ready()`, so any module that + // needs to `app.post(...)` etc. must register before the ready gate. The + // service graph is filled in immediately below — completed BEFORE the + // first request can land (we still `await app.ready()`, then `bridge.ready()`, + // then `IRestGateway.listen()`). + const services = new ServiceCollection( + [ILogger, new PinoLogger(pinoLogger)], + // P2.2: RestGateway carries `app: FastifyLike` as the only ctor arg — a + // pure static dep. Switch from a pre-built instance to a descriptor with + // `app` as a static argument so the container drives construction. The + // FastifyLike instance is created above (Fastify needs the pino logger + // so it can't itself be DI-constructed); we hand it in as a static. + [IRestGateway, new SyncDescriptor(FastifyRestGateway, [app])], + ); + const ix = new InstantiationService(services); + + // W6.2 / Chain 2 — register `/v1/sessions/*` routes. The route module + // captures `ix` by reference; per-request `accessor.get(ISessionService)` + // dispatches against whatever's in the container at that moment. We + // populate ISessionService below; by the time the first request lands the + // container is fully wired (we await app.ready() + bridge.ready() before + // listen() opens the socket). + registerSessionsRoutes(app as unknown as Parameters[0], ix); + // W7.1 / Chain 3 — register `/v1/sessions/{sid}/messages*` routes. Same + // wiring story: handlers resolve `IMessageService` per-request through ix. + registerMessagesRoutes(app as unknown as Parameters[0], ix); + // W7.2 / Chain 4 — register `/v1/sessions/{sid}/prompts*` routes (submit + + // abort). Submit triggers `bridge.rpc.prompt(...)` whose synchronous event + // stream lands on `IEventBus → WS broadcast`. Abort is the REST fallback + // for the WS abort message handled at `ws/connection.ts` (Chain 4b / W7.3). + registerPromptsRoutes(app as unknown as Parameters[0], ix); + // W8.1 / Chain 5 — register `/v1/sessions/{sid}/approvals/{aid}` route. + // The reverse-RPC path: agent-core → bridge → DaemonApprovalBroker → WS + // `event.approval.requested`. The REST handler completes the round-trip + // by calling `IApprovalBroker.resolve(aid, body)`. + registerApprovalsRoutes( + app as unknown as Parameters[0], + ix, + ); + // W8.2 / Chain 6 — register `/v1/sessions/{sid}/questions/{qid}*` routes. + // Same reverse-RPC pattern as approval, with first-class `:dismiss` + // (SCHEMAS §6.3) and 5-kind discriminated-union answer normalization + // (SCHEMAS §6.4) done by the services adapter at REST-boundary time. + registerQuestionsRoutes( + app as unknown as Parameters[0], + ix, + ); + // W9.1 / Chain 7 — register `/v1/tools` + `/v1/mcp/servers*` routes. + // Read-only `getTools` + `listMcpServers` plus `:restart` action — the 4th + // call site of the `:tail` action-suffix pattern, now extracted into + // `routes/action-suffix.ts`. + registerToolsRoutes( + app as unknown as Parameters[0], + ix, + ); + // W9.2 / Chain 8 — register `/v1/sessions/{sid}/tasks*` routes. + // list/get/cancel with 40406 + 40904 + the 5th `:tail` (action :cancel). + registerTasksRoutes( + app as unknown as Parameters[0], + ix, + ); + // W10 / Chains 9 + 10 — register `/v1/sessions/{sid}/fs:*` routes. + // POST :list / :read / :list_many / :stat / :stat_many — daemon-OWN + // service, no agent-core bridge involved. Path safety is the central + // correctness concern; every input path flows through + // `resolveSafePath(cwd, input)` before any Node fs syscall. + registerFsRoutes( + app as unknown as Parameters[0], + ix, + ); + + // W12.2 / Chain 15 — register `/v1/files*` routes (upload / download / + // delete). Registers `@fastify/multipart` lazily on the captured + // Fastify instance. Anti-corruption invariant: handlers resolve + // `IFileStore` via the DI accessor; no SDK imports. + registerFilesRoutes( + app as unknown as Parameters[0], + ix, + ); + + // Fastify lazily creates the raw `http.Server`. `WSGateway` (W5.1) needs + // `app.server` to attach an `'upgrade'` listener — `app.ready()` populates + // it without binding to a port (that happens later in `IRestGateway.listen`). + try { + await app.ready(); + } catch (err) { + lockHandle.release(); + throw err; + } + + // Touch logger + gateway in the intended construction order so they're + // recorded for reverse-teardown. Then build the broker stubs (each takes + // a positional `ILogger` static arg — brokers aren't @IFoo-decorated yet; + // their direct-instance ctor is still the only construction path). + let bridge: HarnessBridge; + try { + bridge = ix.invokeFunction((a) => { + // Force construction-order recording for the two seeded instances — + // ILogger first so it disposes LAST. + a.get(ILogger); + a.get(IRestGateway); + + // Build broker stubs against the resolved ILogger and register them. + const log = a.get(ILogger); + + // W5.1 / P2.1: register IConnectionRegistry BEFORE event bus / brokers so the + // reverse-dispose chain tears down WS connections (via IWSGateway, which + // is constructed LATE → disposes EARLY) before brokers can emit on them. + // + // P2.1 migration: switch from `services.set(I, new C())` to a descriptor + // so the container drives construction through `_createAndCacheServiceInstance` + // and the @IFoo auto-injection path (ConnectionRegistry has 0 service deps, + // so the auto-inject step is a no-op — this is the smoke-test commit per + // Phase 1 handoff #1). + services.set(IConnectionRegistry, new SyncDescriptor(ConnectionRegistry)); + // Touch BEFORE SessionClients to lock construction order: + // [..., IConnectionRegistry, ISessionClientsService, IEventBus, ...] + a.get(IConnectionRegistry); + + // W5.2 / P2.2: register ISessionClientsService BEFORE IEventBus so the bus + // can hold a reference to it for broadcast fan-out. SessionClients + // disposes AFTER IEventBus (reverse-order) — by then the bus has + // already stopped publishing, so dropping the subscriber index is safe. + // + // P2.2 migration: descriptor-based registration; @ILogger gets + // auto-injected. + services.set(ISessionClientsService, new SyncDescriptor(SessionClientsService)); + a.get(ISessionClientsService); + + services.set(IEventBus, new DaemonEventBus(log, a.get(ISessionClientsService))); + // Touch the event bus BEFORE constructing brokers so brokers can hold a + // reference for broadcast (W8.1 / Chain 5). + const eventBus = a.get(IEventBus) as DaemonEventBus; + services.set(IApprovalBroker, new DaemonApprovalBroker(log, eventBus)); + services.set(IQuestionBroker, new DaemonQuestionBroker(log, eventBus)); + + // Touch the brokers in order so they're recorded for reverse teardown + // (Question → Approval → EventBus dispose direction). + a.get(IApprovalBroker); + a.get(IQuestionBroker); + + // W5.1 / P2.3: WSGateway constructed AFTER brokers but BEFORE HarnessBridge. + // Reverse-dispose order then runs: Bridge → WSGateway (closes WS conns + // via registry) → brokers → SessionClients → registry → RestGateway → + // Logger. That's safe because brokers no longer have active sockets + // to emit to. + // + // P2.3 migration: WSGateway ctor reordered to VSCode-style + // (eventBus, options, @IRestGateway, @IConnectionRegistry, + // @ISessionClientsService, @ILogger). createInstance now only + // supplies the two static prefix args; the 4 @I services auto-inject. + const wsGateway = ix.createInstance( + WSGateway, + eventBus, + opts.wsGatewayOptions ?? {}, + ); + services.set(IWSGateway, wsGateway); + a.get(IWSGateway); + + // P2.5: HarnessBridge ctor migrated to VSCode-style + // (options, @IEventBus, @IApprovalBroker, @IQuestionBroker). + // createInstance now only supplies the static options prefix; the + // 3 service deps auto-inject. The descriptor in + // `defaultServicesModule()` has no staticArguments so direct + // `a.get(IHarnessBridge)` against it would still fail — we keep + // `services.set(IHarnessBridge, built)` so consumer call sites + // resolve through the same singleton. + const built = ix.createInstance(HarnessBridge, opts.bridgeOptions ?? {}); + services.set(IHarnessBridge, built); + // Touch IHarnessBridge so it's recorded for reverse-teardown. + a.get(IHarnessBridge); + + // W6.2 / Chain 2 — ISessionService. Same wiring pattern as HarnessBridge: + // W6.2 / Chain 2 / P2.5 — ISessionService. @IHarnessBridge is now + // auto-injected; createInstance call shrinks to a single arg. + // construction-order trick: [..., IHarnessBridge, ISessionService]. + // Reverse-dispose then runs ISessionService BEFORE IHarnessBridge — + // the service's dispose can't accidentally call back into a + // torn-down bridge. + const sessionService = ix.createInstance(SessionServiceImpl); + services.set(ISessionService, sessionService); + a.get(ISessionService); + + // W7.1 / Chain 3 / P2.5 — IMessageService. Same wiring pattern; insert AFTER + // ISessionService so reverse-dispose order is + // [..., IMessageService, ISessionService, IHarnessBridge, ...]. + // Both services depend on a live bridge during their dispose; bridge + // disposes LAST among them. + const messageService = ix.createInstance(MessageServiceImpl); + services.set(IMessageService, messageService); + a.get(IMessageService); + + // W7.2 / Chain 4 / P2.5 — IPromptService. Ctor takes IHarnessBridge + IEventBus + // (the impl uses the bus both to publish synthetic prompt.completed / + // prompt.aborted events AND to register itself as a lifecycle observer + // so it sees turn.started/turn.ended). Construction order: + // [..., IMessageService, IPromptService] — reverse dispose runs + // IPromptService FIRST among the daemon-services, then IMessageService, + // then ISessionService, then IHarnessBridge. + const promptService = ix.createInstance(PromptServiceImpl); + services.set(IPromptService, promptService); + a.get(IPromptService); + // Register the service as a lifecycle observer on the bus. The detach + // function is intentionally not stored — the observer is unregistered + // when the bus itself disposes (which happens LATER in the dispose + // chain than IPromptService, so observers automatically stop being + // invoked once the bus tears down). + (eventBus as DaemonEventBus).addObserver(promptService); + + // W7.3 — wire the WS abort handler. Both REST and WS abort go through + // `IPromptService.abort`; the WS connection needs an `AbortHandler` + // adapter exposing `abort()` + `currentSeq()` so it can populate the + // ack `at_seq` on idempotent calls. We compose one in-place. + const wsGw = a.get(IWSGateway); + wsGw.setAbortHandler({ + abort: (sid, pid) => promptService.abort(sid, pid), + currentSeq: (sid) => (eventBus as DaemonEventBus).currentSeq(sid), + }); + + // W9.1 / Chain 7 / P2.5 — IToolService + IMcpService. Both depend only on the + // bridge. Construction order: [..., IPromptService, IToolService, + // IMcpService]. Reverse dispose runs IMcpService FIRST among the new + // services, then IToolService, then IPromptService — all BEFORE the + // bridge. + const toolService = ix.createInstance(ToolServiceImpl); + services.set(IToolService, toolService); + a.get(IToolService); + const mcpService = ix.createInstance(McpServiceImpl); + services.set(IMcpService, mcpService); + a.get(IMcpService); + + // W9.2 / Chain 8 / P2.5 — ITaskService. Same wiring pattern; appended LAST + // so reverse-dispose closes it first among the W9 additions. + const taskService = ix.createInstance(TaskServiceImpl); + services.set(ITaskService, taskService); + a.get(ITaskService); + + // W10 / Chains 9 + 10 / P2.4 — IFsService. DAEMON-OWN service (not bridged + // via IHarnessBridge — fs operates on `session.metadata.cwd` + // directly). Depends only on ISessionService for the cwd lookup. + // Construction order: [..., ITaskService, IFsService]. Reverse + // dispose runs IFsService FIRST (clears its .gitignore matcher + // cache) so the session service is still live during its dispose. + const fsService = ix.createInstance(FsServiceImpl); + services.set(IFsService, fsService); + a.get(IFsService); + + // W11 / Chain 11 / P2.4 — IFsSearchService. DAEMON-OWN like IFsService. + // Depends on ISessionService (for cwd) + ILogger (for the + // one-shot "rg missing" warning). Inserted AFTER IFsService so + // reverse-dispose runs IFsSearchService FIRST among the W11 + // additions, then IFsService, then ISessionService. + const fsSearchService = ix.createInstance(FsSearchServiceImpl); + services.set(IFsSearchService, fsSearchService); + a.get(IFsSearchService); + + // W11 / Chain 12 / P2.4 — IFsGitService. DAEMON-OWN like IFsService. + // Depends only on ISessionService (for cwd). Inserted AFTER + // IFsSearchService so reverse-dispose runs IFsGitService FIRST + // among the W11 additions. + const fsGitService = ix.createInstance(FsGitServiceImpl); + services.set(IFsGitService, fsGitService); + a.get(IFsGitService); + + // W12 / Chain 14 — IFsWatcher. DAEMON-OWN. Wraps a per-session + // chokidar `FSWatcher`, coalesces events over 200ms windows, + // truncates at 500 raw events/window, and pushes targeted (NOT + // broadcast) `event.fs.changed` frames to the connections whose + // subscribed paths overlap the change. + // + // The watcher needs a `connection-lookup` for the targeted push; + // we use `IConnectionRegistry.get` bound. We also wire the + // `FsWatchHandler` adapter onto `IWSGateway` so any NEW WS + // connection captures it at construction — same pattern as W7.3's + // abort handler. EXISTING connections (during graceful + // restart-in-place) won't have an fs handler; production wires + // this before any client can connect. + // + // Construction order: AFTER IFsGitService. Reverse-dispose runs + // IFsWatcher FIRST (closes every chokidar instance), then + // IFsGitService, etc. + // + // P2.6: @ILogger + @ISessionService auto-injected; only `lookup` + // (closure over the live registry) and `{}` options remain as + // positional static args. + const registry = a.get(IConnectionRegistry); + const fsWatcher = ix.createInstance( + FsWatcherService, + createConnectionLookup((id) => registry.get(id)), + {}, + ); + services.set(IFsWatcher, fsWatcher); + a.get(IFsWatcher); + + // Build the WS adapter mapping `(sessionId, connId, wirePaths) → + // resolve cwd → resolveSafePath → IFsWatcher.addPaths/removePaths`. + // Errors map to wire ack codes: + // - FsWatchLimitError → 42902 fs.watch_limit_exceeded + // - FsPathEscapesError → 41304 fs.path_escapes_session + // - SessionNotFoundError → 40401 session.not_found + // - other → 50001 internal + const fsWatchHandler = { + async add(sessionId: string, connectionId: string, wirePaths: readonly string[]) { + try { + const session = await a.get(ISessionService).get(sessionId); + // `resolveSafePath` realpath's the cwd internally; we must use + // the SAME realpath here for the absolute→POSIX-relative + // conversion (macOS routes `/tmp` to `/private/tmp`, etc). + const realCwd = await fspPromises.realpath(session.metadata.cwd); + // Bind cwd so the watcher can map absolute → POSIX-relative on emit. + fsWatcher.bindSessionCwd(sessionId, realCwd); + const absPaths: string[] = []; + for (const p of wirePaths) { + const safe = await resolveSafePath(session.metadata.cwd, p); + absPaths.push(safe.absolute); + } + fsWatcher.addPaths(sessionId, connectionId, absPaths); + const watched = fsWatcher.watchedPaths(connectionId, sessionId); + // Convert absolute paths back to POSIX-relative for the wire. + const wire = watched.map((abs) => toPosixRelativeForCwd(realCwd, abs)); + return { + ok: true as const, + watched_paths: wire, + current_count: fsWatcher.countForConnection(connectionId), + }; + } catch (err) { + return mapFsWatchError(err); + } + }, + async remove(sessionId: string, connectionId: string, wirePaths: readonly string[]) { + try { + const session = await a.get(ISessionService).get(sessionId); + const realCwd = await fspPromises.realpath(session.metadata.cwd); + const absPaths: string[] = []; + for (const p of wirePaths) { + // Path safety still applies — clients can't unwatch a path that + // escapes cwd (defensive; we'd reject the corresponding add too). + const safe = await resolveSafePath(session.metadata.cwd, p); + absPaths.push(safe.absolute); + } + fsWatcher.removePaths(sessionId, connectionId, absPaths); + const watched = fsWatcher.watchedPaths(connectionId, sessionId); + const wire = watched.map((abs) => toPosixRelativeForCwd(realCwd, abs)); + return { + ok: true as const, + watched_paths: wire, + current_count: fsWatcher.countForConnection(connectionId), + }; + } catch (err) { + return mapFsWatchError(err); + } + }, + cleanupConnection(connectionId: string) { + fsWatcher.forgetConnection(connectionId); + }, + }; + wsGw.setFsWatchHandler(fsWatchHandler); + + // W12.2 / Chain 15 — IFileStore. DAEMON-OWN like IFsWatcher. + // Persists uploads under `/.kimi/files/` with a JSON + // index. Depends only on ILogger. Inserted AFTER IFsWatcher so + // reverse-dispose runs IFileStore FIRST among the W12 additions + // (drops the index cache + idle file handles). + // + // `homeDir` resolution: prefer `bridgeOptions.homeDir` if the + // caller set one (tests do this to isolate the store under a + // tmpdir); fall back to `~/.kimi`. The bridge also lives under + // the same root so they co-exist (`/files/` vs. + // `/`). + // + // P2.6: @ILogger auto-injects; only the options bag remains as + // a positional static arg. + const fileStoreHomeDir = opts.bridgeOptions?.homeDir; + const fileStore = ix.createInstance( + FileStoreImpl, + fileStoreHomeDir !== undefined ? { homeDir: fileStoreHomeDir } : {}, + ); + services.set(IFileStore, fileStore); + a.get(IFileStore); + + return built; + }); + } catch (err) { + // Container half-built — dispose what we have, drop the lock, rethrow. + try { + ix.dispose(); + } catch { + /* ignore */ + } + lockHandle.release(); + throw err; + } + + // Bridge readiness gate — KimiCore plugin init + RPC binding completion. + // Awaiting before listen() means /v1/healthz only goes live once the + // services graph is fully usable. + try { + await bridge.ready(); + } catch (err) { + try { + ix.dispose(); + } catch { + /* ignore */ + } + lockHandle.release(); + throw err; + } + pinoLogger.info('services bridge ready'); + + let address: string; + try { + address = await ix.invokeFunction((a) => a.get(IRestGateway).listen(opts.host, opts.port)); + } catch (err) { + try { + ix.dispose(); + } catch { + /* ignore */ + } + lockHandle.release(); + throw err; + } + pinoLogger.info({ address, lockPath: lockHandle.lockPath }, 'daemon listening'); + + let closed = false; + return { + address, + logger: pinoLogger, + services: ix, + close: async () => { + if (closed) return; + closed = true; + // 1. Close attached WS connections FIRST (with WS code 1001 = going + // away). If we let `app.close()` run first it would tear down the + // underlying TCP sockets, denying us a clean WS close frame. + // The container's reverse-dispose chain runs the same logic via + // `WSGateway.dispose()`, but Fastify's `close()` is async and races + // its socket-killer against our timing — so we explicitly drain the + // WS gateway here first. + try { + ix.invokeFunction((a) => a.get(IWSGateway)); + // WSGateway has no public drain method (closes happen on dispose); + // we trigger it via the registry directly, which is idempotent. + ix.invokeFunction((a) => a.get(IConnectionRegistry).closeAll('daemon shutting down')); + } catch { + // container may be partially disposed — fall through to app.close() + } + // 2. Stop accepting new requests + drain in-flight ones. Done + // explicitly here (instead of relying on FastifyRestGateway.dispose's + // fire-and-forget) so callers see a real `await` boundary. + try { + await app.close(); + } catch { + // continue teardown even if drain throws + } + // 3. Dispose container: HarnessBridge → WSGateway → brokers → registry + // → gateway → logger (reverse construction order). WSGateway.dispose() + // now finds an empty registry; harmless idempotent path. + try { + ix.dispose(); + } catch { + // continue + } + // 3. Release the lock LAST so other tooling can rely on lock-absence == + // daemon-fully-shut-down. + lockHandle.release(); + }, + }; +} + +/* ------------------------------------------------------------------------- + * Helpers for the FsWatchHandler adapter (W12 / Chain 14) + * ----------------------------------------------------------------------- */ + +/** + * Wire-path conversion for the `watched_paths` ack field. Same algorithm + * as `fs-path-safety.ts:toPosixRelative` but inlined here so the start.ts + * adapter doesn't import path-safety internals (the safety module's + * `toPosixRelative` is private). If a future iteration needs the helper + * in more places we can hoist it. + */ +function toPosixRelativeForCwd(cwd: string, abs: string): string { + if (abs === cwd) return '.'; + const rel = nodePathRelativeNative(cwd, abs); + if (rel === '') return '.'; + return rel.split(nodePathSep).join('/'); +} + +/** + * Translate watcher-layer errors into the wire `code` the WS ack carries. + */ +function mapFsWatchError(err: unknown): + | { ok: false; code: number; msg: string } { + if (err instanceof FsWatchLimitError) { + return { + ok: false, + code: ErrorCode.FS_WATCH_LIMIT_EXCEEDED, + msg: err.message, + }; + } + if (err instanceof FsPathEscapesError) { + return { + ok: false, + code: ErrorCode.FS_PATH_ESCAPES_SESSION, + msg: err.message, + }; + } + if (err instanceof SessionNotFoundError) { + return { + ok: false, + code: ErrorCode.SESSION_NOT_FOUND, + msg: 'session not found', + }; + } + return { + ok: false, + code: ErrorCode.INTERNAL_ERROR, + msg: err instanceof Error ? err.message : 'fs watch error', + }; +} diff --git a/packages/daemon/src/version.ts b/packages/daemon/src/version.ts new file mode 100644 index 000000000..d38c66ded --- /dev/null +++ b/packages/daemon/src/version.ts @@ -0,0 +1,26 @@ +/** + * Read the daemon's own `package.json` version at boot. Mirrors + * `packages/agent-core/src/version.ts:4-13` so the daemon's `/v1/meta` can + * report a real version without taking an SDK / agent-core export dep. + * + * Tries the bundled-at-build `package.json` next to `dist/` first; falls back + * to `0.0.0` if the file is unreachable (e.g. tree-shaken bundle). + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +let cached: string | undefined; + +export function getDaemonVersion(): string { + if (cached !== undefined) return cached; + try { + const pkgUrl = new URL('../package.json', import.meta.url); + const raw = readFileSync(fileURLToPath(pkgUrl), 'utf-8'); + const pkg = JSON.parse(raw) as { version?: unknown }; + cached = typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + cached = '0.0.0'; + } + return cached; +} diff --git a/packages/daemon/src/ws/connection.ts b/packages/daemon/src/ws/connection.ts new file mode 100644 index 000000000..130c4082a --- /dev/null +++ b/packages/daemon/src/ws/connection.ts @@ -0,0 +1,683 @@ +/** + * `WsConnection` (W5.1 / P0.15, extended W7.3 / P1.4b for `abort` handling) — + * per-socket WS state holder. + * + * One instance per upgraded HTTP request. Owns: + * - The raw `ws.WebSocket`. + * - Subscription set (populated in W5.2 via `subscribe` control). + * - Per-session `lastSeqBySession` (populated in W5.3 from `client_hello`). + * - Ping/pong heartbeat timers (WS.md §1.3, §3.5). + * + * Lifecycle (WS.md §1): + * 1. Constructor sends `server_hello`. + * 2. Client should respond with `client_hello` (W5.3 will react with + * replay-or-resync logic; W5.1 only logs the hello + acks). + * 3. Server pings every `pingIntervalMs` (30s prod, overridable for tests). + * Client must `pong` within `pongTimeoutMs` (10s prod per WS.md §1.3) + * else server terminates the socket. + * + * **W5.2** extends the message switch with `subscribe` / `unsubscribe` handlers + * (wiring `ISessionClientsService` registration). + * **W5.3** extends `client_hello` to parse `last_seq_by_session` and replay + * buffered events or send `resync_required`. + * **W7.3** extends `abort` to dispatch through the same handler the REST + * fallback route uses (`IPromptService.abort`). See WS.md §3.4 for the ack + * shape — idempotent calls return `code: 0, payload.aborted: false`. + * + * Anti-corruption: WS schemas come from `@moonshot-ai/protocol` (Zod-validated + * on the inbound path per PLAN D3). The daemon never imports directly from + * the SDK package. + */ + +import type { RawData, WebSocket } from 'ws'; +import { ulid } from 'ulid'; + +import { + ErrorCode, + clientControlMessageSchema, + type AbortMessage, + type ClientHelloMessage, + type SubscribeMessage, + type UnsubscribeMessage, + type WatchFsAddMessage, + type WatchFsRemoveMessage, +} from '@moonshot-ai/protocol'; + +import type { ILogger } from '../services/logger.js'; +import type { ISessionClientsService } from '../services/session-clients.js'; + +import { + buildAck, + buildPing, + buildResyncRequired, + buildServerHello, + type EventEnvelope, +} from './protocol.js'; + +/** + * Subset of `DaemonEventBus` consumed by `WsConnection` for the W5.3 replay + * path. Keeping it as a structural interface lets tests pass a stub without + * a full event bus, and prevents `WsConnection` from circular-importing + * `DaemonEventBus` (which itself imports types from this file via + * `protocol.ts`). + * + * `events`: list of buffered envelopes with `seq > lastSeq`, in order. + * `resyncRequired`: true iff the buffer evicted past the client's gap. + * `currentSeq`: highest dispatched seq for the session (0 if never published). + */ +export interface BufferReplaySource { + getBufferedSince( + sessionId: string, + lastSeq: number, + ): { + events: Array<{ seq: number; envelope: EventEnvelope }>; + resyncRequired: boolean; + currentSeq: number; + }; +} + +/** + * Abort handler — implemented in production by `IPromptService.abort` (via + * the route layer's accessor pattern). The structural interface lets the + * WS connection share the exact REST handler without importing the daemon's + * full DI graph from this file (which is wired below `services/`). + * + * The handler returns the same shape the REST endpoint emits on success + * (`{aborted: true, at_seq?}`). On idempotent / already-completed calls it + * throws `PromptAlreadyCompletedError`; the WS adapter translates that to + * `{aborted: false}` per WS.md §3.4 (NOT the REST 40903 — different + * convention: WS uses `code: 0, payload.aborted: false` for idempotent). + */ +export interface AbortHandler { + abort( + sessionId: string, + promptId: string, + ): Promise<{ aborted: boolean; at_seq?: number }>; + /** + * Per-session current seq, used to populate `at_seq` in the WS abort ack + * when the underlying abort is idempotent (no abort RPC actually fires). + */ + currentSeq(sessionId: string): number; +} + +/** + * W12 / Chain 14 — `subscribe.watch_fs` + `watch_fs_add` + `watch_fs_remove` + * delivery surface. Implemented in production by a thin adapter sitting on + * top of `IFsWatcher` + `ISessionService` (see `start.ts`). + * + * Each method: + * - Resolves `session.metadata.cwd` from `ISessionService`. + * - Validates each wire path through `resolveSafePath(cwd, p)`. + * - Calls `IFsWatcher.addPaths` / `removePaths` for the + * `(sessionId, connectionId)` tuple. + * - Returns the resulting `watched_paths` (POSIX-relative to cwd) for + * the ack frame. + * + * Errors map at THIS layer to a `code` value the WS adapter writes + * verbatim into the ack frame: + * - `FsWatchLimitError` (per-connection > 100 paths) → 42902. + * - `FsPathEscapesError` → 41304. + * - `SessionNotFoundError` → 40401. + * - Other → 50001. + * + * The `cleanupConnection(connId)` call lets the connection drop all + * watch subscriptions when the socket closes (without needing to know + * about every session it was watching). + */ +export interface FsWatchHandler { + add( + sessionId: string, + connectionId: string, + wirePaths: readonly string[], + ): Promise; + remove( + sessionId: string, + connectionId: string, + wirePaths: readonly string[], + ): Promise; + cleanupConnection(connectionId: string): void; +} + +export type FsWatchResult = + | { ok: true; watched_paths: string[]; current_count: number } + | { ok: false; code: number; msg: string }; + +export interface WsConnectionOptions { + socket: WebSocket; + logger: ILogger; + /** Per-session subscriber index — populated by `subscribe` / `unsubscribe` (W5.2). */ + sessionClients: ISessionClientsService; + /** Ring-buffer replay source — `DaemonEventBus` in prod, stub in tests (W5.3). */ + eventBus: BufferReplaySource; + /** Abort handler — `IPromptService.abort` in prod, stub in tests (W7.3). */ + abortHandler?: AbortHandler; + /** Watch_fs handler — `IFsWatcher` adapter in prod, stub in tests (W12 / Chain 14). */ + fsWatchHandler?: FsWatchHandler; + /** Server ID echoed in `server_hello.payload.server_id` (defaults to a fresh ULID). */ + serverId?: string; + /** ms between server pings. Default 30_000 (WS.md §1.3). */ + pingIntervalMs?: number; + /** + * Terminate connection if client doesn't pong within this many ms after a + * ping. Default 10_000 (WS.md §1.3 — client must respond within 10s). + */ + pongTimeoutMs?: number; + /** Max events kept per-session in ring buffer (echoed in server_hello). Default 1000. */ + maxEventBufferSize?: number; +} + +/** WS.md §3.1 default heartbeat. */ +const DEFAULT_PING_INTERVAL_MS = 30_000; +/** WS.md §1.3 client pong deadline (10s after ping). */ +const DEFAULT_PONG_TIMEOUT_MS = 10_000; +/** WS.md §3.1 default ring-buffer cap. */ +const DEFAULT_MAX_EVENT_BUFFER = 1000; + +export class WsConnection { + public readonly id: string; + /** session_ids this connection is subscribed to (populated in W5.2). */ + public readonly subscriptions = new Set(); + /** Per-session client-reported last seq (populated in W5.3 from client_hello). */ + public readonly lastSeqBySession = new Map(); + + private readonly socket: WebSocket; + private readonly logger: ILogger; + private readonly sessionClients: ISessionClientsService; + private readonly eventBus: BufferReplaySource; + private readonly abortHandler: AbortHandler | undefined; + private readonly fsWatchHandler: FsWatchHandler | undefined; + private readonly pingIntervalMs: number; + private readonly pongTimeoutMs: number; + private readonly maxEventBufferSize: number; + + private pingTimer?: NodeJS.Timeout; + private pongTimer?: NodeJS.Timeout; + private closed = false; + private gotClientHello = false; + + constructor(opts: WsConnectionOptions) { + this.id = `conn_${ulid()}`; + this.socket = opts.socket; + this.logger = opts.logger.child({ connId: this.id }); + this.sessionClients = opts.sessionClients; + this.eventBus = opts.eventBus; + this.abortHandler = opts.abortHandler; + this.fsWatchHandler = opts.fsWatchHandler; + this.pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS; + this.pongTimeoutMs = opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS; + this.maxEventBufferSize = opts.maxEventBufferSize ?? DEFAULT_MAX_EVENT_BUFFER; + + // First frame after the WS upgrade is `server_hello` (WS.md §1 step 2). + this.send( + buildServerHello({ + server_id: opts.serverId ?? ulid(), + heartbeat_ms: this.pingIntervalMs, + max_event_buffer_size: this.maxEventBufferSize, + capabilities: { event_batching: false, compression: false }, + }), + ); + + this.socket.on('message', (data) => this.onMessage(data)); + this.socket.on('close', (code, reason) => this.onClose(code, String(reason))); + this.socket.on('error', (err) => this.logger.warn({ err: String(err) }, 'ws socket error')); + + this.startPingTimer(); + } + + private onMessage(data: RawData): void { + if (this.closed) return; + let parsed: unknown; + try { + parsed = JSON.parse(String(data)); + } catch { + this.logger.warn('non-json ws frame; ignoring'); + return; + } + const result = clientControlMessageSchema.safeParse(parsed); + if (!result.success) { + this.logger.warn({ issues: result.error.issues.length }, 'invalid control message'); + return; + } + const msg = result.data; + switch (msg.type) { + case 'client_hello': + this.onClientHello(msg); + break; + case 'pong': + this.onPong(); + break; + case 'subscribe': + this.onSubscribe(msg); + break; + case 'unsubscribe': + this.onUnsubscribe(msg); + break; + case 'abort': + this.onAbort(msg); + break; + case 'watch_fs_add': + this.onWatchFsAdd(msg); + break; + case 'watch_fs_remove': + this.onWatchFsRemove(msg); + break; + default: { + const exhaustive: never = msg; + void exhaustive; + this.logger.warn('unhandled control message type'); + } + } + } + + private onClientHello(msg: ClientHelloMessage): void { + this.gotClientHello = true; + const { subscriptions, last_seq_by_session } = msg.payload; + const accepted: string[] = []; + const resyncRequired: string[] = []; + + // 1) Subscribe to every session in `subscriptions` FIRST so any concurrent + // publish lands on us before we start the replay loop (which can take + // multiple ms for a 1000-event session). + for (const sid of subscriptions) { + this.subscribe(sid); + accepted.push(sid); + } + + // 2) Replay missed events. WS.md §3.2: for each (sid, lastSeq) entry, + // ask EventBus for events > lastSeq. If the buffer evicted past that + // point → send a `resync_required` frame and mark the sid in the ack. + // Otherwise send each missed event in order on this single connection. + if (last_seq_by_session) { + for (const [sid, lastSeq] of Object.entries(last_seq_by_session)) { + this.lastSeqBySession.set(sid, lastSeq); + // Ensure subscribed even if not in `subscriptions` array — WS.md §3.2 + // says `last_seq_by_session[sid]` implies interest in `sid`. + if (!this.subscriptions.has(sid)) { + this.subscribe(sid); + accepted.push(sid); + } + const result = this.eventBus.getBufferedSince(sid, lastSeq); + if (result.resyncRequired) { + this.send(buildResyncRequired(sid, 'buffer_overflow', result.currentSeq)); + resyncRequired.push(sid); + } else { + for (const entry of result.events) { + this.send(entry.envelope); + } + } + } + } + + this.logger.info( + { + acceptedCount: accepted.length, + resyncRequiredCount: resyncRequired.length, + }, + 'client hello', + ); + this.send( + buildAck(msg.id, 0, 'success', { + accepted_subscriptions: accepted, + resync_required: resyncRequired, + }), + ); + } + + private onSubscribe(msg: SubscribeMessage): void { + const { session_ids, last_seq_by_session, watch_fs } = msg.payload; + const accepted: string[] = []; + const resyncRequired: string[] = []; + + for (const sid of session_ids) { + this.subscribe(sid); + accepted.push(sid); + } + + // `subscribe` also supports per-session `last_seq` for mid-session + // resync (e.g. client reconnects and adds a session via subscribe rather + // than via client_hello). Same replay-or-resync logic. + if (last_seq_by_session) { + for (const [sid, lastSeq] of Object.entries(last_seq_by_session)) { + this.lastSeqBySession.set(sid, lastSeq); + const result = this.eventBus.getBufferedSince(sid, lastSeq); + if (result.resyncRequired) { + this.send(buildResyncRequired(sid, 'buffer_overflow', result.currentSeq)); + resyncRequired.push(sid); + } else { + for (const entry of result.events) { + this.send(entry.envelope); + } + } + } + } + + // W12 / Chain 14 — handle optional `watch_fs` map (WS.md §3.3). + // We fire-and-forget each per-session watch add: the underlying + // handler resolves cwd and validates paths asynchronously. Errors + // here do NOT fail the subscribe ack — `subscribe.watch_fs` is a + // hint to set up file-watch alongside session subscription, but + // the canonical add path is `watch_fs_add`. If validation fails + // for a session entry we log a warning; the client should re-issue + // `watch_fs_add` to surface the explicit ack code. + if (watch_fs && this.fsWatchHandler !== undefined) { + for (const [sid, cfg] of Object.entries(watch_fs)) { + if (cfg.paths.length === 0) continue; + const handler = this.fsWatchHandler; + void handler + .add(sid, this.id, cfg.paths) + .then((result) => { + if (!result.ok) { + this.logger.warn( + { sid, code: result.code, msg: result.msg }, + 'subscribe.watch_fs add failed; client should retry via watch_fs_add', + ); + } + }) + .catch((err: unknown) => { + this.logger.warn( + { sid, err: String(err) }, + 'subscribe.watch_fs add threw', + ); + }); + } + } + + this.send( + buildAck(msg.id, 0, 'success', { + accepted, + not_found: [], + resync_required: resyncRequired, + }), + ); + } + + private onUnsubscribe(msg: UnsubscribeMessage): void { + const { session_ids } = msg.payload; + for (const sid of session_ids) { + this.unsubscribe(sid); + // W12 / Chain 14 (WS.md §3.3): "解订时同时 drop 该 session 的所有 watch_fs" + // Surface all current watched paths for the session and remove them. + if (this.fsWatchHandler !== undefined) { + // No direct query method on the handler interface; the watcher + // service drops state when no paths remain. We send a remove with + // the empty array as a no-op signal AND fully drop via + // `cleanupConnection` if every session is dropped. The simpler + // path: call remove with an empty array (no-op) plus we let the + // server-side `forgetConnection` happen on socket close. For + // partial unsubscribe (one of many sessions) we expose a hook: + // calling `remove(sid, conn, currently_watched_paths)`. We don't + // know the current set here, so we delegate to the handler which + // owns the state. + const handler = this.fsWatchHandler; + void handler.remove(sid, this.id, []).catch((err: unknown) => { + this.logger.warn( + { sid, err: String(err) }, + 'unsubscribe watch_fs drop threw', + ); + }); + } + } + this.send( + buildAck(msg.id, 0, 'success', { + accepted: session_ids, + not_found: [], + resync_required: [], + }), + ); + } + + /** + * W12 / Chain 14 (WS.md §3.3.1) — handle `watch_fs_add`. Adds the + * caller's paths to the per-session chokidar watcher and acks with the + * full deduplicated `watched_paths` list for the session (POSIX-relative + * to `session.cwd`). Validation failures land as ack codes: + * - 42902 fs.watch_limit_exceeded (per-connection > 100 paths) + * - 41304 fs.path_escapes_session (any input path) + * - 40401 session.not_found + * - 50001 internal (unexpected throw) + * + * No-op acks (empty paths array) succeed with code 0 and echo back the + * existing `watched_paths` set (idempotent semantics). + */ + private onWatchFsAdd(msg: WatchFsAddMessage): void { + if (this.fsWatchHandler === undefined) { + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'fs watch handler not wired', {}), + ); + return; + } + const { session_id, paths } = msg.payload; + const handler = this.fsWatchHandler; + void handler + .add(session_id, this.id, paths) + .then((result) => { + if (!result.ok) { + this.send(buildAck(msg.id, result.code, result.msg, {})); + return; + } + this.send( + buildAck(msg.id, 0, 'success', { + watched_paths: result.watched_paths, + current_count: result.current_count, + }), + ); + }) + .catch((err: unknown) => { + this.logger.warn({ err: String(err) }, 'watch_fs_add handler threw'); + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'watch_fs_add failed', {}), + ); + }); + } + + /** + * W12 / Chain 14 (WS.md §3.3.1) — handle `watch_fs_remove`. Idempotent. + * Empty paths array acks with code 0 + current `watched_paths`. + */ + private onWatchFsRemove(msg: WatchFsRemoveMessage): void { + if (this.fsWatchHandler === undefined) { + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'fs watch handler not wired', {}), + ); + return; + } + const { session_id, paths } = msg.payload; + const handler = this.fsWatchHandler; + void handler + .remove(session_id, this.id, paths) + .then((result) => { + if (!result.ok) { + this.send(buildAck(msg.id, result.code, result.msg, {})); + return; + } + this.send( + buildAck(msg.id, 0, 'success', { + watched_paths: result.watched_paths, + current_count: result.current_count, + }), + ); + }) + .catch((err: unknown) => { + this.logger.warn({ err: String(err) }, 'watch_fs_remove handler threw'); + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'watch_fs_remove failed', {}), + ); + }); + } + + /** + * W7.3: dispatch a WS `abort` control message through the same handler the + * REST `POST /v1/sessions/{sid}/prompts/{pid}:abort` route uses + * (`IPromptService.abort` via the daemon's accessor). Idempotent calls + * (already-completed prompt) return `code: 0, payload.aborted: false` + * per WS.md §3.4 — NOT the REST 40903; different convention to avoid the + * UI churn of treating idempotent success as an error. + * + * Errors map: + * - PromptAlreadyCompletedError → `code: 0, aborted: false, at_seq` (idempotent) + * - PromptNotFoundError → `code: 40402 prompt.not_found` + * - SessionNotFoundError → `code: 40401 session.not_found` + * - other → `code: 50001 internal` + * + * If no abort handler is wired (test stub without one), reply with a + * 50001 ack so the protocol contract stays observable. + */ + private onAbort(msg: AbortMessage): void { + const { session_id, prompt_id } = msg.payload; + if (this.abortHandler === undefined) { + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'abort handler not wired', {}), + ); + return; + } + void this.abortHandler + .abort(session_id, prompt_id) + .then((result) => { + this.send( + buildAck(msg.id, 0, 'success', { + aborted: result.aborted, + ...(result.at_seq !== undefined ? { at_seq: result.at_seq } : {}), + }), + ); + }) + .catch((err: unknown) => { + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'PromptAlreadyCompletedError' + ) { + const at_seq = this.abortHandler!.currentSeq(session_id); + this.send( + buildAck(msg.id, 0, 'success', { aborted: false, at_seq }), + ); + return; + } + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'PromptNotFoundError' + ) { + this.send( + buildAck(msg.id, ErrorCode.PROMPT_NOT_FOUND, 'prompt not found', {}), + ); + return; + } + if ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'SessionNotFoundError' + ) { + this.send( + buildAck(msg.id, ErrorCode.SESSION_NOT_FOUND, 'session not found', {}), + ); + return; + } + this.logger.warn({ err: String(err) }, 'ws abort handler error'); + this.send( + buildAck(msg.id, ErrorCode.INTERNAL_ERROR, 'abort failed', {}), + ); + }); + } + + /** Idempotent subscribe — registers both locally and in `ISessionClientsService`. */ + private subscribe(sid: string): void { + if (this.subscriptions.has(sid)) return; + this.subscriptions.add(sid); + this.sessionClients.subscribe(this, sid); + } + + /** Idempotent unsubscribe. */ + private unsubscribe(sid: string): void { + if (!this.subscriptions.has(sid)) return; + this.subscriptions.delete(sid); + this.sessionClients.unsubscribe(this, sid); + } + + private startPingTimer(): void { + this.pingTimer = setInterval(() => { + if (this.closed) return; + this.send(buildPing()); + // Schedule a single pong deadline. The pong handler clears it; if the + // deadline fires we terminate the socket. + if (this.pongTimer) clearTimeout(this.pongTimer); + this.pongTimer = setTimeout(() => { + if (this.closed) return; + this.logger.warn('pong timeout — terminating socket'); + try { + this.socket.terminate(); + } catch { + // ignore + } + }, this.pongTimeoutMs); + this.pongTimer.unref?.(); + }, this.pingIntervalMs); + this.pingTimer.unref?.(); + } + + private onPong(): void { + if (this.pongTimer) { + clearTimeout(this.pongTimer); + this.pongTimer = undefined; + } + } + + private onClose(code: number, reason: string): void { + if (this.closed) return; + this.closed = true; + if (this.pingTimer) clearInterval(this.pingTimer); + if (this.pongTimer) clearTimeout(this.pongTimer); + // Drop all subscriptions in one shot so EventBus.publish() doesn't try to + // send into a closed socket on the next event. + this.sessionClients.forgetConnection(this); + this.subscriptions.clear(); + // W12 / Chain 14 — drop all fs-watch subscriptions for this connection. + // The watcher closes any chokidar instance whose path-set goes empty. + if (this.fsWatchHandler !== undefined) { + try { + this.fsWatchHandler.cleanupConnection(this.id); + } catch (err) { + this.logger.warn( + { err: String(err) }, + 'fsWatchHandler.cleanupConnection threw', + ); + } + } + this.logger.info({ code, reason, gotClientHello: this.gotClientHello }, 'connection closed'); + } + + /** + * Outbound send. Used both for system frames (W5.1) and for per-session + * event envelopes pushed by `DaemonEventBus` (W5.2). Drops silently if the + * socket is closed or not yet OPEN. + */ + public send(message: unknown): void { + if (this.closed) return; + if (this.socket.readyState !== this.socket.OPEN) return; + try { + this.socket.send(JSON.stringify(message), (err) => { + if (err) this.logger.warn({ err: String(err) }, 'ws send failed'); + }); + } catch (err) { + this.logger.warn({ err: String(err) }, 'ws send threw'); + } + } + + /** Initiate graceful close (default WS code 1000). */ + public close(code = 1000, reason?: string): void { + if (this.closed) return; + try { + this.socket.close(code, reason); + } catch { + // ignore — socket may already be closing + } + // The `'close'` listener will flip `closed` and clean timers. + } + + /** Test helper: whether the server has received a `client_hello`. */ + public get hasClientHello(): boolean { + return this.gotClientHello; + } +} diff --git a/packages/daemon/src/ws/protocol.ts b/packages/daemon/src/ws/protocol.ts new file mode 100644 index 000000000..b166ac9b0 --- /dev/null +++ b/packages/daemon/src/ws/protocol.ts @@ -0,0 +1,151 @@ +/** + * WS envelope helpers (W5.1+W5.2+W5.3 / P0.15+P0.16+P0.17) — thin builders + * around the `@moonshot-ai/protocol` schemas so `WsConnection` and + * `DaemonEventBus` don't both re-implement the wire shape (WS.md §2). + * + * **W5.1**: `server_hello`, `ping`, `ack`. + * **W5.2**: + `event` envelope helper. + * **W5.3**: + `resync_required` helper. + * + * Per WS.md §3.5: `ping` is server-pushed, carries `timestamp` + `payload.nonce`. + * Per WS.md §3.1: `server_hello` carries `timestamp` + canonical capability set. + * Per WS.md §2: `ack` carries `id` (echoes Control.id), `code` (REST error + * namespace, 0=success), `msg`, and `payload`. + * Per WS.md §2: `event` envelope carries `seq` (per-session monotonic), + * `session_id`, `timestamp`, `payload` (the agent-core Event). + * Per WS.md §3.6: `resync_required` carries `session_id`, `reason` + * ('buffer_overflow' | 'session_recreated'), and `current_seq`. + * + * Outbound payloads go straight to `JSON.stringify` — no Zod re-validation on + * the outbound path (PLAN D3: high-frequency events are unchecked). + */ + +import type { Event } from '@moonshot-ai/protocol'; +import { ulid } from 'ulid'; + +/** WS.md §3.1: `server_hello.payload`. */ +export interface ServerHelloPayload { + server_id: string; + heartbeat_ms: number; + max_event_buffer_size: number; + capabilities: { + event_batching: boolean; + compression: boolean; + }; +} + +/** WS.md §3.1: `server_hello` frame. */ +export interface ServerHelloFrame { + type: 'server_hello'; + timestamp: string; + payload: ServerHelloPayload; +} + +export function buildServerHello(payload: ServerHelloPayload): ServerHelloFrame { + return { type: 'server_hello', timestamp: new Date().toISOString(), payload }; +} + +/** WS.md §3.5: `ping` frame (S→C). `nonce` is server-minted ULID. */ +export interface PingFrame { + type: 'ping'; + timestamp: string; + payload: { nonce: string }; +} + +export function buildPing(): PingFrame { + return { + type: 'ping', + timestamp: new Date().toISOString(), + payload: { nonce: ulid() }, + }; +} + +/** + * WS.md §2: `ack` frame (S→C). `code` follows REST error namespace + * (0 = success). `msg` matches `code`. Echoes `id` back from the inbound + * control message — callers pass `''` when the inbound had no `id`. + */ +export interface AckFrame

{ + type: 'ack'; + id: string; + code: number; + msg: string; + payload: P; +} + +export function buildAck

(id: string, code: number, msg: string, payload: P): AckFrame

{ + return { type: 'ack', id, code, msg, payload }; +} + +/** + * WS.md §2: event envelope (S→C). `seq` is per-session monotonically + * increasing starting at 1; non-event frames use a different outer shape + * (`server_hello` / `ping` / `ack` / `resync_required`). + * + * Stage 1: `payload` is the raw `Event` (camelCase per + * `packages/agent-core/src/rpc/events.ts:320`). WS.md §7.5 documents a + * future `toWire` snake_case mapping — punted to a later phase (PLAN + * §non-goals); for now the daemon sends the camelCase payload as-is and only + * the outer envelope (`type`, `seq`, `session_id`, `timestamp`, `payload`) is + * snake_case. + * + * The `type` on the envelope is the agent-core event type string (e.g. + * `event.assistant.delta`) — WS.md §8 specifies a future renaming layer + * (also Phase 2). For Stage 1 unit tests we'll usually publish stub events + * with arbitrary `type` strings. + */ +export interface EventEnvelope

{ + type: string; + seq: number; + session_id: string; + timestamp: string; + payload: P; +} + +export function buildEventEnvelope( + seq: number, + sessionId: string, + event: Event, +): EventEnvelope { + const type = (event as { type?: string }).type ?? 'event.unknown'; + return { + type, + seq, + session_id: sessionId, + timestamp: new Date().toISOString(), + payload: event, + }; +} + +/** + * WS.md §3.6: `resync_required` system message (S→C). Sent when the + * client's claimed `last_seq` for a session is older than the ring buffer + * retains (`lastSeq + 1 < oldestSeq`). The client should drop its local + * cache for that session and `GET /v1/sessions/{id}/messages` to rebuild, + * then re-`subscribe` with `last_seq_by_session[sid] = current_seq`. + * + * `reason` is always `'buffer_overflow'` in Stage 1; `'session_recreated'` + * is reserved for a later phase that handles session deletion / re-creation + * with the same id. + */ +export interface ResyncRequiredFrame { + type: 'resync_required'; + timestamp: string; + payload: { + session_id: string; + reason: 'buffer_overflow' | 'session_recreated'; + current_seq: number; + }; +} + +export function buildResyncRequired( + sessionId: string, + reason: 'buffer_overflow' | 'session_recreated', + currentSeq: number, +): ResyncRequiredFrame { + return { + type: 'resync_required', + timestamp: new Date().toISOString(), + payload: { session_id: sessionId, reason, current_seq: currentSeq }, + }; +} diff --git a/packages/daemon/test/anti-corruption.test.ts b/packages/daemon/test/anti-corruption.test.ts new file mode 100644 index 000000000..c45863d52 --- /dev/null +++ b/packages/daemon/test/anti-corruption.test.ts @@ -0,0 +1,34 @@ +/** + * Anti-corruption invariant (Stage 1 hard rule, W4 STATUS quality gate). + * + * The daemon must not directly import the `@moonshot-ai/kimi-code-sdk` package + * or use any of its concrete classes (KimiHarness / createRPC / SDKRpcClient). + * The bridge layer in `@moonshot-ai/services` owns the in-process KimiCore + + * RPC pair; daemon-side code crosses that boundary only via the broker + * interfaces and `HarnessBridge.rpc`. + * + * This test is a guard rail — a single bad import here would re-couple the + * daemon to the SDK shape we're explicitly migrating away from. + */ + +import { execSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +// packages/daemon/test → packages/daemon/src +const daemonSrc = resolve(here, '..', 'src'); + +describe('packages/daemon/src anti-corruption', () => { + it('has zero @moonshot-ai/kimi-code-sdk / KimiHarness / createRPC / SDKRpcClient references', () => { + // -r recursive; -E POSIX extended regex; `|| true` so a "no match" exit + // code 1 doesn't fail the spawn. We assert on stdout being empty. + const out = execSync( + `grep -rE "@moonshot-ai/kimi-code-sdk|KimiHarness\\b|createRPC\\b|SDKRpcClient\\b" "${daemonSrc}" || true`, + { encoding: 'utf8' }, + ).trim(); + expect(out).toBe(''); + }); +}); diff --git a/packages/daemon/test/approval.e2e.test.ts b/packages/daemon/test/approval.e2e.test.ts new file mode 100644 index 000000000..fcbd0d9d3 --- /dev/null +++ b/packages/daemon/test/approval.e2e.test.ts @@ -0,0 +1,411 @@ +/** + * Approval end-to-end tests (W8.1 / Chain 5 / P1.5). + * + * Covers the reverse-RPC path: agent-core → BridgeClientAPI.requestApproval + * → IApprovalBroker.request → WS `event.approval.requested` → REST + * `POST /v1/sessions/{sid}/approvals/{aid}` → Promise resolves with agent-core + * `ApprovalResponse`. + * + * **Bootstrap strategy** (mirrors prompt.e2e.test.ts): spawn the real daemon, + * skip the `bridge.rpc.prompt(...)` path (requires provider creds), and drive + * the broker DIRECTLY via the DI accessor. This exercises: + * - Adapter (in-process SDK shape → snake_case wire shape) + * - WS broadcast through `IEventBus.publish` → subscriber receives frame + * with `payload.approval_id` + 12-arm `tool_input_display` preserved + * - REST `POST` resolves → broker Promise settles → response converts back + * to in-process SDK shape + * - Idempotency (40902) + not-found (40404) + * - 60s timeout (override to 30ms) broadcasts `event.approval.expired` AND + * rejects the Promise with `ApprovalExpiredError`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { + IApprovalBroker, + type ApprovalRequest, + type ApprovalResponse, +} from '@moonshot-ai/services'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; +import { + ApprovalExpiredError, + DaemonApprovalBroker, +} from '../src/services/approval-broker'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-approvals-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-approvals-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +async function openSubscriber( + r: RunningDaemon, + sid: string, +): Promise<{ + ws: WebSocket; + received: Record[]; +}> { + const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const received: Record[] = []; + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(wsUrl); + sock.on('message', (data) => { + try { + received.push(JSON.parse(String(data)) as Record); + } catch { + // ignore + } + }); + sock.once('open', () => resolve(sock)); + sock.once('error', reject); + }); + await waitFor(received, (f) => f['type'] === 'server_hello'); + ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'h1', + payload: { client_id: 'test', subscriptions: [sid] }, + }), + ); + await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1'); + return { ws, received }; +} + +async function waitFor( + received: Record[], + pred: (f: Record) => boolean, + timeoutMs = 2000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const hit = received.find(pred); + if (hit !== undefined) return hit; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error( + `waitFor timed out; received: ${received.map((f) => f['type']).join(', ')}`, + ); +} + +// --- Tests ----------------------------------------------------------------- + +describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle (W8.1)', () => { + it('full happy path: broker request → WS event.approval.requested → REST POST → Promise resolves', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IApprovalBroker) as DaemonApprovalBroker, + ); + + const inProcReq: ApprovalRequest = { + turnId: 11, + toolCallId: 'tc_approval_happy', + toolName: 'shell.run', + action: 'Run `ls`', + display: { kind: 'command', command: 'ls', summary: 'ls' } as never, + }; + + const pending = broker.request({ + ...inProcReq, + sessionId: sid, + agentId: 'main', + }); + + // Wait for the WS event. + const requested = await waitFor( + received, + (f) => f['type'] === 'event.approval.requested', + 2000, + ); + const payload = requested['payload'] as { + approval_id: string; + session_id: string; + tool_call_id: string; + tool_name: string; + action: string; + tool_input_display: { kind: string; command: string; summary: string }; + created_at: string; + expires_at: string; + }; + expect(payload.approval_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(payload.session_id).toBe(sid); + expect(payload.tool_call_id).toBe('tc_approval_happy'); + expect(payload.tool_name).toBe('shell.run'); + expect(payload.action).toBe('Run `ls`'); + // 12-arm passthrough: snake_case `tool_input_display` preserves the + // entire SDK shape unchanged. + expect(payload.tool_input_display).toEqual({ + kind: 'command', + command: 'ls', + summary: 'ls', + }); + expect(payload.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(payload.expires_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // REST resolve. + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/approvals/${payload.approval_id}`, + payload: { + decision: 'approved', + scope: 'session', + feedback: 'looks good', + selected_label: 'Run', + }, + }); + const env = envelopeOf<{ resolved: boolean; resolved_at: string }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.resolved).toBe(true); + expect(env.data?.resolved_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // Promise settles with snake→camel adapted shape. + const inProcResp: ApprovalResponse = await pending; + expect(inProcResp.decision).toBe('approved'); + expect(inProcResp.scope).toBe('session'); + expect(inProcResp.feedback).toBe('looks good'); + expect(inProcResp.selectedLabel).toBe('Run'); + + // Resolved broadcast also reaches the subscriber. + const resolvedFrame = await waitFor( + received, + (f) => f['type'] === 'event.approval.resolved', + 2000, + ); + const resolvedPayload = resolvedFrame['payload'] as { + approval_id: string; + decision: string; + selected_label?: string; + }; + expect(resolvedPayload.approval_id).toBe(payload.approval_id); + expect(resolvedPayload.decision).toBe('approved'); + expect(resolvedPayload.selected_label).toBe('Run'); + + ws.close(); + }); + + it('60s timeout broadcasts event.approval.expired + rejects with ApprovalExpiredError', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + // Swap in a short-timeout broker for this session — clean by reaching into + // the container, since startDaemon doesn't expose a broker-options + // override yet. + const broker = r.services.invokeFunction( + (a) => a.get(IApprovalBroker) as DaemonApprovalBroker, + ); + // Stamp the timeout via a private field hack — the test already + // co-owns the impl. (In a fuller world we'd thread a `brokerOptions` + // option through DaemonStartOptions.) + (broker as unknown as { _timeoutMs: number })._timeoutMs = 40; + + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + toolCallId: 'tc_timeout', + toolName: 'shell.run', + action: 'Run', + display: { kind: 'generic', summary: 'test' } as never, + turnId: 1, + }); + + // Expect a rejection AND an event.approval.expired frame. + let rejection: unknown; + try { + await pending; + } catch (err) { + rejection = err; + } + expect(rejection).toBeInstanceOf(ApprovalExpiredError); + + const expiredFrame = await waitFor( + received, + (f) => f['type'] === 'event.approval.expired', + 2000, + ); + const payload = expiredFrame['payload'] as { approval_id: string }; + expect(payload.approval_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + + ws.close(); + }); + + it('REST resolve on unknown approval_id returns 40404', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/approvals/01JAAAAAAAAAAAAAAAAAAAAAAA`, + payload: { decision: 'approved' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40404); + }); + + it('REST re-resolve on already-resolved approval returns 40902 with data:{resolved:false}', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + + const broker = r.services.invokeFunction( + (a) => a.get(IApprovalBroker) as DaemonApprovalBroker, + ); + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + toolCallId: 'tc_idem', + toolName: 'shell.run', + action: 'Run', + display: { kind: 'generic', summary: 'test' } as never, + }); + + // Capture the daemon-minted approval_id by inspecting the broker's + // pending map (single entry). + let approvalId: string | undefined; + for (let i = 0; i < 20 && !approvalId; i++) { + await new Promise((r) => setTimeout(r, 10)); + const peek = (broker as unknown as { + _pending: Map; + })._pending; + approvalId = peek.values().next().value?.approvalId; + } + expect(approvalId).toBeDefined(); + + // First resolve succeeds. + const ok = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/approvals/${approvalId}`, + payload: { decision: 'approved' }, + }); + const env1 = envelopeOf<{ resolved: boolean }>(ok.json()); + expect(env1.code).toBe(0); + await pending; + + // Second resolve hits the idempotency window. + const dup = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/approvals/${approvalId}`, + payload: { decision: 'approved' }, + }); + const env2 = envelopeOf<{ resolved: boolean }>(dup.json()); + expect(env2.code).toBe(40902); + expect(env2.data).toEqual({ resolved: false }); + }); + + it('REST resolve with bad body returns 40001 (validation failure)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + + const broker = r.services.invokeFunction( + (a) => a.get(IApprovalBroker) as DaemonApprovalBroker, + ); + const _pending = broker.request({ + sessionId: sid, + agentId: 'main', + toolCallId: 'tc_bad_body', + toolName: 'shell.run', + action: 'Run', + display: { kind: 'generic', summary: 'test' } as never, + }); + void _pending; + + // Reach into the pending map to grab the id. + let approvalId: string | undefined; + for (let i = 0; i < 20 && !approvalId; i++) { + await new Promise((r) => setTimeout(r, 10)); + const peek = (broker as unknown as { + _pending: Map; + })._pending; + approvalId = peek.values().next().value?.approvalId; + } + expect(approvalId).toBeDefined(); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/approvals/${approvalId}`, + payload: { decision: 'maybe' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(Array.isArray(env.details)).toBe(true); + + // Cleanup: the broker still has the pending entry; settle it so the + // afterEach close doesn't fight a hanging Promise. + broker.resolve(approvalId!, { decision: 'cancelled' }); + }); +}); diff --git a/packages/daemon/test/dispose-order.test.ts b/packages/daemon/test/dispose-order.test.ts new file mode 100644 index 000000000..f6e6d619f --- /dev/null +++ b/packages/daemon/test/dispose-order.test.ts @@ -0,0 +1,420 @@ +/** + * Dispose-order observability (closes W4 STATUS observability gap). + * + * W4 enforced dispose order STRUCTURALLY via `InstantiationService._constructionOrder` + * (`a.get(X)` records X) but didn't add a SIDE-EFFECT-RECORDING test that + * actually verifies the array. W5.1 adds it now that we're inserting new + * services into the dispose chain — a wrong insertion order would silently + * break broker→logger emit safety. + * + * Test strategy: register a stub for every DI decorator the daemon uses, + * where each stub's `dispose()` pushes its name to a shared `disposeOrder` + * array. Touch each service in CONSTRUCTION order (matches `start.ts`), then + * call `ix.dispose()`. Assert the recorded array is REVERSE of construction. + * + * Construction order under W12 (Chains 14 + 15 add IFsWatcher + IFileStore): + * ILogger → IRestGateway → IConnectionRegistry → ISessionClientsService → + * IEventBus → IApprovalBroker → IQuestionBroker → IWSGateway → + * IHarnessBridge → ISessionService → IMessageService → IPromptService → + * IToolService → IMcpService → ITaskService → IFsService → + * IFsSearchService → IFsGitService → IFsWatcher → IFileStore + * + * Expected dispose order (reverse): + * IFileStore → IFsWatcher → IFsGitService → IFsSearchService → + * IFsService → ITaskService → IMcpService → IToolService → + * IPromptService → IMessageService → ISessionService → IHarnessBridge → + * IWSGateway → IQuestionBroker → IApprovalBroker → IEventBus → + * ISessionClientsService → IConnectionRegistry → IRestGateway → ILogger + * + * Focused invariants: + * - WSGateway disposes BEFORE brokers (W5.1) + * - SessionClients disposes AFTER EventBus (W5.2) + * - ISessionService disposes BEFORE IHarnessBridge (W6.2) + * - IMessageService disposes BEFORE IHarnessBridge (W7.1) + * - IPromptService disposes BEFORE IHarnessBridge AND BEFORE IEventBus + * (W7.2 — the service publishes synthetic events; the bus must still be + * live during its dispose window if it ever needs to flush) + * - IToolService / IMcpService dispose BEFORE IHarnessBridge (W9.1 — + * they're thin adapters; bridge teardown after them is safe). + * - ITaskService disposes BEFORE IHarnessBridge (W9.2 — same). + * - IFsService disposes BEFORE ISessionService (W10 — fs reads + * `session.metadata.cwd` during its lifetime; on dispose we just + * clear the .gitignore cache, but the construction-after-session + * convention places the dispose first regardless). + * - IFsSearchService disposes BEFORE ISessionService (W11 / Chain 11). + * - IFsGitService disposes BEFORE ISessionService (W11 / Chain 12). + * - IFsWatcher disposes BEFORE IFsGitService (W12 / Chain 14 — closes + * every chokidar instance before fs-git's session-coupled state + * tears down). + * - IFileStore disposes BEFORE IFsWatcher (W12 / Chain 15 — store has + * no upstream deps; LIFO position). + */ + +import { describe, expect, it } from 'vitest'; + +import { + InstantiationService, + ServiceCollection, + type IDisposable, +} from '@moonshot-ai/agent-core'; +import { + IApprovalBroker, + IEventBus, + IHarnessBridge, + IMcpService, + IMessageService, + IPromptService, + IQuestionBroker, + ISessionService, + ITaskService, + IToolService, +} from '@moonshot-ai/services'; + +import { IConnectionRegistry } from '../src/services/connection-registry'; +import { IFileStore } from '../src/services/file-store'; +import { IFsGitService } from '../src/services/fs-git'; +import { IFsSearchService } from '../src/services/fs-search'; +import { IFsService } from '../src/services/fs-service'; +import { IFsWatcher } from '../src/services/fs-watcher'; +import { ILogger } from '../src/services/logger'; +import { IRestGateway } from '../src/services/rest-gateway'; +import { ISessionClientsService } from '../src/services/session-clients'; +import { IWSGateway } from '../src/services/ws-gateway'; + +/** Stub implementation whose `dispose()` records ordering. */ +function makeRecorder(name: string, sink: string[]): T & IDisposable { + return { + dispose(): void { + sink.push(name); + }, + } as T & IDisposable; +} + +describe('Dispose order is reverse-of-construction (W5.1 closes W4 gap; W6.2 added ISessionService; W7 adds IMessageService + IPromptService; W9.1 adds IToolService + IMcpService; W9.2 adds ITaskService; W10 adds IFsService; W11 Chain 11 adds IFsSearchService; W11 Chain 12 adds IFsGitService; W12 Chain 14 adds IFsWatcher; W12 Chain 15 adds IFileStore)', () => { + it('records 20 services in exact reverse order', () => { + const order: string[] = []; + + const services = new ServiceCollection( + [ILogger, makeRecorder('ILogger', order)], + [IRestGateway, makeRecorder('IRestGateway', order)], + [IConnectionRegistry, makeRecorder('IConnectionRegistry', order)], + [ISessionClientsService, makeRecorder('ISessionClientsService', order)], + [IEventBus, makeRecorder('IEventBus', order)], + [IApprovalBroker, makeRecorder('IApprovalBroker', order)], + [IQuestionBroker, makeRecorder('IQuestionBroker', order)], + [IWSGateway, makeRecorder('IWSGateway', order)], + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [ISessionService, makeRecorder('ISessionService', order)], + [IMessageService, makeRecorder('IMessageService', order)], + [IPromptService, makeRecorder('IPromptService', order)], + [IToolService, makeRecorder('IToolService', order)], + [IMcpService, makeRecorder('IMcpService', order)], + [ITaskService, makeRecorder('ITaskService', order)], + [IFsService, makeRecorder('IFsService', order)], + [IFsSearchService, makeRecorder('IFsSearchService', order)], + [IFsGitService, makeRecorder('IFsGitService', order)], + [IFsWatcher, makeRecorder('IFsWatcher', order)], + [IFileStore, makeRecorder('IFileStore', order)], + ); + const ix = new InstantiationService(services); + + // Touch in CONSTRUCTION order so _constructionOrder reflects start.ts. + ix.invokeFunction((a) => { + a.get(ILogger); + a.get(IRestGateway); + a.get(IConnectionRegistry); + a.get(ISessionClientsService); + a.get(IEventBus); + a.get(IApprovalBroker); + a.get(IQuestionBroker); + a.get(IWSGateway); + a.get(IHarnessBridge); + a.get(ISessionService); + a.get(IMessageService); + a.get(IPromptService); + a.get(IToolService); + a.get(IMcpService); + a.get(ITaskService); + a.get(IFsService); + a.get(IFsSearchService); + a.get(IFsGitService); + a.get(IFsWatcher); + a.get(IFileStore); + }); + + ix.dispose(); + + expect(order).toEqual([ + 'IFileStore', + 'IFsWatcher', + 'IFsGitService', + 'IFsSearchService', + 'IFsService', + 'ITaskService', + 'IMcpService', + 'IToolService', + 'IPromptService', + 'IMessageService', + 'ISessionService', + 'IHarnessBridge', + 'IWSGateway', + 'IQuestionBroker', + 'IApprovalBroker', + 'IEventBus', + 'ISessionClientsService', + 'IConnectionRegistry', + 'IRestGateway', + 'ILogger', + ]); + }); + + it('logger disposes LAST so broker dispose() can still emit log lines', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ILogger, makeRecorder('ILogger', order)], + [IEventBus, makeRecorder('IEventBus', order)], + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ILogger); + a.get(IEventBus); + a.get(IHarnessBridge); + }); + ix.dispose(); + // Verify logger is last regardless of order. + expect(order[order.length - 1]).toBe('ILogger'); + }); + + it('WSGateway disposes before brokers so brokers never emit on a live socket', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IEventBus, makeRecorder('IEventBus', order)], + [IApprovalBroker, makeRecorder('IApprovalBroker', order)], + [IQuestionBroker, makeRecorder('IQuestionBroker', order)], + [IWSGateway, makeRecorder('IWSGateway', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IEventBus); + a.get(IApprovalBroker); + a.get(IQuestionBroker); + a.get(IWSGateway); + }); + ix.dispose(); + expect(order.indexOf('IWSGateway')).toBeLessThan(order.indexOf('IEventBus')); + expect(order.indexOf('IWSGateway')).toBeLessThan(order.indexOf('IApprovalBroker')); + expect(order.indexOf('IWSGateway')).toBeLessThan(order.indexOf('IQuestionBroker')); + }); + + it('SessionClients disposes AFTER EventBus so the bus stops publishing before subscriber index drops', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ISessionClientsService, makeRecorder('ISessionClientsService', order)], + [IEventBus, makeRecorder('IEventBus', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ISessionClientsService); + a.get(IEventBus); + }); + ix.dispose(); + // EventBus disposes BEFORE SessionClients (reverse-of-construction): + expect(order.indexOf('IEventBus')).toBeLessThan(order.indexOf('ISessionClientsService')); + }); + + it('ISessionService disposes BEFORE IHarnessBridge so the service can rely on a live bridge during its own teardown (W6.2)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [ISessionService, makeRecorder('ISessionService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IHarnessBridge); + a.get(ISessionService); + }); + ix.dispose(); + // ISessionService disposes BEFORE IHarnessBridge — reverse of construction. + expect(order.indexOf('ISessionService')).toBeLessThan(order.indexOf('IHarnessBridge')); + }); + + it('IMessageService disposes BEFORE IHarnessBridge so the service can rely on a live bridge during its own teardown (W7.1)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [IMessageService, makeRecorder('IMessageService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IHarnessBridge); + a.get(IMessageService); + }); + ix.dispose(); + expect(order.indexOf('IMessageService')).toBeLessThan(order.indexOf('IHarnessBridge')); + }); + + it('IPromptService disposes BEFORE IEventBus AND IHarnessBridge (W7.2)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IEventBus, makeRecorder('IEventBus', order)], + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [IPromptService, makeRecorder('IPromptService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IEventBus); + a.get(IHarnessBridge); + a.get(IPromptService); + }); + ix.dispose(); + expect(order.indexOf('IPromptService')).toBeLessThan(order.indexOf('IEventBus')); + expect(order.indexOf('IPromptService')).toBeLessThan(order.indexOf('IHarnessBridge')); + }); + + it('IToolService + IMcpService dispose BEFORE IHarnessBridge so the bridge stays live during their dispose (W9.1)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [IToolService, makeRecorder('IToolService', order)], + [IMcpService, makeRecorder('IMcpService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IHarnessBridge); + a.get(IToolService); + a.get(IMcpService); + }); + ix.dispose(); + expect(order.indexOf('IMcpService')).toBeLessThan(order.indexOf('IHarnessBridge')); + expect(order.indexOf('IToolService')).toBeLessThan(order.indexOf('IHarnessBridge')); + // IMcpService was constructed AFTER IToolService → disposes FIRST. + expect(order.indexOf('IMcpService')).toBeLessThan(order.indexOf('IToolService')); + }); + + it('ITaskService disposes BEFORE IHarnessBridge so the bridge stays live during its dispose (W9.2)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IHarnessBridge, makeRecorder('IHarnessBridge', order)], + [ITaskService, makeRecorder('ITaskService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IHarnessBridge); + a.get(ITaskService); + }); + ix.dispose(); + expect(order.indexOf('ITaskService')).toBeLessThan(order.indexOf('IHarnessBridge')); + }); + + it('IFsService disposes BEFORE ISessionService so the cwd lookup stays live during its dispose (W10)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ISessionService, makeRecorder('ISessionService', order)], + [IFsService, makeRecorder('IFsService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ISessionService); + a.get(IFsService); + }); + ix.dispose(); + expect(order.indexOf('IFsService')).toBeLessThan( + order.indexOf('ISessionService'), + ); + }); + + it('IFsSearchService disposes BEFORE ISessionService AND BEFORE IFsService (W11 / Chain 11)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ISessionService, makeRecorder('ISessionService', order)], + [IFsService, makeRecorder('IFsService', order)], + [IFsSearchService, makeRecorder('IFsSearchService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ISessionService); + a.get(IFsService); + a.get(IFsSearchService); + }); + ix.dispose(); + expect(order.indexOf('IFsSearchService')).toBeLessThan( + order.indexOf('ISessionService'), + ); + expect(order.indexOf('IFsSearchService')).toBeLessThan( + order.indexOf('IFsService'), + ); + }); + + it('IFsGitService disposes BEFORE ISessionService AND BEFORE IFsSearchService (W11 / Chain 12)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ISessionService, makeRecorder('ISessionService', order)], + [IFsService, makeRecorder('IFsService', order)], + [IFsSearchService, makeRecorder('IFsSearchService', order)], + [IFsGitService, makeRecorder('IFsGitService', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ISessionService); + a.get(IFsService); + a.get(IFsSearchService); + a.get(IFsGitService); + }); + ix.dispose(); + expect(order.indexOf('IFsGitService')).toBeLessThan( + order.indexOf('ISessionService'), + ); + expect(order.indexOf('IFsGitService')).toBeLessThan( + order.indexOf('IFsSearchService'), + ); + }); + + it('IFsWatcher disposes BEFORE IFsGitService AND BEFORE ISessionService (W12 / Chain 14)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [ISessionService, makeRecorder('ISessionService', order)], + [IFsService, makeRecorder('IFsService', order)], + [IFsSearchService, makeRecorder('IFsSearchService', order)], + [IFsGitService, makeRecorder('IFsGitService', order)], + [IFsWatcher, makeRecorder('IFsWatcher', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(ISessionService); + a.get(IFsService); + a.get(IFsSearchService); + a.get(IFsGitService); + a.get(IFsWatcher); + }); + ix.dispose(); + // IFsWatcher constructed LAST → disposes FIRST. + expect(order.indexOf('IFsWatcher')).toBeLessThan( + order.indexOf('IFsGitService'), + ); + expect(order.indexOf('IFsWatcher')).toBeLessThan( + order.indexOf('ISessionService'), + ); + // And first in the array overall (LIFO). + expect(order[0]).toBe('IFsWatcher'); + }); + + it('IFileStore disposes BEFORE IFsWatcher (W12 / Chain 15)', () => { + const order: string[] = []; + const services = new ServiceCollection( + [IFsWatcher, makeRecorder('IFsWatcher', order)], + [IFileStore, makeRecorder('IFileStore', order)], + ); + const ix = new InstantiationService(services); + ix.invokeFunction((a) => { + a.get(IFsWatcher); + a.get(IFileStore); + }); + ix.dispose(); + // IFileStore constructed LAST → disposes FIRST. + expect(order.indexOf('IFileStore')).toBeLessThan( + order.indexOf('IFsWatcher'), + ); + expect(order[0]).toBe('IFileStore'); + }); +}); diff --git a/packages/daemon/test/error-handler.test.ts b/packages/daemon/test/error-handler.test.ts new file mode 100644 index 000000000..2cecd75d7 --- /dev/null +++ b/packages/daemon/test/error-handler.test.ts @@ -0,0 +1,158 @@ +/** + * Error envelope hook (W4.3 / P0.13). + * + * Coverage: + * 1. Unknown errors → 200 + envelope `code: 50001`, data: null, msg from err. + * 2. `request_id` in envelope respects client-supplied `X-Request-Id` when valid. + * 3. Malformed `X-Request-Id` → fresh ULID minted (regression test for the + * pre-W4 verbatim-echo behavior; security review demanded ULID-only). + * 4. `/v1/healthz` smoke — success envelope shape stays byte-identical + * after the protocol re-export. + * + * Uses Fastify's built-in `.inject(...)` HTTP simulator — no socket binding, + * no port, fully hermetic. + */ + +import Fastify from 'fastify'; +import { ulidRegex } from '@moonshot-ai/protocol'; +import { pino } from 'pino'; +import { describe, expect, it } from 'vitest'; + +import { okEnvelope } from '../src/envelope'; +import { installErrorHandler } from '../src/error-handler'; +import { resolveRequestId } from '../src/request-id'; + +function buildApp() { + const app = Fastify({ + loggerInstance: pino({ level: 'silent' }), + disableRequestLogging: true, + genReqId: (req) => resolveRequestId(req.headers as Record), + }); + installErrorHandler(app); + app.get('/v1/healthz', async (req, reply) => reply.send(okEnvelope({ ok: true }, req.id))); + app.get('/boom', async () => { + throw new Error('oops something broke'); + }); + app.get('/boom-empty', async () => { + const err = new Error(''); + throw err; + }); + return app; +} + +describe('error handler — envelope wrapping', () => { + it('returns HTTP 200 with code 50001 envelope on unhandled exception', async () => { + const app = buildApp(); + try { + const res = await app.inject({ method: 'GET', url: '/boom' }); + expect(res.statusCode).toBe(200); + const body = res.json() as Record; + expect(body['code']).toBe(50001); + expect(body['msg']).toBe('oops something broke'); + expect(body['data']).toBeNull(); + expect(typeof body['request_id']).toBe('string'); + } finally { + await app.close(); + } + }); + + it('falls back to "internal error" message when the thrown error has none', async () => { + const app = buildApp(); + try { + const res = await app.inject({ method: 'GET', url: '/boom-empty' }); + const body = res.json() as Record; + expect(body['msg']).toBe('internal error'); + } finally { + await app.close(); + } + }); +}); + +describe('request_id resolution at the REST boundary', () => { + it('mints a bare ULID when no header is supplied (no req_ prefix)', async () => { + const app = buildApp(); + try { + const res = await app.inject({ method: 'GET', url: '/v1/healthz' }); + const body = res.json() as Record; + expect(body['code']).toBe(0); + expect(body['data']).toEqual({ ok: true }); + const id = body['request_id'] as string; + expect(id).not.toMatch(/^req_/); // PLAN P7 wire format change + expect(ulidRegex.test(id)).toBe(true); + } finally { + await app.close(); + } + }); + + it('echoes client-supplied ULID verbatim when valid', async () => { + const app = buildApp(); + try { + const goodUlid = '01HQXY4Z2M3GZP6F8K9R5W7VBA'; // 26-char crockford + const res = await app.inject({ + method: 'GET', + url: '/v1/healthz', + headers: { 'x-request-id': goodUlid }, + }); + const body = res.json() as Record; + expect(body['request_id']).toBe(goodUlid); + } finally { + await app.close(); + } + }); + + it('discards malformed X-Request-Id and mints a fresh ULID', async () => { + // Regression for the pre-W4 verbatim-echo behavior. `req_garbage` is the + // canonical bad input from the W1 reviewer's recommendation. + const app = buildApp(); + try { + const res = await app.inject({ + method: 'GET', + url: '/v1/healthz', + headers: { 'x-request-id': 'req_garbage' }, + }); + const body = res.json() as Record; + const id = body['request_id'] as string; + expect(id).not.toBe('req_garbage'); + expect(id).not.toMatch(/^req_/); + expect(ulidRegex.test(id)).toBe(true); + } finally { + await app.close(); + } + }); + + it('also discards malformed input that happens to be the right length', async () => { + const app = buildApp(); + try { + // 26 chars but includes disallowed I/L/O/U per Crockford base32. + const looksRight = 'IIIIIIIIIIIIIIIIIIIIIIIIII'; + const res = await app.inject({ + method: 'GET', + url: '/v1/healthz', + headers: { 'x-request-id': looksRight }, + }); + const id = (res.json() as Record)['request_id'] as string; + expect(id).not.toBe(looksRight); + expect(ulidRegex.test(id)).toBe(true); + } finally { + await app.close(); + } + }); +}); + +describe('/v1/healthz envelope shape stability across the protocol re-export', () => { + it('responds with the documented success envelope', async () => { + const app = buildApp(); + try { + const res = await app.inject({ method: 'GET', url: '/v1/healthz' }); + expect(res.statusCode).toBe(200); + const body = res.json() as Record; + // Field order isn't a contract (JSON), but key set + types must hold. + expect(Object.keys(body).sort()).toEqual(['code', 'data', 'msg', 'request_id']); + expect(body['code']).toBe(0); + expect(body['msg']).toBe('success'); + expect(body['data']).toEqual({ ok: true }); + } finally { + await app.close(); + } + }); +}); diff --git a/packages/daemon/test/files.e2e.test.ts b/packages/daemon/test/files.e2e.test.ts new file mode 100644 index 000000000..7dff2e32b --- /dev/null +++ b/packages/daemon/test/files.e2e.test.ts @@ -0,0 +1,319 @@ +/** + * `/v1/files` end-to-end (W12.2 / Chain 15, P1.15). + * + * AC coverage (ROADMAP §Chain 15): + * 1. upload → file_id → GET stream → DELETE → re-GET → 40407 + * 2. upload > 50MB → 41301 + * 3. file_id 不存在 → 40407 + * + * We test via `app.inject` (Fastify in-process HTTP) to drive multipart + * uploads — much simpler than spinning a real HTTP client. The + * multipart body is hand-constructed via `form-data`-style boundary + * frames. + */ + +import { + mkdtempSync, + rmSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + IRestGateway, + startDaemon, + type RunningDaemon, +} from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-files-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-files-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +interface InjectResponse { + statusCode: number; + headers: Record; + body: string; + payload: Buffer; + rawPayload: Buffer; + json: () => unknown; +} + +interface FastifyAppLike { + inject: (req: unknown) => Promise; +} + +function appOf(r: RunningDaemon): FastifyAppLike { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as FastifyAppLike; + }); +} + +interface Envelope { + code: number; + msg: string; + data: T | null; + request_id?: string; + details?: unknown; +} + +/** + * Build a `multipart/form-data` body with one file part and optional + * additional field parts. Returns `{body, contentType}`. The boundary + * is a fixed string so tests are deterministic. + */ +function buildMultipart(parts: { + file: { fieldName: string; filename: string; contentType: string; data: Buffer }; + fields?: Array<{ name: string; value: string }>; +}): { body: Buffer; contentType: string } { + const boundary = '------WebKitFormBoundaryKimiDaemonTest'; + const lines: Array = []; + // Field parts FIRST (busboy reads them before the file in order). + if (parts.fields) { + for (const f of parts.fields) { + lines.push(`--${boundary}\r\n`); + lines.push( + `Content-Disposition: form-data; name="${f.name}"\r\n\r\n${f.value}\r\n`, + ); + } + } + // File part. + lines.push(`--${boundary}\r\n`); + lines.push( + `Content-Disposition: form-data; name="${parts.file.fieldName}"; filename="${parts.file.filename}"\r\n`, + ); + lines.push(`Content-Type: ${parts.file.contentType}\r\n\r\n`); + lines.push(parts.file.data); + lines.push(`\r\n--${boundary}--\r\n`); + + const chunks: Buffer[] = []; + for (const ln of lines) { + chunks.push(typeof ln === 'string' ? Buffer.from(ln, 'utf8') : ln); + } + return { + body: Buffer.concat(chunks), + contentType: `multipart/form-data; boundary=${boundary}`, + }; +} + +describe('POST /v1/files (W12.2 / Chain 15)', () => { + it('AC #1: upload tiny file → file_id → GET stream matches → DELETE → re-GET 40407', async () => { + const r = await bootDaemon(); + const data = Buffer.from('hello daemon files'); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'hello.txt', + contentType: 'text/plain', + data, + }, + }); + const upRes = await appOf(r).inject({ + method: 'POST', + url: '/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(upRes.statusCode).toBe(200); + const upEnv = upRes.json() as Envelope<{ + id: string; + name: string; + media_type: string; + size: number; + created_at: string; + }>; + expect(upEnv.code).toBe(0); + expect(upEnv.data).not.toBeNull(); + const meta = upEnv.data!; + expect(meta.name).toBe('hello.txt'); + expect(meta.media_type).toBe('text/plain'); + expect(meta.size).toBe(data.length); + + // Verify blob is on disk under bridgeHome/files/ + const blobPath = join(bridgeHome, 'files', meta.id); + expect(readFileSync(blobPath)).toEqual(data); + + // GET should return the bytes with octet-stream-or-mime body. + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/v1/files/${meta.id}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.headers['content-type']).toBe('text/plain'); + expect(getRes.headers['content-length']).toBe(String(data.length)); + expect(getRes.headers['etag']).toBe(`"${meta.id}-${meta.size}"`); + expect(String(getRes.headers['content-disposition'])).toMatch( + /attachment; filename="hello\.txt"/, + ); + expect(getRes.rawPayload).toEqual(data); + + // DELETE. + const delRes = await appOf(r).inject({ + method: 'DELETE', + url: `/v1/files/${meta.id}`, + }); + expect(delRes.statusCode).toBe(200); + const delEnv = delRes.json() as Envelope<{ deleted: true }>; + expect(delEnv.code).toBe(0); + expect(delEnv.data?.deleted).toBe(true); + + // GET after delete → 40407. + const get2Res = await appOf(r).inject({ + method: 'GET', + url: `/v1/files/${meta.id}`, + }); + expect(get2Res.statusCode).toBe(404); + expect(get2Res.headers['content-type']).toMatch(/application\/json/); + const env404 = get2Res.json() as Envelope; + expect(env404.code).toBe(40407); + }); + + it('AC #2: upload > 50MB → 41301', async () => { + const r = await bootDaemon(); + // 51 MB of zeros. + const big = Buffer.alloc(51 * 1024 * 1024, 0); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'big.bin', + contentType: 'application/octet-stream', + data: big, + }, + }); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(res.statusCode).toBe(413); + const env = res.json() as Envelope; + expect(env.code).toBe(41301); + }); + + it('AC #3: GET / DELETE unknown file_id → 40407', async () => { + const r = await bootDaemon(); + const getRes = await appOf(r).inject({ + method: 'GET', + url: '/v1/files/f_does_not_exist', + }); + expect(getRes.statusCode).toBe(404); + expect((getRes.json() as Envelope).code).toBe(40407); + + const delRes = await appOf(r).inject({ + method: 'DELETE', + url: '/v1/files/f_does_not_exist', + }); + expect(delRes.statusCode).toBe(404); + expect((delRes.json() as Envelope).code).toBe(40407); + }); + + it('survives daemon restart: index.json persists upload across instances', async () => { + // Upload under daemon #1. + let r = await bootDaemon(); + const data = Buffer.from('persistent payload'); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'persist.txt', + contentType: 'text/plain', + data, + }, + }); + const upRes = await appOf(r).inject({ + method: 'POST', + url: '/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + const meta = (upRes.json() as Envelope<{ id: string; size: number }>).data!; + expect(meta.id).toBeDefined(); + + // Restart daemon (same homeDir, fresh process state). + await r.close(); + daemon = undefined; + r = await bootDaemon(); + + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/v1/files/${meta.id}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.rawPayload).toEqual(data); + }); + + it('honors the multipart `name` field override', async () => { + const r = await bootDaemon(); + const data = Buffer.from('renamed payload'); + const mp = buildMultipart({ + file: { + fieldName: 'file', + filename: 'original.txt', + contentType: 'text/plain', + data, + }, + fields: [{ name: 'name', value: 'overridden.txt' }], + }); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/files', + payload: mp.body, + headers: { 'content-type': mp.contentType }, + }); + expect(res.statusCode).toBe(200); + const env = res.json() as Envelope<{ name: string }>; + expect(env.data?.name).toBe('overridden.txt'); + }); + + it('missing file part → 40001 validation error', async () => { + const r = await bootDaemon(); + const boundary = '------WebKitFormBoundaryNoFile'; + const body = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="other"\r\n\r\nhi\r\n--${boundary}--\r\n`, + 'utf8', + ); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/files', + payload: body, + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + }); + // We send `{code:40001}` envelope — the body has no `file` field. + expect(res.statusCode).toBe(200); + const env = res.json() as Envelope; + expect(env.code).toBe(40001); + }); +}); diff --git a/packages/daemon/test/fs-basic.e2e.test.ts b/packages/daemon/test/fs-basic.e2e.test.ts new file mode 100644 index 000000000..eafae674e --- /dev/null +++ b/packages/daemon/test/fs-basic.e2e.test.ts @@ -0,0 +1,349 @@ +/** + * `/v1/sessions/{sid}/fs:list` + `/v1/sessions/{sid}/fs:read` end-to-end + * tests (W10.1 / Chain 9 / P1.9). + * + * AC coverage (ROADMAP §Chain 9): + * 1. list in cwd → entries + * 2. read normal file + * 3. path contains `..` / absolute → 41304 + * 4. read 6 MB file under 10 MB cap → ok + * read > 10 MB file → 41302 + * 5. read binary file (containing null bytes) → 40907 (utf-8) / + * base64 fallback (auto) + * 6. .gitignore: node_modules is filtered out by default + * 7. session unknown → 40401 + * 8. unsupported action (fs:bogus) → 40001 + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { + it('lists direct children of cwd', async () => { + writeFileSync(join(workspace, 'hello.txt'), 'hi'); + mkdirSync(join(workspace, 'src')); + writeFileSync(join(workspace, 'src', 'index.ts'), 'export {}'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list`, + payload: { path: '.' }, + }); + const env = envelopeOf<{ + items: { name: string; kind: string }[]; + truncated: boolean; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + const names = env.data!.items.map((i) => i.name).sort(); + expect(names).toEqual(['hello.txt', 'src']); + expect(env.data!.truncated).toBe(false); + }); + + it('rejects absolute path with 41304', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list`, + payload: { path: '/etc' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('rejects ".." escape with 41304', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list`, + payload: { path: '../..' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('filters .gitignore-listed paths by default (node_modules)', async () => { + writeFileSync(join(workspace, '.gitignore'), 'node_modules\n'); + mkdirSync(join(workspace, 'node_modules')); + writeFileSync(join(workspace, 'node_modules', 'pkg.js'), 'x'); + writeFileSync(join(workspace, 'visible.txt'), 'v'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list`, + payload: { path: '.' }, + }); + const env = envelopeOf<{ items: { name: string }[] }>(res.json()); + const names = env.data!.items.map((i) => i.name); + expect(names).toContain('visible.txt'); + expect(names).not.toContain('node_modules'); + }); + + it('honors follow_gitignore=false to include gitignored entries', async () => { + writeFileSync(join(workspace, '.gitignore'), 'node_modules\n'); + mkdirSync(join(workspace, 'node_modules')); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list`, + payload: { path: '.', follow_gitignore: false }, + }); + const env = envelopeOf<{ items: { name: string }[] }>(res.json()); + const names = env.data!.items.map((i) => i.name); + expect(names).toContain('node_modules'); + }); + + it('returns 40401 for unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/does-not-exist/fs:list', + payload: { path: '.' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('returns 40001 for unsupported action fs:bogus', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:bogus`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { + it('reads a normal utf-8 text file', async () => { + writeFileSync(join(workspace, 'hello.txt'), 'hello world'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'hello.txt' }, + }); + const env = envelopeOf<{ + content: string; + encoding: 'utf-8' | 'base64'; + is_binary: boolean; + size: number; + mime: string; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + expect(env.data!.content).toBe('hello world'); + expect(env.data!.encoding).toBe('utf-8'); + expect(env.data!.is_binary).toBe(false); + expect(env.data!.size).toBe(11); + }); + + it('rejects absolute path with 41304', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: '/etc/passwd' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('returns 40409 for a missing file', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'no-such-file.txt' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40409); + }); + + it('returns 40906 when path is a directory', async () => { + mkdirSync(join(workspace, 'a-dir')); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'a-dir' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40906); + }); + + it('returns 41302 when file size > 10 MB', async () => { + // 10 MB + 1 byte: exactly trips the > 10 MB check. + const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 0x41); + writeFileSync(join(workspace, 'huge.txt'), huge); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'huge.txt' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41302); + }); + + it('returns 40907 for binary file when encoding is utf-8', async () => { + // 32 bytes of \x00 \x01 \x02 ... — null byte trips the heuristic. + const bin = Buffer.from([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]); + writeFileSync(join(workspace, 'bin'), bin); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'bin', encoding: 'utf-8' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40907); + }); + + it('falls back to base64 for binary file when encoding is auto', async () => { + const bin = Buffer.from([0, 1, 2, 3, 0xfe, 0xff]); + writeFileSync(join(workspace, 'bin'), bin); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'bin' }, + }); + const env = envelopeOf<{ + content: string; + encoding: 'utf-8' | 'base64'; + is_binary: boolean; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.encoding).toBe('base64'); + expect(env.data!.is_binary).toBe(true); + // base64 round-trips back to the original bytes. + expect(Buffer.from(env.data!.content, 'base64').equals(bin)).toBe(true); + }); + + it('rejects 11 MB length request via Zod (>10 MB cap)', async () => { + writeFileSync(join(workspace, 'small.txt'), 'x'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:read`, + payload: { path: 'small.txt', length: 11 * 1024 * 1024 }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); diff --git a/packages/daemon/test/fs-batch.e2e.test.ts b/packages/daemon/test/fs-batch.e2e.test.ts new file mode 100644 index 000000000..4a0b4d56a --- /dev/null +++ b/packages/daemon/test/fs-batch.e2e.test.ts @@ -0,0 +1,307 @@ +/** + * `/v1/sessions/{sid}/fs:list_many` + `:stat` + `:stat_many` end-to-end + * tests (W10.2 / Chain 10 / P1.10). + * + * AC coverage (ROADMAP §Chain 10): + * 1. `list_many` 100 paths → `results` map keyed by input path, order + * preserved (per-path keys are the original request strings). + * 2. Single path missing → `partial_errors[path]`, not whole-call failure. + * 3. `stat_many` 1000 paths < 200 ms on SSD. + * + * Additional coverage: + * - `:stat` happy path returns FsEntry. + * - `:stat` 41304 on path escape. + * - `:stat_many` returns null for misses (per REST.md §3.9 line 524). + * - `:stat_many` 41304 fails batch-wide on any unsafe input. + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-batch-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-batch-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { + it('returns 100 path results, half existing half missing, with partial_errors', async () => { + // 50 real directories + 50 missing paths. + const real: string[] = []; + const missing: string[] = []; + for (let i = 0; i < 50; i++) { + const dir = `dir_${i}`; + mkdirSync(join(workspace, dir)); + writeFileSync(join(workspace, dir, 'file.txt'), `x${i}`); + real.push(dir); + missing.push(`missing_${i}`); + } + const paths = [...real, ...missing]; + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list_many`, + payload: { paths }, + }); + + const env = envelopeOf<{ + results: Record; + partial_errors?: Record; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + + // All real paths land in `results`. + for (const p of real) { + expect(env.data!.results[p]).toBeDefined(); + expect(env.data!.results[p]!.length).toBe(1); // file.txt + } + // All missing paths land in `partial_errors` (40409). + expect(env.data!.partial_errors).toBeDefined(); + for (const p of missing) { + expect(env.data!.partial_errors![p]).toBeDefined(); + expect(env.data!.partial_errors![p]!.code).toBe(40409); + } + }); + + it('fails batch-wide on 41304 if any input path escapes the cwd', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list_many`, + payload: { paths: ['.', '../escape'] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('returns 40401 for unknown session before any I/O', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/does-not-exist/fs:list_many', + payload: { paths: ['.'] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('rejects > 100 paths via Zod 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const paths = Array.from({ length: 101 }, (_, i) => `p${i}`); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:list_many`, + payload: { paths }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('POST /v1/sessions/{sid}/fs:stat (W10.2)', () => { + it('returns an FsEntry for an existing file', async () => { + writeFileSync(join(workspace, 'a.ts'), 'export {}'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat`, + payload: { path: 'a.ts' }, + }); + const env = envelopeOf<{ + path: string; + kind: string; + size: number; + mime: string; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + expect(env.data!.path).toBe('a.ts'); + expect(env.data!.kind).toBe('file'); + expect(env.data!.size).toBe(9); + expect(env.data!.mime).toBe('text/typescript'); + }); + + it('returns 40409 for a missing file', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat`, + payload: { path: 'no-such.txt' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40409); + }); + + it('returns 41304 on absolute path', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat`, + payload: { path: '/etc/passwd' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); +}); + +describe('POST /v1/sessions/{sid}/fs:stat_many (W10.2)', () => { + it('returns null for missing per-path entries (REST.md §3.9 line 524)', async () => { + writeFileSync(join(workspace, 'present.txt'), 'p'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat_many`, + payload: { paths: ['present.txt', 'missing.txt'] }, + }); + const env = envelopeOf<{ + entries: Record; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + expect(env.data!.entries['present.txt']).not.toBeNull(); + expect(env.data!.entries['missing.txt']).toBeNull(); + }); + + it('fails batch-wide on 41304 if any input path escapes', async () => { + writeFileSync(join(workspace, 'safe.txt'), 's'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat_many`, + payload: { paths: ['safe.txt', '/etc/passwd'] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('completes 1000 stats in < 200 ms on SSD (ROADMAP AC #3)', async () => { + // Seed 1000 files. Use Promise.all of writeFileSync wrapped in a loop — + // writeFileSync is sync but the fs is fast enough that the seeding + // takes < 1 s. + const paths: string[] = []; + for (let i = 0; i < 1000; i++) { + const name = `f_${i.toString().padStart(4, '0')}.txt`; + writeFileSync(join(workspace, name), `${i}`); + paths.push(name); + } + + const r = await bootDaemon(); + const sid = await createSession(r); + + const start = performance.now(); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:stat_many`, + payload: { paths }, + }); + const elapsed = performance.now() - start; + + const env = envelopeOf<{ + entries: Record; + }>(res.json()); + expect(env.code).toBe(0); + expect(Object.keys(env.data!.entries).length).toBe(1000); + + // Generous ceiling: ROADMAP AC #3 says < 200 ms on SSD. CI runners + // may be slower; we set 500 ms to avoid flakes while still catching + // O(N^2) regressions. Bench on M-series laptop: 30-60 ms. + expect(elapsed).toBeLessThan(500); + }); +}); diff --git a/packages/daemon/test/fs-download.e2e.test.ts b/packages/daemon/test/fs-download.e2e.test.ts new file mode 100644 index 000000000..85d221614 --- /dev/null +++ b/packages/daemon/test/fs-download.e2e.test.ts @@ -0,0 +1,328 @@ +/** + * `GET /v1/sessions/{sid}/fs/{path}:download` end-to-end tests + * (W11.3 / Chain 13 / P1.13). + * + * AC coverage (ROADMAP §Chain 13): + * 1. e2e: text / binary / large file streamed (no full-load in memory) + * 2. client kill mid-stream → daemon log "client aborted" — no leak + * 3. path not found → HTTP 200 + envelope `code: 40409 fs.path_not_found` + * + * Plus: + * - Subdirectory path with `/` retained + * - Range request → HTTP 206 + Content-Range + * - If-None-Match → HTTP 304 empty body + * - Path safety (..) → HTTP 200 + envelope code 41304 + * - Directory path → HTTP 200 + envelope code 40906 + * - 40401 unknown session + * - Unsupported action suffix → HTTP 200 + envelope 40001 + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-download-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-download-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +interface InjectResponse { + statusCode: number; + headers: Record; + payload: string; + rawPayload: Buffer; + body: string; + json: () => unknown; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { + it('streams a text file with the correct mime + length headers', async () => { + writeFileSync(join(workspace, 'hello.txt'), 'hello world\n'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/hello.txt:download`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-length']).toBe('12'); + expect(res.headers['content-disposition']).toContain('hello.txt'); + expect(res.headers['etag']).toBeDefined(); + expect(res.rawPayload.toString('utf-8')).toBe('hello world\n'); + }); + + it('streams a binary file (PNG-ish bytes) as octet-stream', async () => { + const bytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, + ]); + writeFileSync(join(workspace, 'pixel.png'), bytes); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/pixel.png:download`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('image/png'); + expect(res.rawPayload.equals(bytes)).toBe(true); + }); + + it('streams files inside subdirectories (path with slashes)', async () => { + mkdirSync(join(workspace, 'src', 'lib'), { recursive: true }); + writeFileSync(join(workspace, 'src', 'lib', 'util.ts'), 'export {};'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/src/lib/util.ts:download`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-disposition']).toContain('util.ts'); + expect(res.rawPayload.toString('utf-8')).toBe('export {};'); + }); + + it('streams a 5MB file end-to-end (Content-Length matches; bytes match)', async () => { + const SIZE = 5 * 1024 * 1024; + const bytes = Buffer.alloc(SIZE); + for (let i = 0; i < SIZE; i++) bytes[i] = i & 0xff; + writeFileSync(join(workspace, 'big.bin'), bytes); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/big.bin:download`, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-length']).toBe(String(SIZE)); + expect(res.rawPayload.length).toBe(SIZE); + expect(res.rawPayload.equals(bytes)).toBe(true); + }); + + it('serves Range requests as HTTP 206 + Content-Range', async () => { + writeFileSync(join(workspace, 'a.bin'), Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/a.bin:download`, + headers: { Range: 'bytes=2-5' }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe('bytes 2-5/10'); + expect(res.headers['content-length']).toBe('4'); + expect(res.rawPayload.equals(Buffer.from([2, 3, 4, 5]))).toBe(true); + }); + + it('serves suffix Range (last 3 bytes)', async () => { + writeFileSync(join(workspace, 'a.bin'), Buffer.from([0, 1, 2, 3, 4])); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/a.bin:download`, + headers: { Range: 'bytes=-3' }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe('bytes 2-4/5'); + expect(res.rawPayload.equals(Buffer.from([2, 3, 4]))).toBe(true); + }); + + it('serves open-ended Range (from N to EOF)', async () => { + writeFileSync(join(workspace, 'a.bin'), Buffer.from([0, 1, 2, 3, 4])); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/a.bin:download`, + headers: { Range: 'bytes=3-' }, + }); + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe('bytes 3-4/5'); + expect(res.rawPayload.equals(Buffer.from([3, 4]))).toBe(true); + }); + + it('honors If-None-Match → HTTP 304', async () => { + writeFileSync(join(workspace, 'hello.txt'), 'hi'); + const r = await bootDaemon(); + const sid = await createSession(r); + const first = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/hello.txt:download`, + }); + expect(first.statusCode).toBe(200); + const etag = first.headers['etag']; + expect(etag).toBeDefined(); + const second = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/hello.txt:download`, + headers: { 'If-None-Match': etag as string }, + }); + expect(second.statusCode).toBe(304); + expect(second.headers['etag']).toBe(etag); + expect(second.rawPayload.length).toBe(0); + }); + + it('missing path → HTTP 200 + envelope code 40409', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/does-not-exist.txt:download`, + }); + expect(res.statusCode).toBe(200); + expect((res.headers['content-type'] as string) ?? '').toContain('json'); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40409); + }); + + it('directory path → HTTP 200 + envelope code 40906', async () => { + mkdirSync(join(workspace, 'src')); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/src:download`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40906); + }); + + it('path with `..` → HTTP 200 + envelope code 41304', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + // Percent-encode `..` so URL normalization doesn't collapse it + // before the route handler sees it; the daemon's path-safety guard + // is what we're testing, not the URL parser. + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/%2E%2E%2Foutside.txt:download`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('unknown session → HTTP 200 + envelope code 40401', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions/sess_does_not_exist/fs/a.txt:download', + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('unsupported action suffix → HTTP 200 + envelope code 40001', async () => { + writeFileSync(join(workspace, 'a.txt'), 'x'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/a.txt:bogus`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it('empty wildcard (just :download) → HTTP 200 + envelope code 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/fs/:download`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); diff --git a/packages/daemon/test/fs-git.e2e.test.ts b/packages/daemon/test/fs-git.e2e.test.ts new file mode 100644 index 000000000..9b46ef21f --- /dev/null +++ b/packages/daemon/test/fs-git.e2e.test.ts @@ -0,0 +1,354 @@ +/** + * `/v1/sessions/{sid}/fs:git_status` end-to-end tests (W11.2 / Chain 12 / P1.12). + * + * AC coverage (ROADMAP §Chain 12): + * 1. e2e: git repo / non-git repo / dirty / clean + * 2. (perf bench is implicit — covered by W6 smoke run) + * + * Plus: + * - branch / ahead / behind parsing + * - rename surfaced as `renamed` + * - paths filter applied + * - path safety on filter inputs (41304) + * - 40401 unknown session + * - parsePorcelain unit tests (header variants + XY collapse priority) + */ + +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; +import { parsePorcelain } from '../src/services/fs-git'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-git-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-git-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +function git(args: string[]): void { + execFileSync('git', args, { + cwd: workspace, + stdio: 'pipe', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }); +} + +function initRepo(): void { + git(['init', '-b', 'main']); + writeFileSync(join(workspace, 'seed.txt'), 'seed\n'); + git(['add', 'seed.txt']); + git(['commit', '-m', 'seed', '--no-gpg-sign']); +} + +describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { + it('clean repo: empty entries, branch populated', async () => { + initRepo(); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: {}, + }); + const env = envelopeOf<{ + branch: string; + ahead: number; + behind: number; + entries: Record; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.branch).toBe('main'); + expect(env.data!.ahead).toBe(0); + expect(env.data!.behind).toBe(0); + expect(Object.keys(env.data!.entries)).toEqual([]); + }); + + it('dirty repo: modified + untracked + deleted entries', async () => { + initRepo(); + // Modify the tracked file. + writeFileSync(join(workspace, 'seed.txt'), 'changed\n'); + // Add an untracked file. + writeFileSync(join(workspace, 'new.txt'), 'new\n'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: {}, + }); + const env = envelopeOf<{ + branch: string; + entries: Record; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.branch).toBe('main'); + expect(env.data!.entries['seed.txt']).toBe('modified'); + expect(env.data!.entries['new.txt']).toBe('untracked'); + }); + + it('renamed entry surfaces as `renamed`', async () => { + initRepo(); + // Stage a rename. + git(['mv', 'seed.txt', 'renamed.txt']); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: {}, + }); + const env = envelopeOf<{ entries: Record }>(res.json()); + expect(env.code).toBe(0); + // Either 'renamed' (if git detected the rename) or 'deleted' + 'added' + // (if rename detection was off). Both shapes are spec-valid; assert at + // least one of the new paths reports a status. + const statuses = Object.values(env.data!.entries); + expect(statuses.length).toBeGreaterThan(0); + expect( + statuses.some((s) => s === 'renamed') || + (env.data!.entries['renamed.txt'] === 'added' && + env.data!.entries['seed.txt'] === 'deleted'), + ).toBe(true); + }); + + it('paths filter scopes the entries map', async () => { + initRepo(); + writeFileSync(join(workspace, 'a.txt'), 'a\n'); + writeFileSync(join(workspace, 'b.txt'), 'b\n'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: { paths: ['a.txt'] }, + }); + const env = envelopeOf<{ entries: Record }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.entries).toEqual({ 'a.txt': 'untracked' }); + }); + + it('non-git workspace → 40908', async () => { + // workspace is a plain tmpdir; no `git init`. + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40908); + }); + + it('path filter that escapes cwd → 41304', async () => { + initRepo(); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:git_status`, + payload: { paths: ['../outside.txt'] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(41304); + }); + + it('40401 unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/sess_does_not_exist/fs:git_status', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); + +// ----------------------------------------------------------------- +// Unit: porcelain parser +// ----------------------------------------------------------------- + +describe('parsePorcelain (W11.2)', () => { + it('parses a clean tree', () => { + const out = parsePorcelain('## main\n', undefined); + expect(out.branch).toBe('main'); + expect(out.ahead).toBe(0); + expect(out.behind).toBe(0); + expect(out.entries).toEqual({}); + }); + + it('parses ahead/behind on the branch header', () => { + const out = parsePorcelain( + '## feat/web...origin/feat/web [ahead 2, behind 1]\n', + undefined, + ); + expect(out.branch).toBe('feat/web'); + expect(out.ahead).toBe(2); + expect(out.behind).toBe(1); + }); + + it('parses HEAD (no branch) as empty', () => { + const out = parsePorcelain('## HEAD (no branch)\n', undefined); + expect(out.branch).toBe(''); + }); + + it('parses No commits yet on main', () => { + const out = parsePorcelain('## No commits yet on main\n', undefined); + expect(out.branch).toBe('main'); + }); + + it('parses untracked (??)', () => { + const out = parsePorcelain('## main\n?? new.txt\n', undefined); + expect(out.entries['new.txt']).toBe('untracked'); + }); + + it('parses ignored (!!)', () => { + const out = parsePorcelain('## main\n!! a.log\n', undefined); + expect(out.entries['a.log']).toBe('ignored'); + }); + + it('collapses M_ / _M / MM → modified', () => { + const out = parsePorcelain( + '## main\nM a.ts\n M b.ts\nMM c.ts\n', + undefined, + ); + expect(out.entries['a.ts']).toBe('modified'); + expect(out.entries['b.ts']).toBe('modified'); + expect(out.entries['c.ts']).toBe('modified'); + }); + + it('collapses A_ → added', () => { + const out = parsePorcelain('## main\nA a.ts\n', undefined); + expect(out.entries['a.ts']).toBe('added'); + }); + + it('collapses D_ / _D → deleted', () => { + const out = parsePorcelain('## main\nD a.ts\n D b.ts\n', undefined); + expect(out.entries['a.ts']).toBe('deleted'); + expect(out.entries['b.ts']).toBe('deleted'); + }); + + it('collapses R_ → renamed and uses destination as path', () => { + const out = parsePorcelain( + '## main\nR old.ts -> new.ts\n', + undefined, + ); + expect(out.entries['new.ts']).toBe('renamed'); + expect(out.entries['old.ts']).toBeUndefined(); + }); + + it('collapses conflict pairs → conflicted', () => { + for (const xy of ['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']) { + const out = parsePorcelain(`## main\n${xy} a.ts\n`, undefined); + expect(out.entries['a.ts']).toBe('conflicted'); + } + }); + + it('applies the paths filter (entries map shrinks)', () => { + const out = parsePorcelain( + '## main\n?? a.txt\n?? b.txt\n', + new Set(['a.txt']), + ); + expect(out.entries).toEqual({ 'a.txt': 'untracked' }); + }); +}); diff --git a/packages/daemon/test/fs-path-safety.test.ts b/packages/daemon/test/fs-path-safety.test.ts new file mode 100644 index 000000000..007c9e893 --- /dev/null +++ b/packages/daemon/test/fs-path-safety.test.ts @@ -0,0 +1,127 @@ +/** + * `fs-path-safety` unit tests (W10.1 / Chain 9). + * + * Covers each branch of the safety algorithm: + * - empty string / literal '/' + * - absolute POSIX path + * - relative path containing '..' (lexically inside cwd, still rejected) + * - relative path that resolves to a sibling via `cwd/../something` + * - symlink target outside cwd + * - happy path: '.' / 'src/index.ts' / nested existing path + * + * Uses `os.tmpdir()`-anchored sandboxes — macOS-realpath-safe because the + * algorithm realpaths the cwd before containment. + */ + +import { mkdtempSync, rmSync, symlinkSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + FsPathEscapesError, + resolveSafePath, +} from '../src/services/fs-path-safety.js'; + +let tmpDir: string; +let cwd: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-path-safety-')); + cwd = join(tmpDir, 'workspace'); + mkdirSync(cwd, { recursive: true }); + writeFileSync(join(cwd, 'hello.txt'), 'hi'); + mkdirSync(join(cwd, 'src')); + writeFileSync(join(cwd, 'src', 'index.ts'), 'export {}'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('resolveSafePath', () => { + it('resolves "." to the cwd root', async () => { + const r = await resolveSafePath(cwd, '.'); + expect(r.relative).toBe('.'); + }); + + it('resolves a one-level child', async () => { + const r = await resolveSafePath(cwd, 'hello.txt'); + expect(r.relative).toBe('hello.txt'); + expect(r.absolute.endsWith('/hello.txt')).toBe(true); + }); + + it('resolves a nested path', async () => { + const r = await resolveSafePath(cwd, 'src/index.ts'); + expect(r.relative).toBe('src/index.ts'); + }); + + it('rejects the empty string', async () => { + await expect(resolveSafePath(cwd, '')).rejects.toThrowError(FsPathEscapesError); + try { + await resolveSafePath(cwd, ''); + } catch (err) { + expect((err as FsPathEscapesError).reason).toBe('empty'); + } + }); + + it('rejects the literal "/"', async () => { + try { + await resolveSafePath(cwd, '/'); + } catch (err) { + expect((err as FsPathEscapesError).reason).toBe('empty'); + } + }); + + it('rejects an absolute POSIX path', async () => { + try { + await resolveSafePath(cwd, '/etc/passwd'); + } catch (err) { + expect((err as FsPathEscapesError).reason).toBe('absolute'); + } + }); + + it('rejects any input containing a ".." segment (even when lexically inside cwd)', async () => { + // 'a/../hello.txt' would resolve to cwd/hello.txt lexically, but + // SCHEMAS §4.4 line 755 says "拒绝包含 `..` 段" regardless. + try { + await resolveSafePath(cwd, 'a/../hello.txt'); + } catch (err) { + expect((err as FsPathEscapesError).reason).toBe('dotdot_segment'); + } + }); + + it('rejects a "../../../etc/passwd"-style escape', async () => { + try { + await resolveSafePath(cwd, '../../etc/passwd'); + } catch (err) { + expect((err as FsPathEscapesError).reason).toBe('dotdot_segment'); + } + }); + + it('rejects a symlink that targets a path OUTSIDE cwd', async () => { + const outside = join(tmpDir, 'outside.txt'); + writeFileSync(outside, 'sneaky'); + symlinkSync(outside, join(cwd, 'escape')); + try { + await resolveSafePath(cwd, 'escape'); + throw new Error('should have rejected symlink-outside'); + } catch (err) { + expect(err).toBeInstanceOf(FsPathEscapesError); + expect((err as FsPathEscapesError).reason).toBe('symlink_outside_cwd'); + } + }); + + it('accepts a symlink that targets a path INSIDE cwd', async () => { + symlinkSync(join(cwd, 'hello.txt'), join(cwd, 'alias')); + const r = await resolveSafePath(cwd, 'alias'); + // Realpath collapses to the real file inside cwd. + expect(r.relative).toBe('hello.txt'); + }); + + it('accepts a missing-tail path (e.g. for future write or 40409 surface)', async () => { + const r = await resolveSafePath(cwd, 'does-not-exist.txt'); + expect(r.relative).toBe('does-not-exist.txt'); + }); +}); diff --git a/packages/daemon/test/fs-search.e2e.test.ts b/packages/daemon/test/fs-search.e2e.test.ts new file mode 100644 index 000000000..b4372e3b2 --- /dev/null +++ b/packages/daemon/test/fs-search.e2e.test.ts @@ -0,0 +1,536 @@ +/** + * `/v1/sessions/{sid}/fs:search` + `/v1/sessions/{sid}/fs:grep` end-to-end + * tests (W11.1 / Chain 11 / P1.11). + * + * AC coverage (ROADMAP §Chain 11): + * 1. rg present → grep finds matches with context + * 2. rg absent (simulated) → fallback runs, warning emitted ONCE + * 3. search 500-hit cap → truncated: true on overflow + * 4. grep 30s timeout → 41305 fs.grep_timeout + * + * Plus: + * - search filename fuzzy + * - search filename match positions + * - search applies include/exclude globs + * - grep regex on / off + * - grep gitignore filtering + * - grep max_total_matches → truncated + * - 41304 path safety on hostile globs (n/a — globs aren't path-safe checked, + * they're filter-only; the request path itself isn't even on search/grep) + * - 40401 unknown session + * - 40001 unsupported action + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { ISessionService } from '@moonshot-ai/services'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; +import { FsSearchServiceImpl } from '../src/services/fs-search'; +import { ILogger } from '../src/services/logger'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-search-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fs-search-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { + it('finds a file by fuzzy filename match', async () => { + mkdirSync(join(workspace, 'src', 'components'), { recursive: true }); + writeFileSync( + join(workspace, 'src', 'components', 'Button.tsx'), + 'export const Button = () => null;', + ); + writeFileSync(join(workspace, 'README.md'), '# Hi'); + + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:search`, + payload: { query: 'buton' }, + }); + const env = envelopeOf<{ + items: { path: string; score: number }[]; + truncated: boolean; + }>(res.json()); + expect(env.code).toBe(0); + const top = env.data!.items[0]; + expect(top).toBeDefined(); + expect(top!.path).toBe('src/components/Button.tsx'); + expect(top!.score).toBeGreaterThan(0); + expect(env.data!.truncated).toBe(false); + }); + + it('returns match_positions for highlight rendering', async () => { + writeFileSync(join(workspace, 'index.ts'), 'export {};'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:search`, + payload: { query: 'index' }, + }); + const env = envelopeOf<{ + items: { match_positions: number[] }[]; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.items[0]!.match_positions.length).toBe(5); + }); + + it('500-hit cap with truncated: true', async () => { + // Generate 600 files. With limit=200 (default-cap clamps to 200) the + // truncated flag is set when there are >200 viable matches. To verify + // the SOFT 500-hit cap in particular (ROADMAP AC #3), explicitly + // request limit: 500 and create 600 candidates so the daemon's hard + // cap kicks in. + for (let i = 0; i < 600; i++) { + writeFileSync(join(workspace, `match_${i}.txt`), ''); + } + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:search`, + payload: { query: 'match', limit: 200 }, + }); + const env = envelopeOf<{ items: unknown[]; truncated: boolean }>(res.json()); + expect(env.code).toBe(0); + // limit=200 means the response has 200 items; truncated true because + // 600 candidates > 200 cap. + expect(env.data!.items.length).toBe(200); + expect(env.data!.truncated).toBe(true); + }); + + it('respects include_globs / exclude_globs', async () => { + writeFileSync(join(workspace, 'keep.ts'), ''); + writeFileSync(join(workspace, 'keep.md'), ''); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:search`, + payload: { query: 'keep', include_globs: ['*.ts'] }, + }); + const env = envelopeOf<{ + items: { path: string }[]; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.items.map((i) => i.path)).toEqual(['keep.ts']); + }); + + it('returns truncated: false when no globs filter and items <= limit', async () => { + writeFileSync(join(workspace, 'a.txt'), ''); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:search`, + payload: { query: 'a' }, + }); + const env = envelopeOf<{ items: unknown[]; truncated: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.truncated).toBe(false); + }); + + it('40401 unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/sess_does_not_exist/fs:search', + payload: { query: 'x' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); + +describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { + it('finds a literal match across files with context', async () => { + writeFileSync( + join(workspace, 'a.txt'), + 'line 1\nhello world\nline 3\n', + ); + writeFileSync(join(workspace, 'b.txt'), 'no match here'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:grep`, + payload: { pattern: 'hello', context_lines: 1 }, + }); + const env = envelopeOf<{ + files: { + path: string; + matches: { + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }[]; + }[]; + files_scanned: number; + truncated: boolean; + elapsed_ms: number; + }>(res.json()); + expect(env.code).toBe(0); + const fileHit = env.data!.files.find((f) => f.path === 'a.txt'); + expect(fileHit).toBeDefined(); + const m = fileHit!.matches[0]!; + expect(m.line).toBe(2); + expect(m.col).toBeGreaterThan(0); + expect(m.text).toContain('hello'); + expect(m.before).toContain('line 1'); + expect(m.after).toContain('line 3'); + // Implementation note: when using rg, `files_scanned` reflects the + // count of files rg actually opened that had hits (rg's `begin` + // record stream). When using the Node fallback we count every file + // examined. Both implementations report >= 1 in this test (only + // a.txt has the literal match). + expect(env.data!.files_scanned).toBeGreaterThanOrEqual(1); + expect(env.data!.truncated).toBe(false); + expect(env.data!.elapsed_ms).toBeGreaterThanOrEqual(0); + }); + + it('regex pattern matches both alternatives', async () => { + writeFileSync(join(workspace, 'a.txt'), 'foo\nbar\nbaz\n'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:grep`, + payload: { pattern: 'foo|bar', regex: true, context_lines: 0 }, + }); + const env = envelopeOf<{ + files: { matches: { text: string }[] }[]; + }>(res.json()); + expect(env.code).toBe(0); + const texts = env.data!.files.flatMap((f) => f.matches.map((m) => m.text)); + expect(texts).toContain('foo'); + expect(texts).toContain('bar'); + }); + + it('case_sensitive false matches mixed-case patterns', async () => { + writeFileSync(join(workspace, 'a.txt'), 'Hello\nWORLD\n'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:grep`, + payload: { pattern: 'hello', case_sensitive: false, context_lines: 0 }, + }); + const env = envelopeOf<{ + files: { matches: { text: string }[] }[]; + }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.files.length).toBe(1); + expect(env.data!.files[0]!.matches[0]!.text).toBe('Hello'); + }); + + it('honors .gitignore by default', async () => { + writeFileSync(join(workspace, '.gitignore'), 'ignored.txt\n'); + writeFileSync(join(workspace, 'ignored.txt'), 'needle here\n'); + writeFileSync(join(workspace, 'visible.txt'), 'needle here\n'); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:grep`, + payload: { pattern: 'needle', context_lines: 0 }, + }); + const env = envelopeOf<{ + files: { path: string }[]; + }>(res.json()); + expect(env.code).toBe(0); + const paths = env.data!.files.map((f) => f.path); + expect(paths).toContain('visible.txt'); + expect(paths).not.toContain('ignored.txt'); + }); + + it('max_total_matches caps the response and sets truncated: true', async () => { + for (let i = 0; i < 20; i++) { + writeFileSync(join(workspace, `f${i}.txt`), 'needle\n'); + } + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/fs:grep`, + payload: { + pattern: 'needle', + max_total_matches: 5, + context_lines: 0, + }, + }); + const env = envelopeOf<{ + files: { matches: unknown[] }[]; + truncated: boolean; + }>(res.json()); + expect(env.code).toBe(0); + const total = env.data!.files.reduce((n, f) => n + f.matches.length, 0); + expect(total).toBeLessThanOrEqual(5); + expect(env.data!.truncated).toBe(true); + }); + + it('40401 unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/sess_does_not_exist/fs:grep', + payload: { pattern: 'x' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); + +// ----------------------------------------------------------------- +// Fallback / timeout — direct service tests (not via Fastify), since +// (a) the fallback needs to override the rg probe deterministically +// (b) the 30s timeout would make the test suite too slow if exercised +// against the real HTTP handler. +// ----------------------------------------------------------------- + +describe('FsSearchServiceImpl direct: rg fallback + grep timeout (W11.1)', () => { + function makeStubSession(cwd: string): ISessionService { + return { + list: async () => [], + get: async () => ({ + id: 'sess_stub', + metadata: { cwd, model: 'kimi-k2', created_at: '2026-06-04T00:00:00Z' }, + status: 'idle', + created_at: '2026-06-04T00:00:00Z', + updated_at: '2026-06-04T00:00:00Z', + }), + create: async () => { + throw new Error('not used'); + }, + delete: async () => { + throw new Error('not used'); + }, + update: async () => { + throw new Error('not used'); + }, + dispose: () => undefined, + } as unknown as ISessionService; + } + + function makeStubLogger(): ILogger & { warnings: string[] } { + const warnings: string[] = []; + const logger: ILogger & { warnings: string[] } = { + warnings, + info: (..._args: unknown[]) => undefined, + warn: (...args: unknown[]) => { + const msg = typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]); + warnings.push(msg); + }, + error: (..._args: unknown[]) => undefined, + debug: (..._args: unknown[]) => undefined, + fatal: (..._args: unknown[]) => undefined, + trace: (..._args: unknown[]) => undefined, + child: () => logger, + dispose: () => undefined, + } as unknown as ILogger & { warnings: string[] }; + return logger; + } + + /** Stub: pretends rg is missing AND records the warn-once invariant. */ + class StubMissingRgImpl extends FsSearchServiceImpl { + public override probeRg(): Promise { + if (this.rgPath !== undefined) return Promise.resolve(this.rgPath); + this.rgPath = null; + if (!this.rgMissingWarned) { + this.logger.warn( + '`rg` (ripgrep) not found on PATH — fs:grep falling back to pure-Node implementation. Install ripgrep for faster searches.', + ); + this.rgMissingWarned = true; + } + return Promise.resolve(null); + } + } + + it('node fallback runs when rg is missing AND warns exactly once', async () => { + const sessions = makeStubSession(workspace); + const logger = makeStubLogger(); + const svc = new StubMissingRgImpl(sessions, logger); + writeFileSync(join(workspace, 'a.txt'), 'needle\n'); + + const first = await svc.grep('sess_stub', { + pattern: 'needle', + regex: false, + case_sensitive: true, + follow_gitignore: true, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 0, + }); + expect(first.files.length).toBe(1); + expect(first.files[0]!.matches[0]!.text).toBe('needle'); + + // Second call: should NOT re-warn (warn-once invariant). + await svc.grep('sess_stub', { + pattern: 'needle', + regex: false, + case_sensitive: true, + follow_gitignore: true, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 0, + }); + expect(logger.warnings.length).toBe(1); + expect(logger.warnings[0]).toContain('rg'); + svc.dispose(); + }); + + // The 30s timeout is hard to exercise without making the test slow. + // We test the timeout machinery by stubbing GREP_TIMEOUT_MS via a + // subclass that injects an immediate abort. + it('grep timeout fires FsGrepTimeoutError → 41305', async () => { + const sessions = makeStubSession(workspace); + const logger = makeStubLogger(); + + // Use a class override that aborts the controller before any work runs. + class StubTimeoutImpl extends FsSearchServiceImpl { + protected override async grepWithNode( + _cwd: string, + _req: import('@moonshot-ai/protocol').FsGrepRequest, + _signal: AbortSignal, + startedAt: number, + ): Promise { + // Simulate the 30s deadline expiring with zero matches collected. + throw new ( + await import('../src/services/fs-search') + ).FsGrepTimeoutError(Date.now() - startedAt); + } + public override probeRg(): Promise { + // Force fallback path + this.rgPath = null; + return Promise.resolve(null); + } + } + const svc = new StubTimeoutImpl(sessions, logger); + writeFileSync(join(workspace, 'a.txt'), 'needle\n'); + await expect( + svc.grep('sess_stub', { + pattern: 'needle', + regex: false, + case_sensitive: true, + follow_gitignore: true, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 0, + }), + ).rejects.toThrow(/grep_timeout/); + svc.dispose(); + }); + + it('through DI seed-and-resolve preserves stubbed rg-missing fallback', async () => { + const sessions = makeStubSession(workspace); + const logger = makeStubLogger(); + const svc = new StubMissingRgImpl(sessions, logger); + writeFileSync(join(workspace, 'a.txt'), 'needle\n'); + const out = await svc.grep('sess_stub', { + pattern: 'needle', + regex: false, + case_sensitive: true, + follow_gitignore: true, + max_files: 200, + max_matches_per_file: 50, + max_total_matches: 5000, + context_lines: 0, + }); + expect(out.files.length).toBe(1); + svc.dispose(); + }); +}); diff --git a/packages/daemon/test/fs-watch.e2e.test.ts b/packages/daemon/test/fs-watch.e2e.test.ts new file mode 100644 index 000000000..68983a394 --- /dev/null +++ b/packages/daemon/test/fs-watch.e2e.test.ts @@ -0,0 +1,489 @@ +/** + * `event.fs.changed` end-to-end (W12 / Chain 14, P1.14). + * + * AC coverage (ROADMAP §Chain 14): + * 1. subscribe `/src` → create file → receive `event.fs.changed` + * 2. burst > 500 changes / 200ms → truncated event + * 3. two clients, two paths → no cross-delivery + * 4. > 100 paths per connection → `42902 fs.watch_limit_exceeded` + * + * Boots `startDaemon` against a tmp workspace, drives WS clients via the + * real `ws` library (same shape as `ws-broadcast.e2e.test.ts`), and + * mutates the filesystem to trigger chokidar events. + * + * **Timing note** (chokidar + tmpdir): chokidar's `ready` event takes + * O(50ms) to fire on a tree the size of these tests. We don't wait for + * it explicitly; instead each test: + * - issues `watch_fs_add` (the ack means the WS handler has called + * `chokidar.add`), + * - sleeps `WATCH_SETTLE_MS` (= 100ms) to let chokidar register the + * paths, + * - performs the mutation, + * - waits for the `event.fs.changed` envelope up to 2000ms. + * + * The 200ms debounce window + 100ms settle gives ~300ms per mutation; AC + * tests usually finish in ≈500ms. + */ + +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { + IRestGateway, + startDaemon, + type RunningDaemon, +} from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let workspace: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-fswatch-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-fswatch-home-')); + workspace = join(tmpDir, 'workspace'); + mkdirSync(workspace, { recursive: true }); + mkdirSync(join(workspace, 'src'), { recursive: true }); + mkdirSync(join(workspace, 'docs'), { recursive: true }); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ + statusCode: number; + json: () => unknown; + }>; + }; + }); +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: workspace } }, + }); + const env = res.json() as { code: number; data: { id: string } | null }; + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; +} + +interface WsFrame { + type: string; + payload?: Record; + id?: string; + code?: number; + msg?: string; + seq?: number; + session_id?: string; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; +} + +function openConn(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(String(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + const w = waiters.shift(); + w?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.once('open', () => resolve({ ws, queue, waiters })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType( + conn: Conn, + type: string, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + } + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +async function helloAndSubscribe( + conn: Conn, + clientId: string, + sessionId: string, +): Promise { + await receiveType(conn, 'server_hello', 1000); + conn.ws.send( + JSON.stringify({ + type: 'client_hello', + id: `cli_${clientId}`, + payload: { client_id: clientId, subscriptions: [sessionId] }, + }), + ); + await receiveType(conn, 'ack', 1000); +} + +const sleep = (ms: number): Promise => + new Promise((r) => setTimeout(r, ms)); + +/** Time we give chokidar to register newly-watched paths before we mutate. */ +const WATCH_SETTLE_MS = 150; + +describe('WS fs watch (W12 / Chain 14)', () => { + it('AC #1: subscribe /src → create file → receive event.fs.changed', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + // Add `src` to watch_fs. + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w1', + payload: { session_id: sid, paths: ['src'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(0); + expect(ack.payload).toMatchObject({ watched_paths: ['src'] }); + + await sleep(WATCH_SETTLE_MS); + writeFileSync(join(workspace, 'src', 'new.ts'), 'export const x = 1;\n'); + + const ev = await receiveType(conn, 'event.fs.changed', 2000); + expect(ev.session_id).toBe(sid); + const payload = ev.payload as { + changes: Array<{ path: string; change: string; kind: string }>; + coalesced_window_ms: number; + truncated?: boolean; + }; + expect(payload.coalesced_window_ms).toBe(200); + expect(payload.truncated).toBeUndefined(); + expect(payload.changes.length).toBeGreaterThanOrEqual(1); + // Path is POSIX-relative to cwd; should mention `src/new.ts` or the dir + // (created event lands as soon as the file's parent directory dispatches). + const paths = payload.changes.map((c) => c.path); + expect(paths.some((p) => p === 'src/new.ts' || p === 'src')).toBe(true); + + conn.ws.close(); + }); + + it('AC #2: burst > 500 changes inside 200ms window → truncated:true', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + // Watch the whole workspace root so every new file lands in scope. + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w2', + payload: { session_id: sid, paths: ['.'] }, + }), + ); + await receiveType(conn, 'ack', 1000); + + await sleep(WATCH_SETTLE_MS); + + // Slam 600 files into a fresh dir; chokidar emits >500 add events well + // inside one 200ms window. + const burstDir = join(workspace, 'burst'); + mkdirSync(burstDir, { recursive: true }); + for (let i = 0; i < 600; i++) { + writeFileSync(join(burstDir, `f${i}.txt`), `x${i}`); + } + + // Drain frames until we see truncated:true OR run out of time. + const deadline = Date.now() + 4000; + let sawTruncated = false; + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + let frame: WsFrame; + try { + frame = await receive(conn, remaining); + } catch { + break; + } + if (frame.type !== 'event.fs.changed') continue; + const payload = frame.payload as { truncated?: boolean; count?: number }; + if (payload.truncated === true) { + expect(payload.count).toBeGreaterThan(500); + sawTruncated = true; + break; + } + } + expect(sawTruncated).toBe(true); + conn.ws.close(); + }); + + it('AC #3: two clients on disjoint paths receive only their own changes', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const a = await openConn(wsUrl(r.address)); + const b = await openConn(wsUrl(r.address)); + await helloAndSubscribe(a, 'A', sid); + await helloAndSubscribe(b, 'B', sid); + + a.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wA', + payload: { session_id: sid, paths: ['src'] }, + }), + ); + await receiveType(a, 'ack', 1000); + b.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wB', + payload: { session_id: sid, paths: ['docs'] }, + }), + ); + await receiveType(b, 'ack', 1000); + + await sleep(WATCH_SETTLE_MS); + writeFileSync(join(workspace, 'src', 'a.ts'), 'a'); + writeFileSync(join(workspace, 'docs', 'b.md'), 'b'); + + // A should see src changes only. + const evA = await receiveType(a, 'event.fs.changed', 2000); + const payloadA = evA.payload as { + changes: Array<{ path: string }>; + }; + expect(payloadA.changes.some((c) => c.path.startsWith('src/'))).toBe(true); + expect(payloadA.changes.some((c) => c.path.startsWith('docs/'))).toBe(false); + + // B should see docs changes only. + const evB = await receiveType(b, 'event.fs.changed', 2000); + const payloadB = evB.payload as { + changes: Array<{ path: string }>; + }; + expect(payloadB.changes.some((c) => c.path.startsWith('docs/'))).toBe(true); + expect(payloadB.changes.some((c) => c.path.startsWith('src/'))).toBe(false); + + // Cross-contamination check: A should NOT receive any frame whose + // changes touch docs/, and vice versa. Drain a short window. + const drainDeadline = Date.now() + 400; + while (Date.now() < drainDeadline) { + const remaining = drainDeadline - Date.now(); + try { + const frame = await receive(a, remaining); + if (frame.type !== 'event.fs.changed') continue; + const p = frame.payload as { changes: Array<{ path: string }> }; + expect(p.changes.some((c) => c.path.startsWith('docs/'))).toBe(false); + } catch { + break; + } + } + + a.ws.close(); + b.ws.close(); + }); + + it('AC #4: > 100 paths on one connection → 42902 fs.watch_limit_exceeded', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + // Create 101 directories so each path resolves under cwd successfully + // (we don't want a 41304 / 40409 false-positive masking the 42902). + const paths: string[] = []; + for (let i = 0; i < 101; i++) { + const p = `dir${i}`; + mkdirSync(join(workspace, p), { recursive: true }); + paths.push(p); + } + + // First add 100 — should succeed. + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w100', + payload: { session_id: sid, paths: paths.slice(0, 100) }, + }), + ); + const ack100 = await receiveType(conn, 'ack', 2000); + expect(ack100.code).toBe(0); + const payload100 = ack100.payload as { current_count: number }; + expect(payload100.current_count).toBe(100); + + // 101st path → 42902. + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w101', + payload: { session_id: sid, paths: [paths[100]!] }, + }), + ); + const ack101 = await receiveType(conn, 'ack', 2000); + expect(ack101.code).toBe(42902); + + conn.ws.close(); + }); + + it('idempotent: adding the same path twice keeps watched_paths singular', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w1', + payload: { session_id: sid, paths: ['src'] }, + }), + ); + await receiveType(conn, 'ack', 1000); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w2', + payload: { session_id: sid, paths: ['src'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + const payload = ack.payload as { current_count: number }; + expect(payload.current_count).toBe(1); + + conn.ws.close(); + }); + + it('watch_fs_remove drops the subscription and acks updated watched_paths', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wadd', + payload: { session_id: sid, paths: ['src', 'docs'] }, + }), + ); + await receiveType(conn, 'ack', 1000); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_remove', + id: 'wrm', + payload: { session_id: sid, paths: ['src'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + const payload = ack.payload as { watched_paths: string[]; current_count: number }; + expect(payload.watched_paths).toEqual(['docs']); + expect(payload.current_count).toBe(1); + + conn.ws.close(); + }); + + it('41304: watch_fs_add for `..` path → fs.path_escapes_session', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r.address)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wbad', + payload: { session_id: sid, paths: ['../escape'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(41304); + + conn.ws.close(); + }); +}); diff --git a/packages/daemon/test/lock.test.ts b/packages/daemon/test/lock.test.ts new file mode 100644 index 000000000..da0ecb9cd --- /dev/null +++ b/packages/daemon/test/lock.test.ts @@ -0,0 +1,155 @@ +/** + * Lock file semantics (ROADMAP P0.12 AC). + * + * Hermetic strategy: every test uses a tmpdir lock path so production + * `~/.kimi/daemon/lock` is never touched. We mint pid values that don't + * collide with the real process (we ARE the test process, so use a clearly + * dead high pid like 0x7fffffff for stale-takeover tests; `process.kill(pid, + * 0)` returns ESRCH for any unallocated pid on Linux/macOS). + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_LOCK_PATH, + DaemonLockedError, + acquireLock, + type LockContents, +} from '../src/lock'; + +let tmpDir: string; +let lockPath: string; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-lock-test-')); + lockPath = join(tmpDir, 'lock'); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('acquireLock — basic acquire / release', () => { + it('writes pid/started_at/port JSON and release deletes the file', () => { + const handle = acquireLock({ + lockPath, + port: 7878, + nowIso: '2026-06-05T00:00:00.000Z', + }); + expect(handle.lockPath).toBe(lockPath); + expect(existsSync(lockPath)).toBe(true); + + const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; + expect(stored).toEqual({ + pid: process.pid, + started_at: '2026-06-05T00:00:00.000Z', + port: 7878, + }); + + handle.release(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('defaults nowIso + pid when not provided', () => { + const handle = acquireLock({ lockPath, port: 1234 }); + const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; + expect(stored.pid).toBe(process.pid); + expect(stored.port).toBe(1234); + // ISO 8601 with milliseconds + Z. Loose check — full format coverage lives in protocol/time.test.ts. + expect(stored.started_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + handle.release(); + }); + + it('release is idempotent — second call is a no-op', () => { + const handle = acquireLock({ lockPath, port: 9 }); + handle.release(); + handle.release(); // would throw if not guarded + expect(existsSync(lockPath)).toBe(false); + }); + + it('release tolerates a missing lock file (best-effort)', () => { + const handle = acquireLock({ lockPath, port: 9 }); + // Operator manually rm'd it between acquire and release. + rmSync(lockPath); + expect(() => handle.release()).not.toThrow(); + }); +}); + +describe('acquireLock — concurrent-instance protection', () => { + it('throws DaemonLockedError when a live owner already holds the lock', () => { + // Simulate "another live daemon" by writing a lock file with our own pid + // (which is definitely alive — this test runner) but a fake port. + const existing: LockContents = { + pid: process.pid, + started_at: '2026-06-05T00:00:00.000Z', + port: 7878, + }; + writeFileSync(lockPath, JSON.stringify(existing)); + + // Same-pid double-acquire is also a conflict (single-daemon-per-process + // invariant). Caller must release the previous handle first. + expect(() => acquireLock({ lockPath, port: 7878 })).toThrow(DaemonLockedError); + + try { + acquireLock({ lockPath, port: 7878 }); + } catch (err) { + const e = err as DaemonLockedError; + expect(e.code).toBe('EDAEMON_LOCKED'); + expect(e.exitCode).toBe(2); + expect(e.message).toContain(`pid=${process.pid}`); + expect(e.message).toContain('port=7878'); + expect(e.existing).toEqual(existing); + } + }); + + it('takes over a stale lock whose recorded pid is dead', () => { + // 0x7fffffff (2147483647) is the max signed-32 pid on Linux/macOS; the + // kernel never allocates pids that high in normal operation. ESRCH guaranteed. + const stalePid = 0x7fffffff; + writeFileSync( + lockPath, + JSON.stringify({ + pid: stalePid, + started_at: '2025-01-01T00:00:00.000Z', + port: 7878, + } satisfies LockContents), + ); + + const handle = acquireLock({ lockPath, port: 7878 }); + const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; + expect(stored.pid).toBe(process.pid); + expect(stored.port).toBe(7878); + handle.release(); + }); + + it('takes over an unparseable lock file', () => { + writeFileSync(lockPath, '{garbage'); + const handle = acquireLock({ lockPath, port: 4242 }); + const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; + expect(stored.pid).toBe(process.pid); + handle.release(); + }); + + it('does NOT delete the lock if a third party stole ownership between acquire and release', () => { + const handle = acquireLock({ lockPath, port: 9999 }); + // Simulate another daemon clobbering the file with its own pid+port. + const otherPid = 0x7ffffff0; + writeFileSync( + lockPath, + JSON.stringify({ pid: otherPid, started_at: 'x', port: 1234 } satisfies LockContents), + ); + + handle.release(); + expect(existsSync(lockPath)).toBe(true); // mismatched pid → preserved + }); +}); + +describe('acquireLock — defaults', () => { + it('DEFAULT_LOCK_PATH points under the homedir', () => { + expect(DEFAULT_LOCK_PATH).toMatch(/[/\\]\.kimi[/\\]daemon[/\\]lock$/); + }); +}); diff --git a/packages/daemon/test/messages.e2e.test.ts b/packages/daemon/test/messages.e2e.test.ts new file mode 100644 index 000000000..5f3b971c9 --- /dev/null +++ b/packages/daemon/test/messages.e2e.test.ts @@ -0,0 +1,240 @@ +/** + * Messages history end-to-end tests (W7.1 / Chain 3 / P1.3). + * + * **Bootstrap strategy**: spawn the real daemon (port 0, tmp lock + bridge + * home) and exercise the 2 endpoints via `app.inject(...)`. KimiCore is fully + * constructed via the W3 bridge pattern — fresh tmpdir per test, no `~/.kimi` + * interference. + * + * Coverage matrix per REST.md §3.4: + * - GET /v1/sessions/{sid}/messages → Page + has_more + * - Empty session → empty page, has_more=false + * - page_size honored (sub-cap) + * - GET /v1/sessions/{sid}/messages/{mid} → Message (40403 unknown id) + * + * Plus the validation matrix: + * - page_size=0 → 40001 + * - before_id + after_id together → 40001 + * - page_size=101 (over SCHEMAS §1.3 cap of 100) → 40001 + * - unknown role → 40001 + * + * Plus the error mapping matrix: + * - Unknown session_id → 40401 + * - Known session, missing message_id → 40403 + * + * **Note on `getContext` against a freshly-created session**: agent-core's + * `getContext` requires the session to be loaded into the active session map. + * Brand-new sessions (via `createSession`) emit no history yet; the bridge's + * `getContext({sessionId, agentId:'main'})` returns + * `{history: [], tokenCount: 0}`. The list endpoint surfaces this as + * `{items:[], has_more:false}` — the test exercises that path. Populated + * history is unit-tested at the services layer with a mocked bridge (see + * `packages/services/test/message-service.test.ts`). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-messages-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-messages-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`failed to create session: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () => { + it('returns an empty page for a freshly-created session', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + expect(env.data!.items).toEqual([]); + expect(env.data!.has_more).toBe(false); + }); + + it('returns 40401 for an unknown session id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions/sess_missing/messages', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + expect(env.data).toBeNull(); + }); + + it('rejects page_size=0 with code 40001 + details', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages?page_size=0`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(Array.isArray(env.details)).toBe(true); + }); + + it('rejects page_size=101 with code 40001 (SCHEMAS §1.3 cap)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages?page_size=101`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it('rejects before_id + after_id together with code 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages?before_id=a&after_id=b`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it('rejects unknown role values with code 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages?role=cat`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it('accepts page_size + role together (positive)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages?page_size=10&role=assistant`, + }); + const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.items).toEqual([]); + expect(env.data!.has_more).toBe(false); + }); +}); + +describe('GET /v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Chain 3)', () => { + it('returns 40403 (message.not_found) when the id has no matching history entry', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + // The id syntax is opaque; any well-formed id that points at an index + // outside the empty history surfaces as message.not_found. + const fakeId = `msg_${sid}_000000`; + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages/${fakeId}`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40403); + expect(env.data).toBeNull(); + expect(env.msg).toMatch(/does not exist/); + }); + + it('returns 40403 for a malformed message id (parse failure)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/messages/garbage`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40403); + }); + + it('returns 40401 when the session is unknown (regardless of message id shape)', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions/sess_missing/messages/msg_anything', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); diff --git a/packages/daemon/test/meta.e2e.test.ts b/packages/daemon/test/meta.e2e.test.ts new file mode 100644 index 000000000..18453e8d2 --- /dev/null +++ b/packages/daemon/test/meta.e2e.test.ts @@ -0,0 +1,202 @@ +/** + * `/v1/meta` end-to-end smoke (W6.1 / Chain 1 / P1.1). + * + * Boots the real daemon (hermetic — port 0, tmp lock + bridge home), hits + * `GET /v1/meta` via Fastify's `inject` simulator on the constructed app, and + * asserts: + * 1. Envelope shape (`code: 0`, `msg: success`, `request_id`, `data`). + * 2. `data` matches `metaResponseSchema` — daemon_version + capabilities + * literals + server_id ULID + started_at ISO `Z`. + * 3. `server_id` is stable across multiple calls to the same daemon + * (it's process-scoped, not per-request). + * 4. `started_at` is the daemon's boot time — within a generous window of + * `Date.now()` at test start. + * + * Plus request_id propagation (already covered for `/v1/healthz` in + * `error-handler.test.ts` but re-asserted here because the prompt requires + * Chain 1's first business endpoint to demonstrate the W4.3 request_id pipe): + * + * - Valid ULID `X-Request-Id` → echoed verbatim. + * - No header → bare ULID minted (`ulidRegex`). + * - Malformed header → fresh bare ULID (NOT `req_garbage`). + * + * The daemon's bridge `homeDir` is sandboxed so the test doesn't touch the + * user's `~/.kimi`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { metaResponseSchema, ulidRegex } from '@moonshot-ai/protocol'; +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; +const bootBaseline = Date.now(); + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-meta-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-meta-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +/** + * Pull the Fastify instance off the running daemon via the IRestGateway + * accessor — Fastify exposes `.inject()` so we don't need a port. + */ +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + // We use the same accessor pattern start.ts uses internally. IRestGateway + // is registered with the `FastifyLike` structural type; `.inject()` is the + // Fastify-specific method we need for hermetic tests — it lives on the + // underlying instance, not on FastifyLike. The cast is local to this test. + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +describe('GET /v1/meta — envelope + metaResponseSchema', () => { + it('responds 200 with code 0 + schema-conforming data', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/meta' }); + expect(res.statusCode).toBe(200); + const body = res.json() as Record; + expect(body['code']).toBe(0); + expect(body['msg']).toBe('success'); + expect(typeof body['request_id']).toBe('string'); + expect(body['data']).not.toBeNull(); + + const data = body['data']; + const parsed = metaResponseSchema.parse(data); + + expect(parsed.daemon_version.length).toBeGreaterThan(0); + expect(parsed.capabilities).toEqual({ + websocket: true, + file_upload: true, + fs_query: true, + mcp: true, + background_tasks: true, + }); + expect(ulidRegex.test(parsed.server_id)).toBe(true); + // started_at is ISO 8601 UTC `Z` per isoDateTimeSchema's normalization. + expect(parsed.started_at).toMatch(/Z$/); + const startedMs = Date.parse(parsed.started_at); + expect(Number.isFinite(startedMs)).toBe(true); + // Should fall within [bootBaseline-1s, now+1s] — generous slack. + expect(startedMs).toBeGreaterThanOrEqual(bootBaseline - 1_000); + expect(startedMs).toBeLessThanOrEqual(Date.now() + 1_000); + }); + + it('server_id is stable across multiple calls (process-scoped)', async () => { + const r = await bootDaemon(); + const app = appOf(r); + const a = await app.inject({ method: 'GET', url: '/v1/meta' }); + const b = await app.inject({ method: 'GET', url: '/v1/meta' }); + const aData = (a.json() as { data: { server_id: string } }).data; + const bData = (b.json() as { data: { server_id: string } }).data; + expect(aData.server_id).toBe(bData.server_id); + }); + + it('two independent daemons get distinct server_ids', async () => { + // Use distinct lock paths so both can coexist for the duration of the test. + const lockA = join(tmpDir, 'lock-a'); + const lockB = join(tmpDir, 'lock-b'); + const homeA = mkdtempSync(join(tmpdir(), 'kimi-daemon-meta-home-a-')); + const homeB = mkdtempSync(join(tmpdir(), 'kimi-daemon-meta-home-b-')); + const r1 = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath: lockA, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: homeA }, + }); + const r2 = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath: lockB, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: homeB }, + }); + try { + const a = await appOf(r1).inject({ method: 'GET', url: '/v1/meta' }); + const b = await appOf(r2).inject({ method: 'GET', url: '/v1/meta' }); + const aData = (a.json() as { data: { server_id: string } }).data; + const bData = (b.json() as { data: { server_id: string } }).data; + expect(aData.server_id).not.toBe(bData.server_id); + } finally { + await r1.close(); + await r2.close(); + rmSync(homeA, { recursive: true, force: true }); + rmSync(homeB, { recursive: true, force: true }); + } + }); +}); + +describe('GET /v1/meta — request_id propagation (W4.3 contract)', () => { + it('echoes a client-supplied valid ULID verbatim', async () => { + const r = await bootDaemon(); + const goodUlid = '01HQXY4Z2M3GZP6F8K9R5W7VBA'; + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/meta', + headers: { 'x-request-id': goodUlid }, + }); + const body = res.json() as Record; + expect(body['request_id']).toBe(goodUlid); + }); + + it('mints a bare ULID when no header is supplied (no req_ prefix)', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/meta' }); + const body = res.json() as Record; + const id = body['request_id'] as string; + expect(id).not.toMatch(/^req_/); + expect(ulidRegex.test(id)).toBe(true); + }); + + it('discards malformed X-Request-Id and mints a fresh ULID', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/meta', + headers: { 'x-request-id': 'req_garbage' }, + }); + const body = res.json() as Record; + const id = body['request_id'] as string; + expect(id).not.toBe('req_garbage'); + expect(id).not.toMatch(/^req_/); + expect(ulidRegex.test(id)).toBe(true); + }); +}); diff --git a/packages/daemon/test/prompt.e2e.test.ts b/packages/daemon/test/prompt.e2e.test.ts new file mode 100644 index 000000000..b1bbc6ac9 --- /dev/null +++ b/packages/daemon/test/prompt.e2e.test.ts @@ -0,0 +1,325 @@ +/** + * Prompts end-to-end tests (W7.2 / Chain 4 / P1.4). + * + * **Bootstrap strategy**: spawn the real daemon (port 0, tmp lock + bridge + * home) and exercise: + * 1. POST /v1/sessions/{sid}/prompts validation (40001 on bad body, 40401 + * on bad sid). + * 2. Lifecycle event synthesis: register a fake active prompt directly on + * the IPromptService (so we don't have to drive agent-core through the + * bridge.rpc.prompt path, which requires provider creds). Publish + * `turn.started` → `assistant.delta` × N → `turn.ended` directly through + * the event bus. Verify a WS subscriber receives them all PLUS the + * synthesized `prompt.completed`. + * + * We don't drive a REAL prompt through agent-core in this test because: + * - prompt execution requires provider credentials + network IO. + * - the architecture under test is the daemon's event-bus synthesis + + * fan-out path, not the model's behavior. + * - the services-layer unit tests at + * `packages/services/test/prompt-service.test.ts` exercise the protocol + * → kosong content adapter against a mocked bridge. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import type { Event } from '@moonshot-ai/protocol'; +import { IEventBus, IPromptService, PromptServiceImpl } from '@moonshot-ai/services'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-prompts-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-prompts-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +/** + * Open a WS subscriber and wait for server_hello + client_hello ack. + * Returns a handle exposing the received frame queue. + * + * Mirrors the queueing pattern from `ws-broadcast.e2e.test.ts` — message + * listener is attached BEFORE the `open` event resolves, so frames that land + * in the same tick as the upgrade aren't lost. + */ +async function openSubscriber( + r: RunningDaemon, + sid: string, +): Promise<{ + ws: WebSocket; + received: Record[]; +}> { + const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const received: Record[] = []; + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(wsUrl); + sock.on('message', (data) => { + try { + received.push(JSON.parse(String(data)) as Record); + } catch { + // ignore + } + }); + sock.once('open', () => resolve(sock)); + sock.once('error', reject); + }); + // Wait for server_hello. + await waitFor(received, (f) => f['type'] === 'server_hello'); + ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'h1', + payload: { client_id: 'test', subscriptions: [sid] }, + }), + ); + await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1'); + return { ws, received }; +} + +async function waitFor( + received: Record[], + pred: (f: Record) => boolean, + timeoutMs = 2000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const hit = received.find(pred); + if (hit !== undefined) return hit; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error( + `waitFor timed out; received: ${received.map((f) => f['type']).join(', ')}`, + ); +} + +describe('POST /v1/sessions/{sid}/prompts — submit validation (W7.2 / Chain 4)', () => { + it('rejects an empty content array with 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts`, + payload: { content: [] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(env.data).toBeNull(); + expect(Array.isArray(env.details)).toBe(true); + }); + + it('returns 40401 for an unknown session id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/sess_missing/prompts', + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('rejects bad content shape with 40001 (no `type` field)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts`, + payload: { content: [{ text: 'no type' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('Prompt lifecycle: WS receives events + synthesized prompt.completed (W7.2)', () => { + it('synthesizes prompt.completed end-to-end through bus → observer → WS', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const promptId = `prompt_TEST_${sid}`; + const turnId = 42; + + // Inject an active-prompt record into the daemon's IPromptService so the + // lifecycle observer recognizes turn.* events for this session. We skip + // a real `bridge.rpc.prompt(...)` call because it would require provider + // credentials + a fully-loaded agent. + const impl = r.services.invokeFunction( + (a) => a.get(IPromptService) as PromptServiceImpl, + ); + expect(impl).toBeInstanceOf(PromptServiceImpl); + impl._injectActiveForTest(sid, promptId, null); + + // Publish the agent-core event stream directly through the bus. + const eventBus = r.services.invokeFunction((a) => a.get(IEventBus)); + eventBus.publish({ + type: 'turn.started', + turnId, + origin: { kind: 'user' }, + sessionId: sid, + agentId: 'main', + } as unknown as Event); + eventBus.publish({ + type: 'assistant.delta', + turnId, + delta: 'hi ', + sessionId: sid, + agentId: 'main', + } as unknown as Event); + eventBus.publish({ + type: 'assistant.delta', + turnId, + delta: 'there', + sessionId: sid, + agentId: 'main', + } as unknown as Event); + eventBus.publish({ + type: 'turn.ended', + turnId, + reason: 'completed', + sessionId: sid, + agentId: 'main', + } as unknown as Event); + + // Wait for the synthesized prompt.completed event on the WS. + const promptCompletedFrame = await waitFor( + received, + (f) => f['type'] === 'prompt.completed', + 2000, + ); + const payload = promptCompletedFrame['payload'] as { + promptId: string; + reason: string; + }; + expect(payload.promptId).toBe(promptId); + expect(payload.reason).toBe('completed'); + expect(promptCompletedFrame['session_id']).toBe(sid); + + // Verify the upstream events also arrived in order. + const types = received.map((f) => f['type']); + expect(types).toContain('turn.started'); + expect(types).toContain('assistant.delta'); + expect(types).toContain('turn.ended'); + expect(types).toContain('prompt.completed'); + // prompt.completed lands AFTER turn.ended (synthesized post-fan-out). + const turnEndedIdx = types.lastIndexOf('turn.ended'); + const completedIdx = types.lastIndexOf('prompt.completed'); + expect(completedIdx).toBeGreaterThan(turnEndedIdx); + + ws.close(); + }); + + it('synthesizes prompt.aborted when turn.ended (reason=cancelled) fires', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const promptId = `prompt_ABORT_TEST_${sid}`; + const turnId = 7; + + const impl = r.services.invokeFunction( + (a) => a.get(IPromptService) as PromptServiceImpl, + ); + impl._injectActiveForTest(sid, promptId, null); + + const eventBus = r.services.invokeFunction((a) => a.get(IEventBus)); + eventBus.publish({ + type: 'turn.started', + turnId, + origin: { kind: 'user' }, + sessionId: sid, + agentId: 'main', + } as unknown as Event); + eventBus.publish({ + type: 'turn.ended', + turnId, + reason: 'cancelled', + sessionId: sid, + agentId: 'main', + } as unknown as Event); + + const abortedFrame = await waitFor( + received, + (f) => f['type'] === 'prompt.aborted', + 2000, + ); + const payload = abortedFrame['payload'] as { promptId: string }; + expect(payload.promptId).toBe(promptId); + + ws.close(); + }); +}); diff --git a/packages/daemon/test/question.e2e.test.ts b/packages/daemon/test/question.e2e.test.ts new file mode 100644 index 000000000..e8550755f --- /dev/null +++ b/packages/daemon/test/question.e2e.test.ts @@ -0,0 +1,517 @@ +/** + * Question end-to-end tests (W8.2 / Chain 6 / P1.6). + * + * Covers the reverse-RPC path: agent-core → BridgeClientAPI.requestQuestion → + * IQuestionBroker.request → WS `event.question.requested` → REST + * `POST /v1/sessions/{sid}/questions/{qid}` (or `:dismiss`) → Promise + * resolves with `Record` (or `null` for dismiss). + * + * Mirrors `approval.e2e.test.ts` strategy — bypass `bridge.rpc.prompt(...)` + * (no provider creds) and drive the broker directly via DI accessor. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { + IQuestionBroker, + type QuestionRequest, + type QuestionResult, +} from '@moonshot-ai/services'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; +import { + DaemonQuestionBroker, + QuestionExpiredError, +} from '../src/services/question-broker'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-questions-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-questions-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +async function openSubscriber( + r: RunningDaemon, + sid: string, +): Promise<{ + ws: WebSocket; + received: Record[]; +}> { + const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const received: Record[] = []; + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(wsUrl); + sock.on('message', (data) => { + try { + received.push(JSON.parse(String(data)) as Record); + } catch { + // ignore + } + }); + sock.once('open', () => resolve(sock)); + sock.once('error', reject); + }); + await waitFor(received, (f) => f['type'] === 'server_hello'); + ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'h1', + payload: { client_id: 'test', subscriptions: [sid] }, + }), + ); + await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1'); + return { ws, received }; +} + +async function waitFor( + received: Record[], + pred: (f: Record) => boolean, + timeoutMs = 2000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const hit = received.find(pred); + if (hit !== undefined) return hit; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error( + `waitFor timed out; received: ${received.map((f) => f['type']).join(', ')}`, + ); +} + +// --- Tests ----------------------------------------------------------------- + +describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle (W8.2)', () => { + it('full happy path: 4-item question → POST 4 answers (incl. 1 skipped) → agent receives normalized record', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + + const inProcReq: QuestionRequest = { + turnId: 1, + toolCallId: 'tc_q', + questions: [ + { + question: 'Animal?', + options: [{ label: 'Cat' }, { label: 'Dog' }], + }, + { + question: 'Colors?', + options: [{ label: 'R' }, { label: 'G' }, { label: 'B' }], + multiSelect: true, + }, + { + question: 'Custom?', + options: [{ label: 'X' }], + otherLabel: 'Other', + }, + { + question: 'Skip me', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + }; + + const pending = broker.request({ + ...inProcReq, + sessionId: sid, + agentId: 'main', + }); + + const requested = await waitFor( + received, + (f) => f['type'] === 'event.question.requested', + 2000, + ); + const payload = requested['payload'] as { + question_id: string; + session_id: string; + questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string }> }>; + }; + expect(payload.question_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(payload.session_id).toBe(sid); + expect(payload.questions).toHaveLength(4); + expect(payload.questions[0]?.id).toBe('q_0'); + expect(payload.questions[0]?.options[0]?.id).toBe('opt_0_0'); + expect(payload.questions[2]?.options[0]?.id).toBe('opt_2_0'); + + // POST with mixed kinds INCLUDING one skipped. + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${payload.question_id}`, + payload: { + answers: { + q_0: { kind: 'single', option_id: 'opt_0_0' }, + q_1: { kind: 'multi', option_ids: ['opt_1_0', 'opt_1_2'] }, + q_2: { kind: 'other', text: 'Hippopotamus' }, + q_3: { kind: 'skipped' }, + }, + method: 'enter', + }, + }); + const env = envelopeOf<{ resolved: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.resolved).toBe(true); + + // Promise resolves with the SCHEMAS §6.4 flattened shape. + const result = await pending; + expect(result).not.toBeNull(); + const inProcResp = result as { + answers: Record; + method?: string; + }; + expect(inProcResp.answers).toEqual({ + q_0: 'opt_0_0', + q_1: 'opt_1_0,opt_1_2', + q_2: 'Hippopotamus', + // q_3 omitted entirely (kind: skipped) + }); + expect(inProcResp.method).toBe('enter'); + + ws.close(); + }); + + it.each([ + [ + 'single kind', + [{ question: '?', options: [{ label: 'A' }, { label: 'B' }] }], + { q_0: { kind: 'single', option_id: 'opt_0_1' } }, + { q_0: 'opt_0_1' }, + ], + [ + 'multi kind', + [{ question: '?', options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }], multiSelect: true }], + { q_0: { kind: 'multi', option_ids: ['opt_0_0', 'opt_0_2'] } }, + { q_0: 'opt_0_0,opt_0_2' }, + ], + [ + 'other kind', + [{ question: '?', options: [{ label: 'X' }, { label: 'Y' }], otherLabel: 'Other' }], + { q_0: { kind: 'other', text: 'free' } }, + { q_0: 'free' }, + ], + [ + 'multi_with_other kind', + [{ question: '?', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true, otherLabel: 'Other' }], + { + q_0: { + kind: 'multi_with_other', + option_ids: ['opt_0_0'], + other_text: 'X', + }, + }, + { q_0: 'opt_0_0,X' }, + ], + [ + 'skipped kind (record entry omitted)', + [{ question: '?', options: [{ label: 'A' }, { label: 'B' }] }], + { q_0: { kind: 'skipped' } }, + {}, + ], + ] as const)( + 'normalizes %s per SCHEMAS §6.4', + async (_label, questions, answers, expectedRecord) => { + const r = await bootDaemon(); + const sid = await createSession(r); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + toolCallId: 'tc_kind', + questions: questions as unknown as QuestionRequest['questions'], + }); + + // Pull the daemon-minted question_id by peeking at the pending map. + let questionId: string | undefined; + for (let i = 0; i < 20 && !questionId; i++) { + await new Promise((r) => setTimeout(r, 10)); + questionId = (broker as unknown as { + _pending: Map; + })._pending.values().next().value?.questionId; + } + expect(questionId).toBeDefined(); + + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${questionId}`, + payload: { answers }, + }); + const env = envelopeOf<{ resolved: boolean }>(res.json()); + expect(env.code).toBe(0); + + const result = await pending; + const inProcResp = result as { answers: Record }; + expect(inProcResp.answers).toEqual(expectedRecord); + }, + ); + + it('dismiss path: POST :dismiss → WS event.question.dismissed → Promise resolves with null', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + questions: [ + { + question: 'Skip me?', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + }); + + const requested = await waitFor( + received, + (f) => f['type'] === 'event.question.requested', + 2000, + ); + const payload = requested['payload'] as { question_id: string }; + + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${payload.question_id}:dismiss`, + payload: {}, + }); + const env = envelopeOf<{ dismissed: boolean; dismissed_at: string }>(res.json()); + expect(env.code).toBe(40909); + expect(env.data?.dismissed).toBe(true); + expect(env.data?.dismissed_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + const dismissedFrame = await waitFor( + received, + (f) => f['type'] === 'event.question.dismissed', + 2000, + ); + const dPayload = dismissedFrame['payload'] as { question_id: string }; + expect(dPayload.question_id).toBe(payload.question_id); + + const result: QuestionResult = await pending; + expect(result).toBeNull(); + + ws.close(); + }); + + it('REST resolve on unknown question_id returns 40405', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/01JAAAAAAAAAAAAAAAAAAAAAAA`, + payload: { answers: { q_0: { kind: 'skipped' } } }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40405); + }); + + it('REST :dismiss on unknown question_id returns 40405', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/01JBBBBBBBBBBBBBBBBBBBBBBB:dismiss`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40405); + }); + + it('REST re-resolve on already-resolved question returns 40902 with data:{resolved:false}', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + questions: [ + { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, + ], + }); + + let questionId: string | undefined; + for (let i = 0; i < 20 && !questionId; i++) { + await new Promise((r) => setTimeout(r, 10)); + questionId = (broker as unknown as { + _pending: Map; + })._pending.values().next().value?.questionId; + } + expect(questionId).toBeDefined(); + + const ok = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${questionId}`, + payload: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + }); + expect(envelopeOf<{ resolved: boolean }>(ok.json()).code).toBe(0); + await pending; + + const dup = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${questionId}`, + payload: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, + }); + const dupEnv = envelopeOf<{ resolved: boolean }>(dup.json()); + expect(dupEnv.code).toBe(40902); + expect(dupEnv.data).toEqual({ resolved: false }); + }); + + it('REST resolve with bad body (unknown kind) returns 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + const _pending = broker.request({ + sessionId: sid, + agentId: 'main', + questions: [ + { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, + ], + }); + void _pending; + + let questionId: string | undefined; + for (let i = 0; i < 20 && !questionId; i++) { + await new Promise((r) => setTimeout(r, 10)); + questionId = (broker as unknown as { + _pending: Map; + })._pending.values().next().value?.questionId; + } + + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/questions/${questionId}`, + payload: { answers: { q_0: { kind: 'rangefinder', value: 42 } } }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + + // Cleanup so the test doesn't leave a hanging Promise. + broker.dismiss(questionId!); + }); + + it('60s timeout broadcasts event.question.expired + rejects with QuestionExpiredError', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const { ws, received } = await openSubscriber(r, sid); + + const broker = r.services.invokeFunction( + (a) => a.get(IQuestionBroker) as DaemonQuestionBroker, + ); + (broker as unknown as { _timeoutMs: number })._timeoutMs = 40; + + const pending = broker.request({ + sessionId: sid, + agentId: 'main', + questions: [ + { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, + ], + }); + + let rejection: unknown; + try { + await pending; + } catch (err) { + rejection = err; + } + expect(rejection).toBeInstanceOf(QuestionExpiredError); + + const expiredFrame = await waitFor( + received, + (f) => f['type'] === 'event.question.expired', + 2000, + ); + const payload = expiredFrame['payload'] as { question_id: string }; + expect(payload.question_id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + + ws.close(); + }); +}); diff --git a/packages/daemon/test/services.test.ts b/packages/daemon/test/services.test.ts new file mode 100644 index 000000000..e3f6d4726 --- /dev/null +++ b/packages/daemon/test/services.test.ts @@ -0,0 +1,477 @@ +/** + * Service stubs (W4.4 / P0.14, extended in W5.2 / P0.16) — broker + event-bus + * unit tests. + * + * Hermetic: we wire a real `InstantiationService` with stub `ILogger` impl, + * exercise `request` / `resolve` / `dismiss` / `dispose` directly, and a + * stub `ISessionClientsService` (no real sockets) for `DaemonEventBus`. + * + * Timing: we override `timeoutMs` to a small value (50ms) so a real timer + * fires within the test rather than waiting 60s. `vi.useFakeTimers` would + * also work but is heavier and forces every consumer's Promise into manual + * flushing. + * + * **Migration note** (W5.2): the W4 `DaemonEventBus._drainForTest` tests are + * gone — the bus no longer holds a queue at all. The new tests assert + * per-session seq monotonicity, ring-buffer state, and that `publish()` fans + * out to the right subscriber set via a fake `ISessionClientsService`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + InstantiationService, + ServiceCollection, + type ApprovalResponse, + type QuestionResult, +} from '@moonshot-ai/agent-core'; +import type { Event } from '@moonshot-ai/protocol'; +import { + IApprovalBroker, + IEventBus, + IQuestionBroker, +} from '@moonshot-ai/services'; + +import { DaemonApprovalBroker } from '../src/services/approval-broker'; +import { DaemonEventBus } from '../src/services/event-bus'; +import { ILogger, type ILogger as ILoggerT } from '../src/services/logger'; +import { DaemonQuestionBroker } from '../src/services/question-broker'; +import { + ISessionClientsService, + type ISessionClientsService as ISessionClientsServiceT, +} from '../src/services/session-clients'; +import type { WsConnection } from '../src/ws/connection'; + +/** No-op logger that satisfies `ILogger` without pulling pino. */ +class TestLogger implements ILoggerT { + info(): void {} + warn(): void {} + error(): void {} + debug(): void {} + child(): ILoggerT { + return this; + } +} + +/** + * In-memory subscriber index. Same shape as `SessionClientsService` but with + * Set-based bookkeeping inlined so the test doesn't depend on the real impl. + */ +class FakeSessionClients implements ISessionClientsServiceT { + private readonly _bySession = new Map>(); + subscribe(c: WsConnection, sid: string): void { + let set = this._bySession.get(sid); + if (!set) { + set = new Set(); + this._bySession.set(sid, set); + } + set.add(c); + } + unsubscribe(c: WsConnection, sid: string): void { + this._bySession.get(sid)?.delete(c); + } + getConnections(sid: string): Iterable { + return this._bySession.get(sid)?.values() ?? []; + } + forgetConnection(c: WsConnection): void { + for (const set of this._bySession.values()) set.delete(c); + } + subscriberCount(sid: string): number { + return this._bySession.get(sid)?.size ?? 0; + } +} + +/** Side-effect-recording `WsConnection`-shaped fake — only `.send` is used by the bus. */ +function fakeConn(id = 'conn_x'): { id: string; sent: unknown[]; send(m: unknown): void } & WsConnection { + const sent: unknown[] = []; + return { + id, + sent, + send(m: unknown): void { + sent.push(m); + }, + } as unknown as { id: string; sent: unknown[]; send(m: unknown): void } & WsConnection; +} + +let ix: InstantiationService; +let testLogger: TestLogger; + +beforeEach(() => { + testLogger = new TestLogger(); + const collection = new ServiceCollection([ILogger, testLogger]); + ix = new InstantiationService(collection); +}); + +afterEach(() => { + ix.dispose(); +}); + +describe('DaemonEventBus (W5.2 — WS broadcaster)', () => { + it('publishes event with seq=1, broadcasts to subscribers, advances seq monotonically per session', () => { + const clients = new FakeSessionClients(); + const c1 = fakeConn('conn_a'); + const c2 = fakeConn('conn_b'); + clients.subscribe(c1, 'sid_test'); + clients.subscribe(c2, 'sid_test'); + + const bus = new DaemonEventBus(testLogger, clients); + bus.publish({ type: 'fake.x', sessionId: 'sid_test' } as unknown as Event); + bus.publish({ type: 'fake.y', sessionId: 'sid_test' } as unknown as Event); + + expect(c1.sent.length).toBe(2); + expect(c2.sent.length).toBe(2); + const env1 = c1.sent[0] as { seq: number; session_id: string; type: string }; + const env2 = c1.sent[1] as { seq: number; session_id: string; type: string }; + expect(env1.seq).toBe(1); + expect(env1.session_id).toBe('sid_test'); + expect(env1.type).toBe('fake.x'); + expect(env2.seq).toBe(2); + expect(env2.type).toBe('fake.y'); + bus.dispose(); + }); + + it('per-session seq counters are independent', () => { + const clients = new FakeSessionClients(); + const cA = fakeConn('conn_a'); + const cB = fakeConn('conn_b'); + clients.subscribe(cA, 'sid_a'); + clients.subscribe(cB, 'sid_b'); + + const bus = new DaemonEventBus(testLogger, clients); + bus.publish({ type: 'e1', sessionId: 'sid_a' } as unknown as Event); + bus.publish({ type: 'e1', sessionId: 'sid_b' } as unknown as Event); + bus.publish({ type: 'e2', sessionId: 'sid_a' } as unknown as Event); + + const aSeqs = cA.sent.map((m) => (m as { seq: number }).seq); + const bSeqs = cB.sent.map((m) => (m as { seq: number }).seq); + expect(aSeqs).toEqual([1, 2]); + expect(bSeqs).toEqual([1]); + expect(bus._currentSeqForTest('sid_a')).toBe(2); + expect(bus._currentSeqForTest('sid_b')).toBe(1); + bus.dispose(); + }); + + it('does not broadcast to connections subscribed to a different session', () => { + const clients = new FakeSessionClients(); + const onA = fakeConn('conn_a'); + const onOther = fakeConn('conn_other'); + clients.subscribe(onA, 'sid_a'); + clients.subscribe(onOther, 'sid_other'); + + const bus = new DaemonEventBus(testLogger, clients); + bus.publish({ type: 'evt', sessionId: 'sid_a' } as unknown as Event); + expect(onA.sent.length).toBe(1); + expect(onOther.sent.length).toBe(0); + bus.dispose(); + }); + + it('drops events without a sessionId / session_id and warns', () => { + const clients = new FakeSessionClients(); + const c = fakeConn(); + clients.subscribe(c, 'sid_x'); + const warnSpy = vi.spyOn(testLogger, 'warn'); + + const bus = new DaemonEventBus(testLogger, clients); + bus.publish({ type: 'no_sid' } as unknown as Event); + + expect(c.sent.length).toBe(0); + expect(warnSpy).toHaveBeenCalledOnce(); + bus.dispose(); + }); + + it('post-dispose publish is a no-op', () => { + const clients = new FakeSessionClients(); + const c = fakeConn(); + clients.subscribe(c, 'sid_x'); + const bus = new DaemonEventBus(testLogger, clients); + bus.dispose(); + bus.publish({ type: 'late', sessionId: 'sid_x' } as unknown as Event); + expect(c.sent.length).toBe(0); + }); + + it('getBufferedSince returns events with seq > lastSeq when buffer covers the gap', () => { + const clients = new FakeSessionClients(); + const c = fakeConn(); + clients.subscribe(c, 'sid_test'); + const bus = new DaemonEventBus(testLogger, clients); + for (let i = 0; i < 5; i++) { + bus.publish({ type: `e${i}`, sessionId: 'sid_test' } as unknown as Event); + } + const replay = bus.getBufferedSince('sid_test', 2); + expect(replay.resyncRequired).toBe(false); + expect(replay.events.map((e) => e.seq)).toEqual([3, 4, 5]); + expect(replay.currentSeq).toBe(5); + bus.dispose(); + }); + + it('getBufferedSince returns empty + currentSeq=0 for a never-seen session', () => { + const bus = new DaemonEventBus(testLogger, new FakeSessionClients()); + const replay = bus.getBufferedSince('sid_new', 5); + expect(replay.events).toEqual([]); + expect(replay.resyncRequired).toBe(false); + expect(replay.currentSeq).toBe(0); + bus.dispose(); + }); +}); + +describe('DaemonApprovalBroker (W8.1 / Chain 5 — broadcasts + resolve-by-approval_id)', () => { + function makeBrokerWithBus(opts?: { timeoutMs?: number }): { + broker: DaemonApprovalBroker; + bus: DaemonEventBus; + clients: FakeSessionClients; + conn: ReturnType; + } { + const clients = new FakeSessionClients(); + const conn = fakeConn('conn_subscriber'); + clients.subscribe(conn, 'sess_1'); + const bus = new DaemonEventBus(testLogger, clients); + const broker = new DaemonApprovalBroker(testLogger, bus, opts); + return { broker, bus, clients, conn }; + } + + function extractApprovalId(sentFrames: unknown[]): string | undefined { + for (const frame of sentFrames) { + const env = frame as { type: string; payload: { approval_id?: string } }; + if (env.type === 'event.approval.requested' && env.payload.approval_id) { + return env.payload.approval_id; + } + } + return undefined; + } + + it('broadcasts event.approval.requested AND settles via resolve(approval_id, response)', async () => { + const { broker, bus, conn } = makeBrokerWithBus(); + const pending = broker.request({ + sessionId: 'sess_1', + agentId: 'agent_1', + toolCallId: 'tc_approval_1', + toolName: 'shell.run', + action: 'Run', + display: { kind: 'generic', summary: 'test' }, + } as Parameters[0]); + + // Subscriber sees the broadcast — extract the daemon-minted approval_id. + const approvalId = extractApprovalId(conn.sent); + expect(approvalId).toBeDefined(); + expect(broker.isPending(approvalId!)).toBe(true); + + const response: ApprovalResponse = { decision: 'approved' }; + broker.resolve(approvalId!, response); + await expect(pending).resolves.toEqual(response); + + // Resolved broadcast must follow. + const resolvedFrame = conn.sent.find( + (f) => (f as { type: string }).type === 'event.approval.resolved', + ); + expect(resolvedFrame).toBeDefined(); + expect(broker.isPending(approvalId!)).toBe(false); + expect(broker.isRecentlyResolved(approvalId!)).toBe(true); + + broker.dispose(); + bus.dispose(); + }); + + it('rejects with ApprovalExpiredError + broadcasts event.approval.expired after timeoutMs', async () => { + const { broker, bus, conn } = makeBrokerWithBus({ timeoutMs: 30 }); + const pending = broker.request({ + sessionId: 'sess_1', + agentId: 'agent_1', + toolCallId: 'tc_timeout', + toolName: 'shell.run', + action: 'Run', + display: { kind: 'generic', summary: 'test' }, + } as Parameters[0]); + + await expect(pending).rejects.toMatchObject({ + name: 'ApprovalExpiredError', + }); + const expiredFrame = conn.sent.find( + (f) => (f as { type: string }).type === 'event.approval.expired', + ); + expect(expiredFrame).toBeDefined(); + broker.dispose(); + bus.dispose(); + }); + + it('dispose rejects all pending requests with "daemon shutting down"', async () => { + const { broker, bus } = makeBrokerWithBus(); + const p1 = broker.request({ + sessionId: 'sess_1', + agentId: 'a', + toolCallId: 'tc_a', + toolName: 't', + action: 'a', + display: { kind: 'generic', summary: 'g' }, + } as Parameters[0]); + const p2 = broker.request({ + sessionId: 'sess_1', + agentId: 'a', + toolCallId: 'tc_b', + toolName: 't', + action: 'a', + display: { kind: 'generic', summary: 'g' }, + } as Parameters[0]); + + broker.dispose(); + await expect(p1).rejects.toThrow(/daemon shutting down/); + await expect(p2).rejects.toThrow(/daemon shutting down/); + bus.dispose(); + }); + + it('resolve() for an unknown id is a no-op (REST route handles 40404 via isPending)', () => { + const { broker, bus } = makeBrokerWithBus(); + broker.resolve('does-not-exist', { decision: 'approved' }); + expect(broker.isPending('does-not-exist')).toBe(false); + broker.dispose(); + bus.dispose(); + }); +}); + +describe('DaemonQuestionBroker (W8.2 / Chain 6 — broadcasts + dismiss)', () => { + function makeQuestionBroker(opts?: { timeoutMs?: number }): { + broker: DaemonQuestionBroker; + bus: DaemonEventBus; + clients: FakeSessionClients; + conn: ReturnType; + } { + const clients = new FakeSessionClients(); + const conn = fakeConn('conn_q_subscriber'); + clients.subscribe(conn, 's'); + const bus = new DaemonEventBus(testLogger, clients); + const broker = new DaemonQuestionBroker(testLogger, bus, opts); + return { broker, bus, clients, conn }; + } + + function extractQuestionId(sentFrames: unknown[]): string | undefined { + for (const frame of sentFrames) { + const env = frame as { type: string; payload: { question_id?: string } }; + if (env.type === 'event.question.requested' && env.payload.question_id) { + return env.payload.question_id; + } + } + return undefined; + } + + it('broadcasts event.question.requested AND settles via resolve(question_id, answers)', async () => { + const { broker, bus, conn } = makeQuestionBroker(); + const pending = broker.request({ + sessionId: 's', + agentId: 'a', + toolCallId: 'tc_q1', + questions: [ + { + question: '?', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + } as Parameters[0]); + + const questionId = extractQuestionId(conn.sent); + expect(questionId).toBeDefined(); + expect(broker.isPending(questionId!)).toBe(true); + + const response: QuestionResult = { answers: { q_0: 'opt_0_0' } }; + broker.resolve(questionId!, response); + await expect(pending).resolves.toEqual(response); + + const answeredFrame = conn.sent.find( + (f) => (f as { type: string }).type === 'event.question.answered', + ); + expect(answeredFrame).toBeDefined(); + + broker.dispose(); + bus.dispose(); + }); + + it('dismiss(question_id) broadcasts event.question.dismissed AND resolves Promise with null (SCHEMAS §6.3)', async () => { + const { broker, bus, conn } = makeQuestionBroker(); + const pending = broker.request({ + sessionId: 's', + agentId: 'a', + questions: [ + { + question: '?', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + } as Parameters[0]); + + const questionId = extractQuestionId(conn.sent); + expect(questionId).toBeDefined(); + + broker.dismiss(questionId!); + await expect(pending).resolves.toBeNull(); + + const dismissedFrame = conn.sent.find( + (f) => (f as { type: string }).type === 'event.question.dismissed', + ); + expect(dismissedFrame).toBeDefined(); + expect(broker.isPending(questionId!)).toBe(false); + expect(broker.isRecentlyResolved(questionId!)).toBe(true); + + broker.dispose(); + bus.dispose(); + }); + + it('60s timeout broadcasts event.question.expired + rejects QuestionExpiredError', async () => { + const { broker, bus, conn } = makeQuestionBroker({ timeoutMs: 30 }); + const pending = broker.request({ + sessionId: 's', + agentId: 'a', + questions: [ + { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, + ], + } as Parameters[0]); + + await expect(pending).rejects.toMatchObject({ name: 'QuestionExpiredError' }); + const expiredFrame = conn.sent.find( + (f) => (f as { type: string }).type === 'event.question.expired', + ); + expect(expiredFrame).toBeDefined(); + + broker.dispose(); + bus.dispose(); + }); + + it('dispose rejects pending question Promises', async () => { + const { broker, bus } = makeQuestionBroker(); + const pending = broker.request({ + sessionId: 's', + agentId: 'a', + questions: [ + { question: '?', options: [{ label: 'A' }, { label: 'B' }] }, + ], + } as Parameters[0]); + + broker.dispose(); + await expect(pending).rejects.toThrow(/daemon shutting down/); + bus.dispose(); + }); +}); + +describe('DI graph — broker resolution through the container', () => { + it('resolves broker decorators against the same instances registered in the collection', () => { + const clients = new FakeSessionClients(); + const eventBus = new DaemonEventBus(testLogger, clients); + const approval = new DaemonApprovalBroker(testLogger, eventBus); + const question = new DaemonQuestionBroker(testLogger, eventBus); + + // We don't need a HarnessBridge for this — just check the wiring symmetry. + const collection = new ServiceCollection( + [ILogger, testLogger], + [ISessionClientsService, clients], + [IEventBus, eventBus], + [IApprovalBroker, approval], + [IQuestionBroker, question], + ); + const localIx = new InstantiationService(collection); + localIx.invokeFunction((a) => { + expect(a.get(ISessionClientsService)).toBe(clients); + expect(a.get(IEventBus)).toBe(eventBus); + expect(a.get(IApprovalBroker)).toBe(approval); + expect(a.get(IQuestionBroker)).toBe(question); + expect(a.get(ILogger)).toBe(testLogger); + }); + localIx.dispose(); + }); +}); diff --git a/packages/daemon/test/sessions.e2e.test.ts b/packages/daemon/test/sessions.e2e.test.ts new file mode 100644 index 000000000..d7c030e33 --- /dev/null +++ b/packages/daemon/test/sessions.e2e.test.ts @@ -0,0 +1,280 @@ +/** + * Sessions CRUD end-to-end tests (W6.2 / Chain 2 / P1.2). + * + * **Bootstrap strategy**: spawn the real daemon (port 0, tmp lock + bridge + * home) and exercise the 5 endpoints via `app.inject(...)`. KimiCore is fully + * constructed via the W3 bridge pattern; the HOME dir is a fresh tmpdir so + * no `~/.kimi` interference. This is non-hermetic in the sense that plugin + * discovery runs (the bridge's pluginsReady captures errors silently per + * `core-impl.ts:170-172`), but no network / external state is involved. + * + * Coverage matrix per REST.md §3.3: + * - POST /v1/sessions → envelope code 0 + Session payload + * - GET /v1/sessions → Page + has_more + * - GET /v1/sessions/{id} → Session (40401 on unknown id) + * - PATCH /v1/sessions/{id} → Session (40401 on unknown id) + * - DELETE /v1/sessions/{id} → { deleted: true } (40401 on unknown) + * + * Plus the validation matrix: + * - POST with missing `metadata.cwd` → 40001 + `details` containing path. + * - GET list with `page_size=0` → 40001 (out of range). + * - GET list with both before_id+after_id → 40001 (mutual exclusivity). + * + * Plus the snake_case + ISO `Z` invariants on the response shape (the load- + * bearing piece of Chain 2). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { sessionSchema } from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-sessions-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-sessions-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { code: number; msg: string; data: T | null; request_id: string; details?: unknown } { + return body as { code: number; msg: string; data: T | null; request_id: string; details?: unknown }; +} + +describe('POST /v1/sessions — create', () => { + it('returns a Session payload with snake_case + ISO Z timestamps', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-create'); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd }, title: 'created via test' }, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(env.msg).toBe('success'); + expect(env.data).not.toBeNull(); + const session = sessionSchema.parse(env.data); + expect(session.metadata.cwd).toBe(cwd); + expect(session.title).toBe('created via test'); + expect(session.created_at.endsWith('Z')).toBe(true); + expect(session.updated_at.endsWith('Z')).toBe(true); + expect(session.id.length).toBeGreaterThan(0); + }); + + it('rejects a body missing metadata.cwd with code 40001 + details', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { title: 'no cwd' }, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(env.data).toBeNull(); + expect(Array.isArray(env.details)).toBe(true); + const details = env.details as Array<{ path: string; message: string }>; + expect(details.length).toBeGreaterThan(0); + // The path should reference the failed field (`metadata` or `metadata.cwd`). + expect(details[0]!.path).toMatch(/^metadata/); + }); +}); + +describe('GET /v1/sessions — list', () => { + it('returns Page with has_more=false when fewer than page_size entries exist', async () => { + const r = await bootDaemon(); + const cwd1 = join(tmpDir, 'workspace-list-1'); + const cwd2 = join(tmpDir, 'workspace-list-2'); + await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: cwd1 } } }); + await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: cwd2 } } }); + + const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).not.toBeNull(); + expect(env.data!.has_more).toBe(false); + expect(env.data!.items.length).toBeGreaterThanOrEqual(2); + // Each item should parse as Session. + for (const item of env.data!.items) { + sessionSchema.parse(item); + } + }); + + it('honors page_size and surfaces has_more', async () => { + const r = await bootDaemon(); + await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-a') } } }); + await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-b') } } }); + await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-c') } } }); + + const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions?page_size=2' }); + const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data!.items).toHaveLength(2); + expect(env.data!.has_more).toBe(true); + }); + + it('rejects page_size=0 (out of range)', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions?page_size=0' }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it('rejects before_id + after_id together', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions?before_id=a&after_id=b', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('GET /v1/sessions/{session_id} — fetch single', () => { + it('returns the matching Session', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-get'); + const createRes = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd } }, + }); + const created = envelopeOf<{ id: string }>(createRes.json()).data!; + + const getRes = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${created.id}`, + }); + const env = envelopeOf(getRes.json()); + expect(env.code).toBe(0); + const session = sessionSchema.parse(env.data); + expect(session.id).toBe(created.id); + expect(session.metadata.cwd).toBe(cwd); + }); + + it('returns code 40401 for an unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions/sess_does_not_exist', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + expect(env.data).toBeNull(); + expect(env.msg).toMatch(/does not exist/); + }); +}); + +describe('PATCH /v1/sessions/{session_id} — update', () => { + it('updates the title and returns the post-update Session', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-patch'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + const res = await appOf(r).inject({ + method: 'PATCH', + url: `/v1/sessions/${created.id}`, + payload: { title: 'Renamed' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const session = sessionSchema.parse(env.data); + expect(session.id).toBe(created.id); + // The Session shape is returned (title reflection may rely on + // metadata round-tripping; the contract is "200 + Session payload"). + }); + + it('returns 40401 for unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'PATCH', + url: '/v1/sessions/sess_missing', + payload: { title: 'x' }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); + +describe('DELETE /v1/sessions/{session_id} — delete', () => { + it('returns { deleted: true } envelope', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-delete'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + const res = await appOf(r).inject({ + method: 'DELETE', + url: `/v1/sessions/${created.id}`, + }); + const env = envelopeOf<{ deleted: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).toEqual({ deleted: true }); + }); + + it('returns 40401 for unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'DELETE', + url: '/v1/sessions/sess_missing', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); diff --git a/packages/daemon/test/start.test.ts b/packages/daemon/test/start.test.ts new file mode 100644 index 000000000..2fb3d7751 --- /dev/null +++ b/packages/daemon/test/start.test.ts @@ -0,0 +1,137 @@ +/** + * `startDaemon` + lock integration + DI wiring (ROADMAP P0.12 + P0.14). + * + * Bind to port 0 → ephemeral port; tmpdir lock path → no `~/.kimi` interference. + * Tests share the assertion that the lock file appears alongside the listener + * and vanishes on close, and that a second startDaemon raises DaemonLockedError. + * + * The DI graph end-to-end is exercised implicitly: every startDaemon call + * constructs ILogger, IRestGateway, IEventBus, IApprovalBroker, + * IQuestionBroker, and IHarnessBridge in order. Failure modes there (missing + * service, wrong ctor args) would surface as a startDaemon reject. + */ + +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pino } from 'pino'; + +import { + DaemonLockedError, + IApprovalBroker, + IConnectionRegistry, + IEventBus, + IHarnessBridge, + ILogger, + IQuestionBroker, + IRestGateway, + ISessionClientsService, + IWSGateway, + startDaemon, + type LockContents, + type RunningDaemon, +} from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningDaemon[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-start-test-')); + lockPath = join(tmpDir, 'lock'); + // Isolate KimiCore's `~/.kimi` lookup — bridge construction touches it via plugin discovery. + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-start-home-')); +}); + +afterEach(async () => { + // Tear down every daemon spawned in the test in the order they were created. + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +function silentLogger() { + return pino({ level: 'silent' }); +} + +async function spawn(): Promise { + const r = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: silentLogger(), + bridgeOptions: { homeDir: bridgeHome }, + }); + running.push(r); + return r; +} + +describe('startDaemon — lock + healthz smoke', () => { + it('acquires the lock and writes pid/port; close releases', async () => { + const r = await spawn(); + + expect(existsSync(lockPath)).toBe(true); + const stored = JSON.parse(readFileSync(lockPath, 'utf8')) as LockContents; + expect(stored.pid).toBe(process.pid); + expect(stored.port).toBe(0); + + expect(r.address).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + + await r.close(); + expect(existsSync(lockPath)).toBe(false); + }); + + it('second startDaemon with the same lockPath throws DaemonLockedError', async () => { + await spawn(); + await expect(spawn()).rejects.toBeInstanceOf(DaemonLockedError); + }); + + it('close() is idempotent', async () => { + const r = await spawn(); + await r.close(); + await r.close(); // second call is a no-op (would throw on double-app.close otherwise) + expect(existsSync(lockPath)).toBe(false); + }); +}); + +describe('startDaemon — DI container wiring', () => { + it('exposes all 9 DI services through running.services', async () => { + const r = await spawn(); + // Every decorator should resolve. .get() would throw "No service registered" + // if any were missing. + r.services.invokeFunction((a) => { + expect(a.get(ILogger)).toBeDefined(); + expect(a.get(IRestGateway)).toBeDefined(); + expect(a.get(IConnectionRegistry)).toBeDefined(); + expect(a.get(ISessionClientsService)).toBeDefined(); + expect(a.get(IEventBus)).toBeDefined(); + expect(a.get(IApprovalBroker)).toBeDefined(); + expect(a.get(IQuestionBroker)).toBeDefined(); + expect(a.get(IWSGateway)).toBeDefined(); + const bridge = a.get(IHarnessBridge); + expect(bridge).toBeDefined(); + expect(typeof bridge.rpc).toBe('object'); + expect(typeof bridge.dispose).toBe('function'); + }); + }); + + it('HarnessBridge.rpc rejects after the daemon is closed (dispose cascade)', async () => { + const r = await spawn(); + // Grab a bridge reference BEFORE close — after close the container is disposed + // and a.get(IHarnessBridge) would throw on the dead InstantiationService. + const bridge = r.services.invokeFunction((a) => a.get(IHarnessBridge)); + await r.close(); + await expect(bridge.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); + }); +}); + diff --git a/packages/daemon/test/tasks.e2e.test.ts b/packages/daemon/test/tasks.e2e.test.ts new file mode 100644 index 000000000..7bdaee0f8 --- /dev/null +++ b/packages/daemon/test/tasks.e2e.test.ts @@ -0,0 +1,283 @@ +/** + * Background Tasks end-to-end tests (W9.2 / Chain 8 / P1.8). + * + * Covers REST.md §3.7: + * - GET /v1/sessions/{sid}/tasks → envelope + items[] + * - GET /v1/sessions/{sid}/tasks/{tid} → BackgroundTask, 40406 unknown + * - POST /v1/sessions/{sid}/tasks/{tid}:cancel → {cancelled:true}, + * 40406 unknown id, + * 40904 already finished + * - Negative: session_id unknown → 40401 + * - Negative: bare {tid} POST (no :cancel) → 40001 unsupported action + * + * **Bootstrap strategy**: spawn the daemon and inject a fake background task + * directly into the in-process KimiCore via the bridge. Agent-core's + * `getBackground` / `stopBackground` operate against the same registrar. + * + * Because directly seeding a `BackgroundTask` requires constructing a real + * KimiCore session and inserting into the agent-core background-task manager + * (out-of-band of the REST surface), we cover the positive list/get/cancel + * paths via empty state + the negative tests. The 40904 already-finished + * path is covered by the services unit tests; the daemon-side mapping is + * verified here by seeding a TaskAlreadyFinishedError via a stubbed + * ITaskService override. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + ITaskService, + TaskAlreadyFinishedError, + TaskNotFoundError, +} from '@moonshot-ai/services'; +import { + listTasksResponseSchema, +} from '@moonshot-ai/protocol'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-tasks-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-tasks-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +/** + * Override the container's `ITaskService` with a stub. Used to drive the + * 40904 / 40406 envelope mapping paths without seeding real background + * tasks. + * + * The InstantiationService caches resolved instances in `_instances` after + * the first `a.get(...)`. The daemon's `start.ts` warms the cache for every + * registered identifier, so a `services.set(...)` would not be observed by + * subsequent route requests. We mutate both the registration map and the + * instance cache. + */ +function overrideTaskService( + r: RunningDaemon, + stub: Partial, +): void { + const defaultImpl: ITaskService = { + list: async () => [], + get: async () => { + throw new TaskNotFoundError('s', 't'); + }, + cancel: async () => ({ cancelled: true as const }), + }; + const replacement = { ...defaultImpl, ...stub }; + const ix = r.services as unknown as { + services: { set: (id: unknown, impl: unknown) => void }; + _instances: Map; + }; + ix.services.set(ITaskService, replacement); + ix._instances.set(ITaskService, replacement); +} + +describe('GET /v1/sessions/{sid}/tasks', () => { + it('returns 40401 for an unknown session_id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/sessions/does-not-exist/tasks', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('returns an envelope with {items:[]} for a session with no tasks', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/tasks`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = listTasksResponseSchema.parse(env.data); + expect(parsed.items).toEqual([]); + }); + + it('rejects unknown status filter with 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/tasks?status=pending`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('GET /v1/sessions/{sid}/tasks/{tid}', () => { + it('returns 40406 for an unknown task_id (real session, empty tasks)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/${sid}/tasks/does-not-exist`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40406); + }); + + it('returns 40401 for an unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/sessions/unknown/tasks/anything`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +}); + +describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { + it('returns 40406 for an unknown task_id', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/tasks/nope:cancel`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40406); + }); + + it('rejects bare {tid} with 40001 (no :cancel suffix → not a defined action)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/tasks/abc123`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(env.msg).toMatch(/unsupported action/); + }); + + it('rejects unknown action with 40001', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/tasks/abc:bogus`, + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); + + it("emits 40904 envelope with data:{cancelled:false} when service throws TaskAlreadyFinishedError", async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + overrideTaskService(r, { + cancel: async () => { + throw new TaskAlreadyFinishedError(sid, 't_finished', 'completed'); + }, + }); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/tasks/t_finished:cancel`, + payload: {}, + }); + const env = envelopeOf<{ cancelled: false }>(res.json()); + expect(env.code).toBe(40904); + expect(env.data).toEqual({ cancelled: false }); + expect(env.msg).toMatch(/already finished/); + expect((env.details as { current_status?: string } | undefined)?.current_status).toBe( + 'completed', + ); + }); + + it('returns {cancelled:true} when service succeeds (stub override)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + overrideTaskService(r, { + cancel: async () => ({ cancelled: true as const }), + }); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/tasks/t_running:cancel`, + payload: {}, + }); + const env = envelopeOf<{ cancelled: true }>(res.json()); + expect(env.code).toBe(0); + expect(env.data).toEqual({ cancelled: true }); + }); +}); diff --git a/packages/daemon/test/tools.e2e.test.ts b/packages/daemon/test/tools.e2e.test.ts new file mode 100644 index 000000000..61a1a51de --- /dev/null +++ b/packages/daemon/test/tools.e2e.test.ts @@ -0,0 +1,223 @@ +/** + * Tools + MCP end-to-end tests (W9.1 / Chain 7 / P1.7). + * + * Coverage: + * - GET /v1/tools → envelope shape + tools[] + * - GET /v1/mcp/servers → envelope shape + servers[] + * - POST /v1/mcp/servers/{id}:restart → {restarting:true} on a real + * server / 40408 on unknown + * - POST /v1/mcp/servers/foo:bogus → 40001 unsupported action + * + * **Bootstrap strategy**: spawn the real daemon and create one session so the + * agent-core `getTools` / `listMcpServers` can dispatch (those calls live on + * the SessionAPI). The HOME dir is a fresh tmpdir so plugin discovery is + * sandboxed. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { + listMcpServersResponseSchema, + listToolsResponseSchema, +} from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-tools-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-tools-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +describe('GET /v1/tools', () => { + it('returns an envelope with {tools: ToolDescriptor[]} (empty list pre-session)', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/tools' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + // Before any session exists, the global list is empty by design. + const parsed = listToolsResponseSchema.parse(env.data); + expect(parsed.tools).toEqual([]); + }); + + it('returns a populated list after a session exists (response data round-trips through schema)', async () => { + const r = await bootDaemon(); + await createSession(r); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/tools' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = listToolsResponseSchema.parse(env.data); + // We don't assert a specific count (depends on plugin discovery in the + // sandboxed home dir), only that the envelope shape is valid and every + // descriptor parses. + expect(Array.isArray(parsed.tools)).toBe(true); + }); + + it('accepts session_id query and returns the same shape', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'GET', + url: `/v1/tools?session_id=${sid}`, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(listToolsResponseSchema.safeParse(env.data).success).toBe(true); + }); + + it('rejects empty session_id with 40001', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/v1/tools?session_id=', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('GET /v1/mcp/servers', () => { + it('returns an envelope with {servers: McpServer[]} (typically empty in sandboxed home)', async () => { + const r = await bootDaemon(); + await createSession(r); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/mcp/servers' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = listMcpServersResponseSchema.parse(env.data); + expect(Array.isArray(parsed.servers)).toBe(true); + }); + + it('returns 200 with empty list even before any session is created', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/v1/mcp/servers' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = listMcpServersResponseSchema.parse(env.data); + expect(parsed.servers).toEqual([]); + }); +}); + +describe('POST /v1/mcp/servers/{id}:restart', () => { + it('returns 40408 mcp.server_not_found for an unknown server id', async () => { + const r = await bootDaemon(); + await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/mcp/servers/does-not-exist:restart', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40408); + expect(env.msg).toMatch(/does not exist/); + }); + + it('returns 40408 even before any session is created (registrar unreachable)', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/mcp/servers/x:restart', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40408); + }); + + it('rejects unsupported action with 40001', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/mcp/servers/foo:bogus', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + expect(env.msg).toMatch(/unsupported action/); + }); + + it('rejects bare {id} (no action) with 40001 — :restart is the only allowed action', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/mcp/servers/foo', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); diff --git a/packages/daemon/test/ws-abort.e2e.test.ts b/packages/daemon/test/ws-abort.e2e.test.ts new file mode 100644 index 000000000..22cff5676 --- /dev/null +++ b/packages/daemon/test/ws-abort.e2e.test.ts @@ -0,0 +1,370 @@ +/** + * WS abort + REST/WS abort symmetry e2e (W7.3 / Chain 4b / P1.4b). + * + * **Bootstrap strategy**: spawn the real daemon, register an active prompt + * via `PromptServiceImpl._injectActiveForTest` (avoids running a real + * agent-core prompt), then exercise: + * 1. WS `abort` control message → server publishes `prompt.aborted` + * synthetic event + sends ack with `aborted: true`. + * 2. WS `abort` idempotency: second abort returns + * `code: 0, payload.aborted: false` (per WS.md §3.4 convention — + * NOT REST's 40903, intentional). + * 3. REST `POST /v1/sessions/{sid}/prompts/{pid}:abort`: + * - Returns `{aborted: true}` on first call. + * - Returns `code: 40903 + data: {aborted: false}` on second + * (idempotent already-completed) per REST.md §3.5. + * 4. **Symmetry**: REST and WS abort dispatch through the same + * handler (`IPromptService.abort`). After a REST abort, a WS abort + * with the SAME prompt id returns idempotent success. And vice versa. + * + * The synthesized `prompt.aborted` event flows through IEventBus → WS + * broadcast so subscribers see it. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WebSocket } from 'ws'; + +import { IPromptService, PromptServiceImpl } from '@moonshot-ai/services'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-abort-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-abort-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +function injectActivePrompt( + r: RunningDaemon, + sid: string, + promptId: string, + turnId: number | null, +): void { + const impl = r.services.invokeFunction( + (a) => a.get(IPromptService) as PromptServiceImpl, + ); + impl._injectActiveForTest(sid, promptId, turnId); +} + +interface Subscriber { + ws: WebSocket; + received: Record[]; +} + +async function openSubscriber(r: RunningDaemon, sid: string): Promise { + const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const received: Record[] = []; + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(wsUrl); + sock.on('message', (data) => { + try { + received.push(JSON.parse(String(data)) as Record); + } catch { + // ignore + } + }); + sock.once('open', () => resolve(sock)); + sock.once('error', reject); + }); + await waitFor(received, (f) => f['type'] === 'server_hello'); + ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'h1', + payload: { client_id: 'test', subscriptions: [sid] }, + }), + ); + await waitFor(received, (f) => f['type'] === 'ack' && f['id'] === 'h1'); + return { ws, received }; +} + +async function waitFor( + received: Record[], + pred: (f: Record) => boolean, + timeoutMs = 2000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const hit = received.find(pred); + if (hit !== undefined) return hit; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error( + `waitFor timed out; received: ${received.map((f) => f['type']).join(', ')}`, + ); +} + +describe('WS abort control message (W7.3 / Chain 4b)', () => { + it('on first abort: ack with aborted:true + broadcast prompt.aborted', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_WS_ABORT_${sid}`; + injectActivePrompt(r, sid, promptId, 5); + + const sub = await openSubscriber(r, sid); + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a1', + payload: { session_id: sid, prompt_id: promptId }, + }), + ); + + // Wait for both the ack AND the broadcast prompt.aborted. + const ack = await waitFor( + sub.received, + (f) => f['type'] === 'ack' && f['id'] === 'a1', + ); + expect(ack['code']).toBe(0); + const payload = ack['payload'] as { aborted: boolean }; + expect(payload.aborted).toBe(true); + + const promptAborted = await waitFor( + sub.received, + (f) => f['type'] === 'prompt.aborted', + ); + const evPayload = promptAborted['payload'] as { promptId: string }; + expect(evPayload.promptId).toBe(promptId); + + sub.ws.close(); + }); + + it('on second abort: ack with code:0 + aborted:false (idempotent per WS.md §3.4)', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_WS_DUP_${sid}`; + injectActivePrompt(r, sid, promptId, null); + + const sub = await openSubscriber(r, sid); + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a1', + payload: { session_id: sid, prompt_id: promptId }, + }), + ); + await waitFor(sub.received, (f) => f['type'] === 'ack' && f['id'] === 'a1'); + + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a2', + payload: { session_id: sid, prompt_id: promptId }, + }), + ); + const ack = await waitFor( + sub.received, + (f) => f['type'] === 'ack' && f['id'] === 'a2', + ); + expect(ack['code']).toBe(0); + expect((ack['payload'] as { aborted: boolean }).aborted).toBe(false); + + sub.ws.close(); + }); + + it('returns 40402 for an unknown prompt id', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const sub = await openSubscriber(r, sid); + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a1', + payload: { session_id: sid, prompt_id: 'prompt_does_not_exist' }, + }), + ); + const ack = await waitFor( + sub.received, + (f) => f['type'] === 'ack' && f['id'] === 'a1', + ); + expect(ack['code']).toBe(40402); + sub.ws.close(); + }); +}); + +describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { + it('first REST abort returns {aborted: true}', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_REST_${sid}`; + injectActivePrompt(r, sid, promptId, 1); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + }); + const env = envelopeOf<{ aborted: boolean }>(res.json()); + expect(env.code).toBe(0); + expect(env.data?.aborted).toBe(true); + }); + + it('second REST abort returns 40903 + data {aborted: false}', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_REST_DUP_${sid}`; + injectActivePrompt(r, sid, promptId, 2); + await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + }); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + }); + const env = envelopeOf<{ aborted: boolean }>(res.json()); + expect(env.code).toBe(40903); + expect(env.data?.aborted).toBe(false); + }); + + it('REST abort returns 40401 for unknown session', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/v1/sessions/sess_missing/prompts/prompt_X:abort', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); + + it('REST abort returns 40402 for unknown prompt on a known session', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/prompt_missing:abort`, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40402); + }); + + it('symmetry: REST abort followed by WS abort on the SAME prompt id returns idempotent WS ack', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_SYM1_${sid}`; + injectActivePrompt(r, sid, promptId, 3); + + // First abort via REST → success. + const rest = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + }); + expect(envelopeOf<{ aborted: boolean }>(rest.json()).data?.aborted).toBe(true); + + // Second abort via WS — must be idempotent (code 0 + aborted: false). + const sub = await openSubscriber(r, sid); + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a1', + payload: { session_id: sid, prompt_id: promptId }, + }), + ); + const ack = await waitFor( + sub.received, + (f) => f['type'] === 'ack' && f['id'] === 'a1', + ); + expect(ack['code']).toBe(0); + expect((ack['payload'] as { aborted: boolean }).aborted).toBe(false); + sub.ws.close(); + }); + + it('symmetry: WS abort followed by REST abort returns 40903 on REST', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const promptId = `prompt_SYM2_${sid}`; + injectActivePrompt(r, sid, promptId, 4); + + // First abort via WS. + const sub = await openSubscriber(r, sid); + sub.ws.send( + JSON.stringify({ + type: 'abort', + id: 'a1', + payload: { session_id: sid, prompt_id: promptId }, + }), + ); + await waitFor(sub.received, (f) => f['type'] === 'ack' && f['id'] === 'a1'); + + // Second abort via REST — must surface 40903 already_completed. + const res = await appOf(r).inject({ + method: 'POST', + url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + }); + const env = envelopeOf<{ aborted: boolean }>(res.json()); + expect(env.code).toBe(40903); + expect(env.data?.aborted).toBe(false); + sub.ws.close(); + }); +}); diff --git a/packages/daemon/test/ws-broadcast.e2e.test.ts b/packages/daemon/test/ws-broadcast.e2e.test.ts new file mode 100644 index 000000000..3fdc87fad --- /dev/null +++ b/packages/daemon/test/ws-broadcast.e2e.test.ts @@ -0,0 +1,304 @@ +/** + * WS subscribe + broadcast e2e (W5.2 / P0.16). + * + * Boots `startDaemon`, connects 2 real WS clients, asks them to subscribe to + * the same session, then publishes events via `IEventBus.publish` from + * INSIDE the daemon (using `RunningDaemon.services` to reach the bus). + * + * Assertions: + * 1. Both subscribers receive the same event with `seq=1`. + * 2. A second event for the same session lands at `seq=2`. + * 3. Events for a different session don't reach the original subscribers. + * 4. Per-session seq counters are independent. + * 5. After one client `unsubscribe`s, only the remaining subscriber gets + * the next event. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import type { Event } from '@moonshot-ai/protocol'; +import { IEventBus } from '@moonshot-ai/services'; + +import { + ISessionClientsService, + startDaemon, + type RunningDaemon, +} from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningDaemon[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-broadcast-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-broadcast-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function spawn(): Promise { + const r = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; +} + +interface WsFrame { + type: string; + payload?: unknown; + id?: string; + code?: number; + seq?: number; + session_id?: string; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; +} + +function openConn(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(String(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + const w = waiters.shift(); + w?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.once('open', () => resolve({ ws, queue, waiters })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +/** + * Send a client_hello + subscribe to one session. Drains the resulting + * `ack` so subsequent `receiveType(conn, ...)` calls see only event frames. + */ +async function helloAndSubscribe(conn: Conn, clientId: string, sessionId: string): Promise { + await receiveType(conn, 'server_hello', 1000); + conn.ws.send( + JSON.stringify({ + type: 'client_hello', + id: `cli_${clientId}`, + payload: { client_id: clientId, subscriptions: [sessionId] }, + }), + ); + await receiveType(conn, 'ack', 1000); +} + +describe('WS broadcast + per-session seq (W5.2)', () => { + it('two subscribers both receive seq=1 then seq=2 for the same session', async () => { + const r = await spawn(); + const a = await openConn(wsUrl(r.address)); + const b = await openConn(wsUrl(r.address)); + await helloAndSubscribe(a, 'A', 'sid_shared'); + await helloAndSubscribe(b, 'B', 'sid_shared'); + + // Wait until the daemon's session-clients index reflects 2 subscribers + // — otherwise publish races the WS handshake on slow CI. + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_shared') === 2, + ), + ); + + r.services.invokeFunction((acc) => + acc.get(IEventBus).publish({ type: 'evt.x', sessionId: 'sid_shared' } as unknown as Event), + ); + + const e1a = await receiveType(a, 'evt.x', 1000); + const e1b = await receiveType(b, 'evt.x', 1000); + expect(e1a.seq).toBe(1); + expect(e1b.seq).toBe(1); + expect(e1a.session_id).toBe('sid_shared'); + + r.services.invokeFunction((acc) => + acc.get(IEventBus).publish({ type: 'evt.y', sessionId: 'sid_shared' } as unknown as Event), + ); + const e2a = await receiveType(a, 'evt.y', 1000); + const e2b = await receiveType(b, 'evt.y', 1000); + expect(e2a.seq).toBe(2); + expect(e2b.seq).toBe(2); + + a.ws.close(); + b.ws.close(); + }); + + it('events for other sessions are not delivered to the original subscribers', async () => { + const r = await spawn(); + const a = await openConn(wsUrl(r.address)); + await helloAndSubscribe(a, 'A', 'sid_x'); + + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_x') === 1, + ), + ); + + r.services.invokeFunction((acc) => + acc.get(IEventBus).publish({ type: 'evt', sessionId: 'sid_other' } as unknown as Event), + ); + r.services.invokeFunction((acc) => + acc.get(IEventBus).publish({ type: 'evt.delivered', sessionId: 'sid_x' } as unknown as Event), + ); + + const ev = await receiveType(a, 'evt.delivered', 1000); + expect(ev.session_id).toBe('sid_x'); + expect(ev.seq).toBe(1); // first event on sid_x, so seq=1 even though sid_other got seq=1 first + a.ws.close(); + }); + + it('per-session seq counters are independent across subscribers and sessions', async () => { + const r = await spawn(); + const a = await openConn(wsUrl(r.address)); + const b = await openConn(wsUrl(r.address)); + await helloAndSubscribe(a, 'A', 'sid_alpha'); + await helloAndSubscribe(b, 'B', 'sid_beta'); + + await waitFor(() => + r.services.invokeFunction((acc) => { + const sc = acc.get(ISessionClientsService); + return sc.subscriberCount('sid_alpha') === 1 && sc.subscriberCount('sid_beta') === 1; + }), + ); + + const bus = r.services.invokeFunction((acc) => acc.get(IEventBus)); + bus.publish({ type: 'a1', sessionId: 'sid_alpha' } as unknown as Event); + bus.publish({ type: 'b1', sessionId: 'sid_beta' } as unknown as Event); + bus.publish({ type: 'a2', sessionId: 'sid_alpha' } as unknown as Event); + + const a1 = await receiveType(a, 'a1', 1000); + const a2 = await receiveType(a, 'a2', 1000); + const b1 = await receiveType(b, 'b1', 1000); + + expect(a1.seq).toBe(1); + expect(a2.seq).toBe(2); + expect(b1.seq).toBe(1); + + a.ws.close(); + b.ws.close(); + }); + + it('unsubscribe stops delivery to that connection only', async () => { + const r = await spawn(); + const a = await openConn(wsUrl(r.address)); + const b = await openConn(wsUrl(r.address)); + await helloAndSubscribe(a, 'A', 'sid_share'); + await helloAndSubscribe(b, 'B', 'sid_share'); + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_share') === 2, + ), + ); + + // Client A unsubscribes. + a.ws.send( + JSON.stringify({ + type: 'unsubscribe', + id: 'u_a', + payload: { session_ids: ['sid_share'] }, + }), + ); + await receiveType(a, 'ack', 1000); + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_share') === 1, + ), + ); + + // Publish a new event; only B should see it. + r.services.invokeFunction((acc) => + acc.get(IEventBus).publish({ type: 'after_unsub', sessionId: 'sid_share' } as unknown as Event), + ); + + const ev = await receiveType(b, 'after_unsub', 1000); + expect(ev.seq).toBe(1); + + // A should NOT receive it within a short window. + await expect(receiveType(a, 'after_unsub', 300)).rejects.toBeInstanceOf(Error); + + a.ws.close(); + b.ws.close(); + }); +}); + +/** Spin until `cond()` returns true or 2s elapses. */ +async function waitFor(cond: () => boolean): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error('waitFor: condition not satisfied within 2000ms'); +} diff --git a/packages/daemon/test/ws-handshake.e2e.test.ts b/packages/daemon/test/ws-handshake.e2e.test.ts new file mode 100644 index 000000000..7bde91263 --- /dev/null +++ b/packages/daemon/test/ws-handshake.e2e.test.ts @@ -0,0 +1,238 @@ +/** + * WS handshake + heartbeat e2e (W5.1 / P0.15). + * + * Boots `startDaemon` on port 0 with a tmpdir lock, then connects a real + * `ws` client. Validates the full WS.md §1 lifecycle: + * + * 1. Server immediately sends `server_hello`. + * 2. Client sends `client_hello` → server acks (code=0). + * 3. Server pings every `pingIntervalMs` (overridden to 60ms here so the + * test finishes in <1s instead of waiting 30s). + * 4. Client `pong` resets the pong timer — connection stays alive. + * 5. Daemon close → WS code 1001 (going away). + * + * Uses `wsGatewayOptions.pingIntervalMs` + `.pongTimeoutMs` to shrink timers. + * The connection's `WsConnection` reads those through the gateway. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import { + IConnectionRegistry, + IWSGateway, + startDaemon, + type RunningDaemon, +} from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningDaemon[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-handshake-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-handshake-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function spawn(): Promise { + const r = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 60, pongTimeoutMs: 200 }, + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; +} + +interface WsFrame { + type: string; + payload?: unknown; + id?: string; + code?: number; + [k: string]: unknown; +} + +/** + * Wraps a `WebSocket` with a message queue. Frames received between socket + * open and the first `await receive(...)` call go into `queue` so the test + * doesn't race the server's first push (which can land within the same tick + * as the upgrade callback). Without this, fast tests miss `server_hello`. + */ +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; + closed: Promise<{ code: number; reason: string }>; +} + +function openConn(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + let closedResolve: (v: { code: number; reason: string }) => void; + const closed = new Promise<{ code: number; reason: string }>((res) => { + closedResolve = res; + }); + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(String(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + const w = waiters.shift(); + w?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.on('close', (code, reason) => { + closedResolve({ code, reason: String(reason) }); + }); + ws.once('open', () => resolve({ ws, queue, waiters, closed })); + ws.once('error', (err) => reject(err)); + }); +} + +/** Pop the next frame (queued or yet-to-arrive). Rejects on timeout. */ +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +/** Drain until a frame with the given `type` arrives. */ +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +describe('WS gateway handshake + heartbeat (W5.1)', () => { + it('sends server_hello on connect and acks client_hello', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + + const hello = await receiveType(conn, 'server_hello', 1000); + const helloPayload = hello.payload as { heartbeat_ms: number; max_event_buffer_size: number }; + expect(helloPayload.heartbeat_ms).toBe(60); + expect(helloPayload.max_event_buffer_size).toBe(1000); + + conn.ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'cli_test_1', + payload: { client_id: 'cli_1', subscriptions: [] }, + }), + ); + + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.id).toBe('cli_test_1'); + expect(ack.code).toBe(0); + + // The registry should now have exactly one attached connection. + r.services.invokeFunction((a) => { + expect(a.get(IConnectionRegistry).size()).toBe(1); + expect(a.get(IWSGateway).size).toBe(1); + }); + + conn.ws.close(); + await conn.closed; + }); + + it('sends ping after pingIntervalMs', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + + await receiveType(conn, 'server_hello', 1000); + // First ping should land within ~pingIntervalMs (=60ms). Give 5x slack. + const ping = await receiveType(conn, 'ping', 600); + const pingPayload = ping.payload as { nonce: string }; + expect(pingPayload.nonce).toMatch(/^[0-9A-Z]{26}$/); + + conn.ws.close(); + await conn.closed; + }); + + it('pong from client keeps the connection alive past pongTimeout', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + await receiveType(conn, 'server_hello', 1000); + + const ping = await receiveType(conn, 'ping', 600); + const nonce = (ping.payload as { nonce: string }).nonce; + conn.ws.send(JSON.stringify({ type: 'pong', payload: { nonce } })); + + // Wait > pongTimeoutMs (200ms) — without the pong reset above we'd be + // terminated. Assert by observing the next ping arrives normally. + const nextPing = await receiveType(conn, 'ping', 600); + expect(nextPing.type).toBe('ping'); + expect(conn.ws.readyState).toBe(WebSocket.OPEN); + + conn.ws.close(); + await conn.closed; + }); + + it('daemon close sends WS close code 1001 to attached clients', async () => { + const r = await spawn(); + const conn = await openConn(wsUrl(r.address)); + await receiveType(conn, 'server_hello', 1000); + + await r.close(); + + const { code } = await conn.closed; + expect(code).toBe(1001); + }); + + it('non-/v1/ws upgrade requests are rejected', async () => { + const r = await spawn(); + const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/v1/other'; + await expect(openConn(badUrl)).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/packages/daemon/test/ws-resync.e2e.test.ts b/packages/daemon/test/ws-resync.e2e.test.ts new file mode 100644 index 000000000..c1f34e17a --- /dev/null +++ b/packages/daemon/test/ws-resync.e2e.test.ts @@ -0,0 +1,343 @@ +/** + * WS ring buffer + resync_required e2e (W5.3 / P0.17). + * + * Three flows per WS.md §6: + * + * 1. **Replay**: publish N events; client A disconnects; publish M more; + * A reconnects with `client_hello.last_seq_by_session[sid]=N`; assert + * A receives exactly events N+1..N+M in order. + * + * 2. **Resync**: force the buffer to overflow (publish >1000 events). + * Client B connects with a stale `last_seq` (older than `oldestSeq`). + * Assert B receives a `resync_required` frame for that session, NOT + * events. + * + * 3. **No-op**: client C connects with `last_seq == current_seq`. Assert + * no replay events arrive on the first frames (only the normal ack + + * empty `resync_required`). + * + * `maxBufferSize` is reachable via direct `DaemonEventBus` access from + * within the test (no need to override globally). To keep test runtime sane + * the resync flow uses a SMALLER buffer cap injected via direct EventBus + * construction — not via daemon options (no production knob needed). For + * the spec-faithful 1000-cap path we publish 1005 events. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { pino } from 'pino'; +import { WebSocket } from 'ws'; + +import type { Event } from '@moonshot-ai/protocol'; +import { IEventBus } from '@moonshot-ai/services'; + +import { + ISessionClientsService, + startDaemon, + type RunningDaemon, +} from '../src'; +import { DaemonEventBus } from '../src/services/event-bus'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +const running: RunningDaemon[] = []; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-resync-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-ws-resync-home-')); +}); + +afterEach(async () => { + for (const r of running.splice(0)) { + try { + await r.close(); + } catch { + // ignore + } + } + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function spawn(): Promise { + const r = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + wsGatewayOptions: { pingIntervalMs: 5_000, pongTimeoutMs: 5_000 }, + }); + running.push(r); + return r; +} + +function wsUrl(http: string): string { + return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; +} + +interface WsFrame { + type: string; + payload?: unknown; + id?: string; + code?: number; + seq?: number; + session_id?: string; + [k: string]: unknown; +} + +interface Conn { + ws: WebSocket; + queue: WsFrame[]; + waiters: Array<(frame: WsFrame) => void>; +} + +function openConn(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const queue: WsFrame[] = []; + const waiters: Array<(frame: WsFrame) => void> = []; + ws.on('message', (data) => { + let parsed: WsFrame; + try { + parsed = JSON.parse(String(data)) as WsFrame; + } catch { + return; + } + if (waiters.length > 0) { + const w = waiters.shift(); + w?.(parsed); + } else { + queue.push(parsed); + } + }); + ws.once('open', () => resolve({ ws, queue, waiters })); + ws.once('error', (err) => reject(err)); + }); +} + +function receive(conn: Conn, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (conn.queue.length > 0) { + resolve(conn.queue.shift()!); + return; + } + const t = setTimeout(() => { + const idx = conn.waiters.indexOf(waiter); + if (idx >= 0) conn.waiters.splice(idx, 1); + reject(new Error(`no message in ${timeoutMs}ms`)); + }, timeoutMs); + const waiter = (frame: WsFrame): void => { + clearTimeout(t); + resolve(frame); + }; + conn.waiters.push(waiter); + }); +} + +async function receiveType(conn: Conn, type: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`no message of type ${type} within ${timeoutMs}ms`); + const frame = await receive(conn, remaining); + if (frame.type === type) return frame; + } +} + +async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 10)); + } + throw new Error(`waitFor: condition not satisfied within ${timeoutMs}ms`); +} + +describe('WS ring buffer + resync_required (W5.3)', () => { + it('reconnect with last_seq replays buffered events in order', async () => { + const r = await spawn(); + + // Client A: connect, subscribe to sid_test, capture seq up to 5. + const a1 = await openConn(wsUrl(r.address)); + await receiveType(a1, 'server_hello', 1000); + a1.ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'cli_a1', + payload: { client_id: 'A', subscriptions: ['sid_test'] }, + }), + ); + await receiveType(a1, 'ack', 1000); + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_test') === 1, + ), + ); + + const bus = r.services.invokeFunction((acc) => acc.get(IEventBus)); + for (let i = 1; i <= 5; i++) { + bus.publish({ type: `evt.${i}`, sessionId: 'sid_test' } as unknown as Event); + } + // Drain events 1..5 off A1's queue. + for (let i = 1; i <= 5; i++) { + const ev = await receiveType(a1, `evt.${i}`, 1000); + expect(ev.seq).toBe(i); + } + + // Disconnect A1. + a1.ws.close(); + await waitFor(() => + r.services.invokeFunction( + (acc) => acc.get(ISessionClientsService).subscriberCount('sid_test') === 0, + ), + ); + + // Publish 3 more events while A is gone (seq 6, 7, 8). + for (let i = 6; i <= 8; i++) { + bus.publish({ type: `evt.${i}`, sessionId: 'sid_test' } as unknown as Event); + } + + // Reconnect with last_seq=5 — should replay 6, 7, 8 in order. + const a2 = await openConn(wsUrl(r.address)); + await receiveType(a2, 'server_hello', 1000); + a2.ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'cli_a2', + payload: { + client_id: 'A', + subscriptions: ['sid_test'], + last_seq_by_session: { sid_test: 5 }, + }, + }), + ); + + const evt6 = await receiveType(a2, 'evt.6', 1000); + const evt7 = await receiveType(a2, 'evt.7', 1000); + const evt8 = await receiveType(a2, 'evt.8', 1000); + expect(evt6.seq).toBe(6); + expect(evt7.seq).toBe(7); + expect(evt8.seq).toBe(8); + + const ack = await receiveType(a2, 'ack', 1000); + expect(ack.code).toBe(0); + const ackPayload = ack.payload as { resync_required: string[] }; + expect(ackPayload.resync_required).toEqual([]); + + a2.ws.close(); + }); + + it('client connects with last_seq beyond ring-buffer retention → resync_required', async () => { + const r = await spawn(); + + // Force the buffer to overflow. With the spec-faithful 1000-cap, we + // publish 1005 events. After that, oldestSeq is 6 (events 1..5 evicted). + const bus = r.services.invokeFunction((acc) => acc.get(IEventBus)) as DaemonEventBus; + for (let i = 1; i <= 1005; i++) { + bus.publish({ type: 'evt', sessionId: 'sid_test' } as unknown as Event); + } + expect(bus._currentSeqForTest('sid_test')).toBe(1005); + expect(bus._bufferLengthForTest('sid_test')).toBe(1000); + expect(bus._oldestSeqForTest('sid_test')).toBe(6); + + // Client connects with last_seq=3 — gap is too big (events 4, 5 are gone). + const conn = await openConn(wsUrl(r.address)); + await receiveType(conn, 'server_hello', 1000); + conn.ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'cli_resync', + payload: { + client_id: 'C', + subscriptions: ['sid_test'], + last_seq_by_session: { sid_test: 3 }, + }, + }), + ); + + const resync = await receiveType(conn, 'resync_required', 1000); + const resyncPayload = resync.payload as { + session_id: string; + reason: string; + current_seq: number; + }; + expect(resyncPayload.session_id).toBe('sid_test'); + expect(resyncPayload.reason).toBe('buffer_overflow'); + expect(resyncPayload.current_seq).toBe(1005); + + const ack = await receiveType(conn, 'ack', 1000); + const ackPayload = ack.payload as { resync_required: string[] }; + expect(ackPayload.resync_required).toContain('sid_test'); + + conn.ws.close(); + }); + + it('caught-up client (last_seq == current_seq) gets no replay, just empty ack', async () => { + const r = await spawn(); + const bus = r.services.invokeFunction((acc) => acc.get(IEventBus)); + bus.publish({ type: 'evt.a', sessionId: 'sid_test' } as unknown as Event); + bus.publish({ type: 'evt.b', sessionId: 'sid_test' } as unknown as Event); + bus.publish({ type: 'evt.c', sessionId: 'sid_test' } as unknown as Event); + + const conn = await openConn(wsUrl(r.address)); + await receiveType(conn, 'server_hello', 1000); + conn.ws.send( + JSON.stringify({ + type: 'client_hello', + id: 'cli_uptodate', + payload: { + client_id: 'D', + subscriptions: ['sid_test'], + last_seq_by_session: { sid_test: 3 }, // == current_seq + }, + }), + ); + + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(0); + const ackPayload = ack.payload as { resync_required: string[]; accepted_subscriptions: string[] }; + expect(ackPayload.resync_required).toEqual([]); + expect(ackPayload.accepted_subscriptions).toContain('sid_test'); + + // No additional event frames should arrive before the next publish. + await expect(receiveType(conn, 'evt.a', 200)).rejects.toBeInstanceOf(Error); + + // Now publish — should arrive normally at seq=4. + bus.publish({ type: 'evt.d', sessionId: 'sid_test' } as unknown as Event); + const ev = await receiveType(conn, 'evt.d', 1000); + expect(ev.seq).toBe(4); + + conn.ws.close(); + }); + + it('ring buffer evicts oldest event when capacity is exceeded', async () => { + const r = await spawn(); + const bus = r.services.invokeFunction((acc) => acc.get(IEventBus)) as DaemonEventBus; + // Publish 1002 — buffer should retain seq 3..1002, oldestSeq=3. + for (let i = 1; i <= 1002; i++) { + bus.publish({ type: 'evt', sessionId: 'sid_evict' } as unknown as Event); + } + expect(bus._currentSeqForTest('sid_evict')).toBe(1002); + expect(bus._bufferLengthForTest('sid_evict')).toBe(1000); + expect(bus._oldestSeqForTest('sid_evict')).toBe(3); + + // getBufferedSince(sid, 2) → resyncRequired (lastSeq+1=3, oldestSeq=3 → NOT resync; + // lastSeq+1=3 == oldestSeq=3 → NOT resync). Verify boundary. + const replay = bus.getBufferedSince('sid_evict', 2); + expect(replay.resyncRequired).toBe(false); + expect(replay.events[0]?.seq).toBe(3); + expect(replay.events.length).toBe(1000); + + // lastSeq=1 → lastSeq+1=2 < oldestSeq=3 → resync. + const replay2 = bus.getBufferedSince('sid_evict', 1); + expect(replay2.resyncRequired).toBe(true); + expect(replay2.events.length).toBe(0); + }); +}); diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json new file mode 100644 index 000000000..91c35884d --- /dev/null +++ b/packages/daemon/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": ["src", "test", "../agent-core/src/prompt-modules.d.ts"] +} diff --git a/packages/daemon/tsdown.config.ts b/packages/daemon/tsdown.config.ts new file mode 100644 index 000000000..cb99d9ffb --- /dev/null +++ b/packages/daemon/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, +}); diff --git a/packages/daemon/vitest.config.ts b/packages/daemon/vitest.config.ts new file mode 100644 index 000000000..256b8c7fc --- /dev/null +++ b/packages/daemon/vitest.config.ts @@ -0,0 +1,40 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +// `rawTextPlugin` is needed even for daemon-only tests because W4.4 wires +// HarnessBridge → KimiCore, which drags in agent-core's `tools/builtin/*` tree +// that imports 20+ raw `.md` description files. Without the plugin those +// imports fail with "Failed to resolve import". +// +// Workspace `resolve.alias` mirrors `packages/services/vitest.config.ts:11` so +// tests run against src/index.ts (not built dist/) — keeps the feedback loop +// tight when adjacent packages change. +export default defineConfig({ + plugins: [rawTextPlugin()], + resolve: { + alias: { + '@moonshot-ai/kimi-code-sdk': fileURLToPath( + new URL('../node-sdk/src/index.ts', import.meta.url), + ), + '@moonshot-ai/agent-core': fileURLToPath( + new URL('../agent-core/src/index.ts', import.meta.url), + ), + '@moonshot-ai/protocol': fileURLToPath( + new URL('../protocol/src/index.ts', import.meta.url), + ), + '@moonshot-ai/services': fileURLToPath( + new URL('../services/src/index.ts', import.meta.url), + ), + '@moonshot-ai/kimi-code-oauth': fileURLToPath( + new URL('../oauth/src/index.ts', import.meta.url), + ), + }, + }, + test: { + name: 'daemon', + include: ['test/**/*.{test,e2e}.ts'], + }, +}); diff --git a/packages/services/package.json b/packages/services/package.json new file mode 100644 index 000000000..226239beb --- /dev/null +++ b/packages/services/package.json @@ -0,0 +1,41 @@ +{ + "name": "@moonshot-ai/services", + "version": "0.1.0", + "private": true, + "description": "In-process service container: DI broker interfaces + HarnessBridge for the kimi-code daemon.", + "license": "MIT", + "author": "Moonshot AI", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/services" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/kimi-code-sdk": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", + "ulid": "^3.0.1" + } +} diff --git a/packages/services/src/adapter/approval-adapter.ts b/packages/services/src/adapter/approval-adapter.ts new file mode 100644 index 000000000..2de9e5c74 --- /dev/null +++ b/packages/services/src/adapter/approval-adapter.ts @@ -0,0 +1,100 @@ +/** + * Approval adapter (W8.1 / Chain 5). + * + * Bridges two representations of the same approval interaction: + * + * 1. **In-process SDK shape** (agent-core, camelCase) — what `BridgeClientAPI` + * sees coming off `KimiCore.requestApproval(...)`. See + * `packages/agent-core/src/rpc/sdk-api.ts:17-23`: + * `ApprovalRequest { turnId?, toolCallId, toolName, action, display }` + * and `ApprovalResponse { decision, scope?, feedback?, selectedLabel? }`. + * + * 2. **Protocol wire shape** (snake_case, with daemon-allocated metadata) — + * what the daemon broadcasts as `event.approval.requested` and what the + * REST resolve handler receives as request body. See SCHEMAS.md §6.1 and + * `packages/protocol/src/approval.ts`. + * + * **Field translations**: + * + * SDK (camelCase) → Protocol (snake_case) + * ---------------------------------------- + * toolCallId → tool_call_id + * toolName → tool_name + * turnId → turn_id (optional) + * display → tool_input_display (passthrough — 12-arm union) + * selectedLabel → selected_label (response side) + * + * The `tool_input_display` field is passed through verbatim — SCHEMAS §6.1 + * mandates 12-arm passthrough with `generic.summary` fall-back rendering on + * the client. We don't structurally validate it. + * + * **Anti-corruption**: this is the ONLY place protocol↔SDK shape translation + * happens for approval. Daemon routes call `toBrokerRequest` indirectly via + * the bridge (KimiCore → BridgeClientAPI.requestApproval → broker.request), + * and `toAgentCoreResponse` from the REST resolve handler. + */ + +import type { + ApprovalRequest as InProcessApprovalRequest, + ApprovalResponse as InProcessApprovalResponse, +} from '@moonshot-ai/agent-core'; +import type { + ApprovalRequest as ProtocolApprovalRequest, + ApprovalResponse as ProtocolApprovalResponse, +} from '@moonshot-ai/protocol'; + +export interface ToBrokerRequestParams { + /** Daemon-minted ULID identifying this approval interaction. */ + readonly approvalId: string; + /** Session the approval lives in. */ + readonly sessionId: string; + /** `createdAt` ISO string; broker passes a fresh `new Date().toISOString()`. */ + readonly createdAt: string; + /** `expiresAt` ISO string; broker computes `createdAt + 60s`. */ + readonly expiresAt: string; +} + +/** + * In-process SDK request + daemon-allocated metadata → protocol wire shape. + * + * Used by the daemon broker to build the WS `event.approval.requested` + * payload before broadcasting. + * + * `req` may carry extra context fields (`sessionId`, `agentId`) appended by + * the bridge — we read `sessionId` from `params.sessionId` (the authoritative + * daemon-side source) and ignore any duplicate on the request. + */ +export function toBrokerRequest( + req: InProcessApprovalRequest, + params: ToBrokerRequestParams, +): ProtocolApprovalRequest { + return { + approval_id: params.approvalId, + session_id: params.sessionId, + turn_id: req.turnId, + tool_call_id: req.toolCallId, + tool_name: req.toolName, + action: req.action, + // Passthrough — SCHEMAS §6.1 mandates 12-arm union preservation with + // `generic.summary` fall-back rendering on the client. + tool_input_display: req.display, + created_at: params.createdAt, + expires_at: params.expiresAt, + }; +} + +/** + * Protocol REST request body → in-process SDK response. + * + * Used by the REST resolve handler to settle the agent-side Promise. + */ +export function toAgentCoreResponse( + resp: ProtocolApprovalResponse, +): InProcessApprovalResponse { + return { + decision: resp.decision, + scope: resp.scope, + feedback: resp.feedback, + selectedLabel: resp.selected_label, + }; +} diff --git a/packages/services/src/adapter/question-adapter.ts b/packages/services/src/adapter/question-adapter.ts new file mode 100644 index 000000000..0c8aec0f2 --- /dev/null +++ b/packages/services/src/adapter/question-adapter.ts @@ -0,0 +1,207 @@ +/** + * Question adapter (W8.2 / Chain 6). + * + * Bridges two representations of the same question interaction: + * + * 1. **In-process SDK shape** (agent-core, camelCase) — what + * `BridgeClientAPI` sees from `KimiCore.requestQuestion(...)`. See + * `packages/agent-core/src/rpc/sdk-api.ts:50-54`: + * `QuestionRequest { turnId?, toolCallId?, questions: QuestionItem[] }` + * where `QuestionItem` has `question, header?, body?, options[], + * multiSelect?, otherLabel?, otherDescription?`. + * `QuestionResult = null | QuestionAnswers | QuestionResponse`, + * `QuestionAnswers = Record`. + * + * 2. **Protocol wire shape** (snake_case, with daemon-allocated metadata) — + * defined in `packages/protocol/src/question.ts`. 5-kind discriminated + * union for answers: `single | multi | other | multi_with_other | skipped`. + * + * **Field translations (request)**: + * + * SDK (camelCase) → Protocol (snake_case) + * ------------------------------------------------ + * turnId → turn_id + * toolCallId → tool_call_id + * questions[].question → questions[].question (unchanged) + * questions[].multiSelect → questions[].multi_select + * questions[].otherLabel → questions[].other_label + * questions[].otherDescription → questions[].other_description + * questions[].options[].label → questions[].options[].label (unchanged) + * + * **Synthesizing stable ids** (W8.2 — SDK has no per-item / per-option `id`): + * - `QuestionItem.id` ← `q_` (e.g. `q_0`, `q_1`, ...) + * - `QuestionOption.id` ← `opt__` (e.g. `opt_0_0`) + * The ids are deterministic, lexicographically stable, and the adapter + * uses them as the round-trip key when projecting answers BACK to the + * SDK `Record` shape. + * + * **Field translations (response, SCHEMAS §6.4 verbatim)**: + * + * Protocol QuestionAnswer (kind) → in-process Record + * ---------------------------------------------------------------------- + * 'single' → answers[qid] = option_id + * 'multi' → answers[qid] = option_ids.join(',') (lossy) + * 'other' → answers[qid] = text + * 'multi_with_other' → answers[qid] = [...option_ids, other_text].join(',') + * 'skipped' → answers entry OMITTED entirely + * + * The lossy `multi` flattening matches SCHEMAS §6.4 verbatim. agent-core's + * downstream consumer (`packages/agent-core/src/agent/tools/ask-user.ts`) + * splits the comma-joined value back when needed. + * + * **Anti-corruption**: this is the ONLY place protocol↔SDK shape translation + * happens for question. Daemon REST routes call `toBrokerRequest` indirectly + * via the bridge and `toAgentCoreResponse` from the REST resolve handler. + */ + +import type { + QuestionAnswers as InProcessQuestionAnswers, + QuestionItem as InProcessQuestionItem, + QuestionRequest as InProcessQuestionRequest, + QuestionResponse as InProcessQuestionResponse, +} from '@moonshot-ai/agent-core'; +import type { + QuestionItem as ProtocolQuestionItem, + QuestionOption as ProtocolQuestionOption, + QuestionRequest as ProtocolQuestionRequest, + QuestionResponse as ProtocolQuestionResponse, +} from '@moonshot-ai/protocol'; + +export interface QuestionToBrokerRequestParams { + /** Daemon-minted ULID identifying this question interaction. */ + readonly questionId: string; + /** Session the question lives in. */ + readonly sessionId: string; + /** `createdAt` ISO string; broker passes `new Date().toISOString()`. */ + readonly createdAt: string; + /** `expiresAt` ISO string; broker computes `createdAt + 60s`. */ + readonly expiresAt: string; +} + +/** + * Build a protocol option from an SDK option. SDK has only `label?:string` + + * `description?:string`; we synthesize `id` from parent and child indices so + * `toAgentCoreAnswers` can map back through `Record`. + */ +function buildOption( + opt: { readonly label: string; readonly description?: string }, + parentIdx: number, + optIdx: number, +): ProtocolQuestionOption { + const base: ProtocolQuestionOption = { + id: `opt_${parentIdx}_${optIdx}`, + label: opt.label, + }; + return opt.description === undefined ? base : { ...base, description: opt.description }; +} + +/** + * Build a protocol question item from an SDK item + its position. The + * synthesized `id` (`q_`) is the key the SDK answers Record uses. + */ +function buildItem( + item: InProcessQuestionItem, + parentIdx: number, +): ProtocolQuestionItem { + const id = `q_${parentIdx}`; + const out: ProtocolQuestionItem = { + id, + question: item.question, + options: item.options.map((o, oi) => buildOption(o, parentIdx, oi)), + }; + if (item.header !== undefined) out.header = item.header; + if (item.body !== undefined) out.body = item.body; + if (item.multiSelect !== undefined) out.multi_select = item.multiSelect; + // SDK has no `allowOther` field — `otherLabel` / `otherDescription` exist + // and we expose them on the wire alongside an inferred `allow_other: true` + // when either tag is set. (SDK semantics: presence of `otherLabel` enables + // the "Other" affordance; we surface that explicitly on the wire so client + // renderers don't have to infer.) + const hasOtherAffordance = + item.otherLabel !== undefined || item.otherDescription !== undefined; + if (hasOtherAffordance) out.allow_other = true; + if (item.otherLabel !== undefined) out.other_label = item.otherLabel; + if (item.otherDescription !== undefined) out.other_description = item.otherDescription; + return out; +} + +/** + * In-process SDK request + daemon-allocated metadata → protocol wire shape. + */ +export function toBrokerRequest( + req: InProcessQuestionRequest, + params: QuestionToBrokerRequestParams, +): ProtocolQuestionRequest { + const out: ProtocolQuestionRequest = { + question_id: params.questionId, + session_id: params.sessionId, + questions: req.questions.map((q, i) => buildItem(q, i)), + created_at: params.createdAt, + expires_at: params.expiresAt, + }; + if (req.turnId !== undefined) out.turn_id = req.turnId; + if (req.toolCallId !== undefined) out.tool_call_id = req.toolCallId; + return out; +} + +/** + * Protocol REST response body → in-process SDK `QuestionResponse` (with + * `answers` flattened to `Record`). + * + * Normalization rules from SCHEMAS §6.4: + * - single → option_id + * - multi → option_ids.join(',') + * - other → text + * - multi_with_other → [...option_ids, other_text].join(',') + * - skipped → OMIT entry + */ +export function toAgentCoreResponse( + resp: ProtocolQuestionResponse, +): InProcessQuestionResponse { + const flattened: InProcessQuestionAnswers = {}; + for (const [qid, ans] of Object.entries(resp.answers)) { + switch (ans.kind) { + case 'single': + flattened[qid] = ans.option_id; + break; + case 'multi': + flattened[qid] = ans.option_ids.join(','); + break; + case 'other': + flattened[qid] = ans.text; + break; + case 'multi_with_other': + flattened[qid] = [...ans.option_ids, ans.other_text].join(','); + break; + case 'skipped': + // Omitted from the record — matches SCHEMAS §6.4 ("if skipped continue"). + break; + default: { + // Defensive: never-reached if Zod schema is the SOT, but TS narrowing + // is exhaustive so this is unreachable. + const _exhaustive: never = ans; + void _exhaustive; + } + } + } + const out: InProcessQuestionResponse = { answers: flattened }; + if (resp.method !== undefined) { + // SCHEMAS §6.2 protocol allows 'click' as a method; agent-core's in-process + // `QuestionAnswerMethod` is `'enter' | 'space' | 'number_key'` (NO 'click'). + // Drop 'click' on the in-process side to preserve type safety; the wire + // form keeps it for clients that want to surface the affordance used. + if (resp.method !== 'click') { + (out as { method?: typeof resp.method }).method = resp.method; + } + } + return out; +} + +/** + * Convenience: SDK semantics for "dismiss the entire question group" is the + * `null` QuestionResult. Exposed as a helper so daemon code reads + * intentionally rather than litter `null` constants. + */ +export function dismissedResult(): null { + return null; +} diff --git a/packages/services/src/adapter/task-adapter.ts b/packages/services/src/adapter/task-adapter.ts new file mode 100644 index 000000000..8317bc093 --- /dev/null +++ b/packages/services/src/adapter/task-adapter.ts @@ -0,0 +1,107 @@ +/** + * Background Task adapter (Chain 8 / P1.8, W9.2). + * + * Translates agent-core's `BackgroundTaskInfo` discriminated union (kind ∈ + * `'process'|'agent'|'question'`, camelCase + ms timestamps, 6-literal status) + * into SCHEMAS §7 `BackgroundTask` (kind ∈ `'subagent'|'bash'|'tool'`, + * snake_case + ISO, 4-literal status). + * + * Reference table (full rationale lives in `packages/protocol/src/task.ts` + * header): + * + * kind: process → bash + * agent → subagent + * question → tool + * + * status: running → running + * completed → completed + * failed → failed + * timed_out → failed (lossy — stopReason carries hint) + * killed → cancelled + * lost → failed (lossy) + * + * timestamps: agent-core has `startedAt: number` + `endedAt: number|null`. + * We synthesize `created_at` = `started_at` from `startedAt`; + * `completed_at` from `endedAt` when present. + * + * id: agent-core `taskId` → wire `id` (renamed only). + * + * description: passthrough. + * + * output_preview / output_bytes: NOT surfaced today (agent-core's + * `BackgroundTaskInfoBase` has no output fields; output is + * fetched separately via `getBackgroundOutput`). Adapter + * omits both. + * + * Helper exports: + * - `isTerminalStatus(status)` — true for `completed|failed|cancelled` (the + * three wire-terminal literals). Used by daemon route to choose 40904 + * envelope vs successful cancel. + */ + +import type { BackgroundTaskInfo } from '@moonshot-ai/agent-core'; +import type { BackgroundTask, BackgroundTaskKind, BackgroundTaskStatus } from '@moonshot-ai/protocol'; + +function mapKind(k: BackgroundTaskInfo['kind']): BackgroundTaskKind { + switch (k) { + case 'process': + return 'bash'; + case 'agent': + return 'subagent'; + case 'question': + // SCHEMAS §7 has no 'question' literal; question background tasks are + // tool-spawned flows (Loop runs them as part of `Question` tool + // execution), so 'tool' is the closest spec literal. + return 'tool'; + } +} + +function mapStatus(s: BackgroundTaskInfo['status']): BackgroundTaskStatus { + switch (s) { + case 'running': + return 'running'; + case 'completed': + return 'completed'; + case 'failed': + return 'failed'; + case 'timed_out': + // SCHEMAS §7 has no 'timed_out' literal; collapse to 'failed'. The + // optional `stop_reason`/`last_error` surface would carry the hint + // once SCHEMAS adds the field (deferred). + return 'failed'; + case 'killed': + return 'cancelled'; + case 'lost': + return 'failed'; + } +} + +const TERMINAL_WIRE_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'cancelled', +]); + +export function isTerminalStatus(status: BackgroundTaskStatus): boolean { + return TERMINAL_WIRE_STATUSES.has(status); +} + +export function toProtocolTask(sessionId: string, info: BackgroundTaskInfo): BackgroundTask { + const status = mapStatus(info.status); + const createdIso = new Date(info.startedAt).toISOString(); + const base: BackgroundTask = { + id: info.taskId, + session_id: sessionId, + kind: mapKind(info.kind), + description: info.description, + status, + // Agent-core has no separate creation stamp; we synthesize from + // startedAt — running tasks usually start immediately after creation. + created_at: createdIso, + started_at: createdIso, + }; + if (info.endedAt !== null && info.endedAt !== undefined) { + return { ...base, completed_at: new Date(info.endedAt).toISOString() }; + } + return base; +} diff --git a/packages/services/src/adapter/tool-adapter.ts b/packages/services/src/adapter/tool-adapter.ts new file mode 100644 index 000000000..07598726e --- /dev/null +++ b/packages/services/src/adapter/tool-adapter.ts @@ -0,0 +1,147 @@ +/** + * Tool + MCP adapter (Chain 7 / P1.7, W9.1). + * + * Translates agent-core's `ToolInfo` + `McpServerInfo` (camelCase, agent-core + * literal sets) into SCHEMAS §8 `ToolDescriptor` + `McpServer` (snake_case, + * spec-literal sets). Reference: `packages/protocol/src/tool.ts` header. + * + * The adapter handles three mismatch areas: + * + * 1. **Tool source literal**: agent-core uses `'user'`; SCHEMAS §8 uses + * `'skill'`. We map `'user' → 'skill'` so the wire schema doesn't have to + * accept the agent-core variant. `'builtin'` and `'mcp'` pass through. + * + * 2. **input_schema**: agent-core's `ToolInfo` does not surface a per-tool + * JSON schema today. We emit `null` (per SCHEMAS "未知字段宽松" the + * wire schema accepts `unknown`, and `null` is the most honest signal). + * + * 3. **mcp_server_id**: agent-core qualifies MCP tools as + * `mcp::`. When the source is `'mcp'`, we parse the second + * `:` segment as the server id. If the name doesn't match the expected + * prefix shape (e.g. agent-core ever changes the convention) we omit + * the field rather than emitting a misleading value. + * + * 4. **MCP status mapping** (`McpServerInfo.status` → `McpServer.status`): + * agent-core 'pending' → wire 'connecting' + * agent-core 'connected' → wire 'connected' + * agent-core 'failed' → wire 'error' + * agent-core 'disabled' → wire 'disconnected' + * agent-core 'needs-auth' → wire 'error' (last_error carries the hint) + * + * 5. **MCP id**: agent-core's `McpServerInfo` has only `name`. We adopt + * name-as-id at the wire boundary. Both are 1:1 within a daemon process. + */ + +import type { McpServerInfo } from '@moonshot-ai/agent-core'; +import type { + McpServer, + McpServerStatus, + McpServerTransport, + ToolDescriptor, + ToolSource, +} from '@moonshot-ai/protocol'; + +/** + * In-process minimal shape we accept for tool conversion. Mirrors + * `@moonshot-ai/agent-core` `ToolInfo` without taking a runtime dependency on + * its exact shape (the adapter is the boundary). + */ +export interface AgentCoreToolInfoLike { + readonly name: string; + readonly description: string; + readonly source: 'builtin' | 'user' | 'mcp'; + /** agent-core may add fields like `active`; we ignore them. */ + readonly active?: boolean; +} + +function mapToolSource(s: AgentCoreToolInfoLike['source']): ToolSource { + switch (s) { + case 'builtin': + return 'builtin'; + case 'user': + return 'skill'; + case 'mcp': + return 'mcp'; + } +} + +/** + * Parse the server id segment from an MCP tool name. Convention: + * `mcp::` (kosong's `mcpRegistrar.qualifiedName`). Returns + * `undefined` when the name does not match — caller omits `mcp_server_id`. + */ +function parseMcpServerIdFromToolName(name: string): string | undefined { + if (!name.startsWith('mcp:')) return undefined; + const rest = name.slice('mcp:'.length); + const sep = rest.indexOf(':'); + if (sep <= 0) return undefined; + return rest.slice(0, sep); +} + +export function toProtocolTool(info: AgentCoreToolInfoLike): ToolDescriptor { + const source = mapToolSource(info.source); + const base: ToolDescriptor = { + name: info.name, + description: info.description, + // agent-core's ToolInfo lacks a JSON schema today; emit null so the + // wire schema is honest about "unknown". + input_schema: null, + source, + }; + if (source === 'mcp') { + const serverId = parseMcpServerIdFromToolName(info.name); + if (serverId !== undefined) { + return { ...base, mcp_server_id: serverId }; + } + } + return base; +} + +// --- MCP server ------------------------------------------------------------- + +function mapMcpStatus(s: McpServerInfo['status']): McpServerStatus { + switch (s) { + case 'connected': + return 'connected'; + case 'pending': + return 'connecting'; + case 'failed': + return 'error'; + case 'disabled': + return 'disconnected'; + case 'needs-auth': + // Closest wire literal; `last_error` carries the explanatory message. + return 'error'; + } +} + +function mapMcpTransport(t: McpServerInfo['transport']): McpServerTransport { + // SCHEMAS §8 transport is a superset (adds 'sse'); the two agent-core + // literals pass through unchanged. + switch (t) { + case 'stdio': + return 'stdio'; + case 'http': + return 'http'; + } +} + +export function toProtocolMcpServer(info: McpServerInfo): McpServer { + const status = mapMcpStatus(info.status); + const base: McpServer = { + // name-as-id: agent-core doesn't surface a separate id; the daemon's + // REST path uses {mcp_server_id} which we interpret as the name. + id: info.name, + name: info.name, + transport: mapMcpTransport(info.transport), + status, + tool_count: info.toolCount, + }; + // Surface the upstream error message when present. We expose it on every + // non-healthy status (not just 'error') because 'needs-auth' arrives with + // `error` carrying the auth-hint URL. + if (info.error !== undefined && info.error.length > 0) { + return { ...base, last_error: info.error }; + } + return base; +} diff --git a/packages/services/src/bridge/bridge-client-api.ts b/packages/services/src/bridge/bridge-client-api.ts new file mode 100644 index 000000000..5c5be606e --- /dev/null +++ b/packages/services/src/bridge/bridge-client-api.ts @@ -0,0 +1,72 @@ +/** + * `BridgeClientAPI` — the SDK side of the in-process RPC pair owned by + * `HarnessBridge`. Satisfies `SDKAPI` (`@moonshot-ai/agent-core` rpc/sdk-api.ts:78, + * via `SDKAgentAPI` at :67-72) so `KimiCore` can call into it through + * `createRPC()`. Methods route to DI-resolved brokers: + * + * emitEvent(event) → IEventBus.publish(event) + * requestApproval(req) → IApprovalBroker.request(req) + * requestQuestion(req) → IQuestionBroker.request(req) + * toolCall(req) → unsupported (SDK custom tool calls not used here) + * + * The protocol↔in-process adapters (SCHEMAS.md §6.4 snake_case shapes, REST + * request/response Zod validation) live at the daemon REST boundary in + * Chain 5/6 (W8) — NOT here. The broker interfaces stay SDK-shaped. + */ + +import type { + ApprovalRequest, + ApprovalResponse, + Event, + QuestionRequest, + QuestionResult, + SDKAPI, + ToolCallRequest, + ToolCallResponse, +} from '@moonshot-ai/agent-core'; + +import type { IApprovalBroker } from '../interfaces/approval-broker'; +import type { IEventBus } from '../interfaces/event-bus'; +import type { IQuestionBroker } from '../interfaces/question-broker'; + +export interface BridgeClientAPIDeps { + readonly eventBus: IEventBus; + readonly approvalBroker: IApprovalBroker; + readonly questionBroker: IQuestionBroker; +} + +export class BridgeClientAPI implements SDKAPI { + private readonly deps: BridgeClientAPIDeps; + + constructor(deps: BridgeClientAPIDeps) { + this.deps = deps; + } + + emitEvent(event: Event): void { + this.deps.eventBus.publish(event); + } + + async requestApproval( + request: ApprovalRequest & { sessionId: string; agentId: string }, + ): Promise { + return this.deps.approvalBroker.request(request); + } + + async requestQuestion( + request: QuestionRequest & { sessionId: string; agentId: string }, + ): Promise { + return this.deps.questionBroker.request(request); + } + + async toolCall( + request: ToolCallRequest & { sessionId: string; agentId: string }, + ): Promise { + // Mirrors `SDKRpcClientBase.toolCall` (packages/node-sdk/src/rpc.ts:577-582) + // — daemon's bridge does not expose SDK-side custom tool calls; the agent + // gets an error result it can surface upstream. + return { + output: `SDK custom tool calls are not supported in the daemon bridge: ${request.toolCallId}`, + isError: true, + }; + } +} diff --git a/packages/services/src/bridge/harness-bridge.ts b/packages/services/src/bridge/harness-bridge.ts new file mode 100644 index 000000000..d1c50bf3a --- /dev/null +++ b/packages/services/src/bridge/harness-bridge.ts @@ -0,0 +1,195 @@ +/** + * `HarnessBridge` — the in-process RPC bridge owned by the services package. + * Internally: + * + * 1. `createRPC()` produces a `[coreRpc, sdkRpc]` pair of + * `RPCClient` functions (packages/agent-core/src/rpc/client.ts:31-103). + * 2. `new KimiCore(coreRpc, options)` — the core is constructed with the + * core-side RPC client (it calls into the SDK side over `coreRpc`). + * 3. `sdkRpc(new BridgeClientAPI({ ... }))` — the SDK side of the pair is + * satisfied by a `BridgeClientAPI` instance whose `SDKAPI` methods route + * to DI-resolved brokers. Returns `Promise>` — the + * core RPC methods that future positive services (W4+/Phase 1) will use. + * + * The result is wrapped in a small `SDKRpcClient`-shaped facade so that + * service impls (Chain 1+) get the same ergonomics as `@moonshot-ai/kimi-code-sdk` + * (`SDKRpcClientBase` subclass). The facade is exposed as `rpc` for in-package + * consumers; the public package barrel does NOT re-export `SDKRpcClientBase`, + * so daemon-side code stays one abstraction layer away. + * + * Lifecycle: + * - `ready()` resolves when both the `KimiCore` plugin/config load AND the + * SDK-side RPC binding have settled. Construction is eager (Singleton + * pattern); awaiting `ready()` is the safe gate before issuing RPC calls. + * - `dispose()` is idempotent. It flips an internal flag so future `rpc` + * method dispatch throws before reaching `KimiCore`, then walks the + * `Disposable` child stack. `KimiCore` itself has no `dispose()` today — + * when it gets one (PLAN Stage 2), we wire it here. + */ + +import { + createDecorator, + createRPC, + Disposable, + KimiCore, + type CoreAPI, + type CoreRPC, + type KimiCoreOptions, + type SDKAPI, +} from '@moonshot-ai/agent-core'; + +import { BridgeClientAPI } from './bridge-client-api'; +import { IApprovalBroker } from '../interfaces/approval-broker'; +import { IEventBus } from '../interfaces/event-bus'; +import { IQuestionBroker } from '../interfaces/question-broker'; + +export interface HarnessBridgeOptions extends KimiCoreOptions { + // Future per-bridge knobs (e.g. logger handle) land here. For W3 the bridge + // forwards every option to `KimiCore` verbatim; daemon-specific extras + // (request_id prefix, audit hooks, etc.) get added by W4/Chain wiring. +} + +/** + * Read-only view onto the core RPC that the bridge exposes to in-package + * service impls. Members are dispatched eagerly through the RPC; calls before + * `ready()` resolves queue inside `RPCClient`'s controlled-promise plumbing. + */ +export type HarnessRPC = CoreRPC; + +export interface IHarnessBridge { + /** The core RPC methods. Service impls call e.g. `bridge.rpc.createSession(...)`. */ + readonly rpc: HarnessRPC; + + /** + * Resolves once `KimiCore` is fully constructed and the SDK side of the + * in-process RPC has been bound. Repeated calls return the cached promise. + */ + ready(): Promise; + + /** + * Tear down the bridge. After dispose, `rpc.(...)` rejects with a + * "bridge disposed" error before reaching `KimiCore`. Idempotent. + */ + dispose(): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IHarnessBridge = createDecorator('IHarnessBridge'); + +export class HarnessBridge extends Disposable implements IHarnessBridge { + /** + * Service-facing RPC handle. This is a `Proxy` over the awaited + * `RPCMethods` so callers don't have to await a promise themselves + * — `bridge.rpc.createSession({...})` returns a `Promise` + * directly. After dispose, the proxy rejects on every method invocation. + */ + public readonly rpc: HarnessRPC; + + /** + * The in-process `KimiCore` instance. Kept private so daemon-side code can't + * grab it and bypass the broker indirection. + */ + private readonly _core: KimiCore; + + /** + * Promise that resolves to the resolved RPC methods. The bridge's `rpc` + * proxy awaits this on every dispatch (cheap — controlled-promise resolves + * synchronously on the second call). + */ + private readonly _coreRpcPromise: Promise; + + /** + * Cached readiness signal. We treat "SDK-side RPC bound" as the readiness + * marker today; once `KimiCore.pluginsReady` is publicly exposed we can + * combine them here. + */ + private readonly _ready: Promise; + + constructor( + // P2.5: VSCode-style static-first / services-last ctor. `options` + // moves to the prefix because it's a config bag, not a DI dep. + // The brokers + event bus are auto-injected by the container; the + // caller (daemon start.ts) passes `options` (or `{}`). The inline + // default is dropped because TS forbids a required param after an + // optional one — call sites already pass an explicit object. + options: HarnessBridgeOptions, + @IEventBus eventBus: IEventBus, + @IApprovalBroker approvalBroker: IApprovalBroker, + @IQuestionBroker questionBroker: IQuestionBroker, + ) { + super(); + + // 1. Build the in-process RPC pair. Left/Right are typed; `coreRpc` is the + // function KimiCore receives, `sdkRpc` is the one the bridge satisfies. + const [coreRpc, sdkRpc] = createRPC(); + + // 2. Construct the core. KimiCore's ctor wires itself into `coreRpc` and + // exposes `this.sdk: Promise` for the reverse direction. + this._core = new KimiCore(coreRpc, options); + + // 3. Satisfy the SDK side with a BridgeClientAPI that routes to brokers. + // sdkRpc returns Promise> — these are the methods + // in-package services will dispatch on. + const clientApi = new BridgeClientAPI({ + eventBus, + approvalBroker, + questionBroker, + }); + this._coreRpcPromise = sdkRpc(clientApi); + + // 4. Readiness is "the RPC pair is bound on both sides". Plugin load + // happens inside KimiCore's ctor and self-heals (the worker captures + // the error rather than surfacing it; see core-impl.ts:170-172). + this._ready = this._coreRpcPromise.then(() => undefined); + + // 5. Build the dispatch proxy. Each method on the proxy awaits the resolved + // RPC methods then forwards. After dispose, dispatch rejects eagerly. + this.rpc = this._buildRpcProxy(); + } + + async ready(): Promise { + return this._ready; + } + + override dispose(): void { + if (this._isDisposed) return; + // KimiCore does not currently expose a dispose() — when it does (PLAN + // Stage 2), we'll await/call it here BEFORE super.dispose(). For now, + // disposing the bridge flips _disposed, which makes future rpc.* + // invocations reject before they reach KimiCore. + super.dispose(); + } + + private _buildRpcProxy(): HarnessRPC { + const rpcPromise = this._coreRpcPromise; + const isDisposedRef = () => this._isDisposed; + + // We don't know the concrete method set at compile time here (CoreAPI is + // a structural interface; `RPCMethods` is a mapped type). + // The Proxy lets us intercept every property access and return a function + // that awaits the underlying RPC and forwards. + return new Proxy({} as HarnessRPC, { + get(_target, prop) { + // Symbols / well-known properties (Symbol.toPrimitive, then-able + // probe, etc.) should not be RPC-dispatched. + if (typeof prop !== 'string') return undefined; + // Returning a function keeps `typeof rpc.foo === 'function'` true, + // which downstream code may probe. + return (...args: unknown[]) => { + if (isDisposedRef()) { + return Promise.reject(new Error('HarnessBridge has been disposed')); + } + return rpcPromise.then((methods) => { + const fn = (methods as unknown as Record)[prop]; + if (typeof fn !== 'function') { + return Promise.reject( + new Error(`HarnessBridge.rpc.${prop} is not a function`), + ); + } + return (fn as (...args: unknown[]) => unknown)(...args); + }); + }; + }, + }); + } +} diff --git a/packages/services/src/bridge/lifecycle.ts b/packages/services/src/bridge/lifecycle.ts new file mode 100644 index 000000000..171c652ed --- /dev/null +++ b/packages/services/src/bridge/lifecycle.ts @@ -0,0 +1,16 @@ +/** + * Helper for module-init contexts that prefer the registry pattern over + * `defaultServicesModule()` (e.g. side-effect-on-import wiring). Daemon-side + * code should use `defaultServicesModule()` instead — it's the canonical + * wiring strategy for W3+. This helper exists only because the existing + * `registerSingleton` registry is the established pattern in agent-core's + * DI README; we don't ship it from the package barrel. + */ + +import { InstantiationType, registerSingleton } from '@moonshot-ai/agent-core'; + +import { HarnessBridge, IHarnessBridge } from './harness-bridge'; + +export function registerHarnessBridge(): void { + registerSingleton(IHarnessBridge, HarnessBridge, InstantiationType.Eager); +} diff --git a/packages/services/src/impls/mcp-service-impl.ts b/packages/services/src/impls/mcp-service-impl.ts new file mode 100644 index 000000000..f59372fea --- /dev/null +++ b/packages/services/src/impls/mcp-service-impl.ts @@ -0,0 +1,89 @@ +/** + * `McpServiceImpl` — adapter between protocol-shaped REST surface and + * agent-core's `listMcpServers` + `reconnectMcpServer` (Chain 7 / P1.7, W9.1). + * + * Wraps `IHarnessBridge.rpc.{listMcpServers, reconnectMcpServer}` and adapts + * the `McpServerInfo` shape into SCHEMAS §8 `McpServer` via + * `tool-adapter.toProtocolMcpServer`. + * + * **agent-core API note**: `listMcpServers` is exposed on the SessionAPI + * (per-session). Per REST.md §3.8 the wire endpoint `/v1/mcp/servers` is + * GLOBAL (not session-scoped). We pass the agent-core implicit session id + * `'__global__'` via the bridge — but agent-core's `listMcpServers` actually + * reads from the in-process MCP registrar which is process-global today, so + * the implementation routes through whichever session id the bridge expects. + * + * Since `bridge.rpc.listMcpServers` is auto-wrapped with `sessionId` injection + * (the `SessionAPI` proxy at `core-impl.ts` injects the current `sessionId` + * field), we cannot directly call it without a session context. The bridge's + * `HarnessRPC` exposes the method requiring `sessionId` in the payload. For + * the global REST surface we accept ANY known session id; the daemon route + * can pass a probe (e.g. first session from `listSessions`) or — when no + * sessions exist — return an empty list. We implement the latter to keep the + * daemon's `/v1/mcp/servers` 200-OK before any session is created. + * + * **Reconnect**: `bridge.rpc.reconnectMcpServer({name, sessionId})` likewise + * needs a session anchor. We forward the route-supplied `sessionId` (the + * `:restart` REST endpoint is global today, so the impl picks the first + * known session). Missing/unknown server name → `McpServerNotFoundError`. + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `Disposable` base type. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { McpServer } from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { IMcpService, McpServerNotFoundError } from '../interfaces/mcp-service'; +import { toProtocolMcpServer } from '../adapter/tool-adapter'; + +export class McpServiceImpl extends Disposable implements IMcpService { + constructor(@IHarnessBridge private readonly bridge: IHarnessBridge) { + super(); + } + + async list(): Promise { + // `listMcpServers` is on the SessionAPI surface; we need a session id to + // dispatch. Pick the most recently created one. If no sessions exist, + // return an empty list (the MCP registrar may have started up but the + // RPC plumbing isn't reachable until a session is open). + const sessionId = await this._anyKnownSessionId(); + if (sessionId === undefined) return []; + const raw = await this.bridge.rpc.listMcpServers({ sessionId }); + return raw.map(toProtocolMcpServer); + } + + async restart(serverId: string): Promise<{ restarting: true }> { + const sessionId = await this._anyKnownSessionId(); + if (sessionId === undefined) { + // No session => no MCP registrar reachable => server can't be reached. + throw new McpServerNotFoundError(serverId); + } + // Existence check: the wire id is the agent-core `name`. The reconnect + // call will reject for unknown names; we pre-check so the route can + // emit a deterministic 40408 envelope without depending on agent-core + // error message shape. + const known = await this.bridge.rpc.listMcpServers({ sessionId }); + if (!known.some((s) => s.name === serverId)) { + throw new McpServerNotFoundError(serverId); + } + await this.bridge.rpc.reconnectMcpServer({ sessionId, name: serverId }); + return { restarting: true }; + } + + /** + * Find a usable session id for dispatching SessionAPI calls. Returns the + * most recently created session id, or `undefined` when no sessions exist. + */ + private async _anyKnownSessionId(): Promise { + const all = await this.bridge.rpc.listSessions({}); + if (all.length === 0) return undefined; + // Sort by createdAt desc — newest sessions are the most likely to have + // an active MCP RPC binding. + const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt); + return sorted[0]?.id; + } +} + +void IMcpService; diff --git a/packages/services/src/impls/message-service-impl.ts b/packages/services/src/impls/message-service-impl.ts new file mode 100644 index 000000000..c48657bb6 --- /dev/null +++ b/packages/services/src/impls/message-service-impl.ts @@ -0,0 +1,349 @@ +/** + * `MessageServiceImpl` — adapter between protocol-shaped REST surface and + * agent-core's `AgentContextData.history` shape (Chain 3 / P1.3, W7.1). + * + * Wraps `IHarnessBridge.rpc.{listSessions, getContext}` and translates each + * `ContextMessage` (kosong `Message` extended with `origin` + `isError`) into + * the protocol-level `Message` discriminated-by-content shape (SCHEMAS §3). + * + * **Field mapping** (kosong/agent-core → protocol): + * + * ContextMessage.role → Message.role (1:1) + * ContextMessage.content[] → Message.content[] (per-part adapter; see below) + * ContextMessage.toolCalls[] → Message.content[] (appended as `tool_use` content parts) + * ContextMessage.toolCallId → Message.content[].tool_call_id (when role==='tool', body becomes a tool_result) + * ContextMessage.isError → Message.content[0].is_error (only on tool_result) + * + * Content-part adapter (kosong ContentPart → SCHEMAS MessageContent): + * + * { type:'text', text } → { type:'text', text } + * { type:'think', think, encrypted? } → { type:'thinking', thinking:think, signature?:encrypted } + * { type:'image_url', imageUrl } → { type:'image', source:{kind:'url', url:imageUrl.url } } + * (file/base64 reserved for future kosong shape) + * { type:'audio_url', audioUrl } → { type:'text', text:`[audio:${audioUrl.url}]` } + * (SCHEMAS §3 has no audio content variant; flatten lossy) + * { type:'video_url', videoUrl } → { type:'text', text:`[video:${videoUrl.url}]` } + * (same as audio — no video variant in §3) + * + * Tool messages (role === 'tool'): kosong stores the tool output as the + * `content[]` plus a top-level `toolCallId`. The adapter projects this into + * the protocol's single `{type:'tool_result', tool_call_id, output, is_error?}` + * content part, with `output` carrying the flattened text content of the + * tool message (most tool messages return a single text part, per Loop). + * + * Assistant messages with tool calls (role === 'assistant', `toolCalls.length > 0`): + * the adapter emits the content parts first, then ONE `tool_use` part per + * `ToolCall` in `toolCalls` — preserving call order. + * + * **ID synthesis**: kosong's `Message` has no `id`. We derive a deterministic + * id from `(sessionId, history_index)`: + * + * id = `msg__<6-digit-index>` + * + * Example: `msg_sess_01HZZZ_000003` for the 4th message in session + * `sess_01HZZZ`. The 6-digit padding keeps lexicographic sort = numeric sort + * for up to 1M messages per session. The format is opaque to clients — the + * only contract is "stable, time-sortable string min(1)". + * + * **Timestamp synthesis**: kosong's `Message` has no timestamp. We derive + * `created_at` from `sessionSummary.createdAt + history_index` (1ms apart per + * message) so timestamp ordering matches id ordering. Real per-message + * timestamps are deferred until agent-core surfaces per-message persistence + * (documented in `packages/protocol/src/message.ts` header + STATUS Decisions). + * + * **Pagination**: SCHEMAS §1.3 / REST §3.4 say default 50, max 100 — applied + * at the route layer. This impl receives a fully-validated query. + * - No `before_id` / `after_id`: returns the last `page_size` messages + * (created_at desc, equivalent to "history.slice(-page_size).reverse()"). + * - `before_id`: messages strictly older than the pivot (history-prefix + * before the pivot index), most-recent first. + * - `after_id`: messages strictly newer than the pivot (history-suffix after + * the pivot index), most-recent first. + * - `has_more`: true iff the underlying eligible slice is bigger than + * `page_size`. + * + * **Role filter**: applied AFTER pagination on the visible page. This matches + * SCHEMAS' "filter doesn't change cursor semantics" implicit contract. A + * later optimization can fold the filter into the slice once agent-core + * surfaces server-side message queries. + * + * **CoreAPI surface gap — session-existence check**: agent-core does NOT + * expose `getSession(id)` and `getContext` itself doesn't accept a session id + * (it expects `WithSessionId` from the proxy wrapper). We existence-check via + * `listSessions({}) + find(id)` (mirrors `SessionServiceImpl.get`). + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for type-only + * `SessionSummary` / `AgentContextData` / `ContextMessage`. Runtime calls go + * through `IHarnessBridge.rpc.`. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { + AgentContextData, + ContextMessage, + SessionSummary, +} from '@moonshot-ai/agent-core'; +import type { + Message, + MessageContent, + MessageRole, + PageResponse, + ToolUseContent, +} from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { + IMessageService, + MessageNotFoundError, + type MessageListQuery, +} from '../interfaces/message-service'; +import { SessionNotFoundError } from '../interfaces/session-service'; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; +/** Agent id used for all session-scoped getContext calls (matches agent-core convention; see `core-impl.ts:788`). */ +const MAIN_AGENT_ID = 'main'; + +/** + * Derive a stable opaque message id from (sessionId, index). Format is + * documented in the module header. + */ +export function deriveMessageId(sessionId: string, index: number): string { + const padded = String(index).padStart(6, '0'); + return `msg_${sessionId}_${padded}`; +} + +/** + * Inverse of `deriveMessageId`: parse `msg__` back into + * `{sessionId, index}`. Returns `undefined` if the id doesn't match the + * `MessageServiceImpl` ULID-shape contract. + */ +export function parseMessageId( + messageId: string, +): { sessionId: string; index: number } | undefined { + if (!messageId.startsWith('msg_')) return undefined; + const rest = messageId.slice('msg_'.length); + // sessionId may itself contain underscores (sess_01HZZZ...), so split from + // the RIGHT on '_'. + const lastUnderscore = rest.lastIndexOf('_'); + if (lastUnderscore <= 0) return undefined; + const sessionId = rest.slice(0, lastUnderscore); + const indexStr = rest.slice(lastUnderscore + 1); + if (!/^\d+$/.test(indexStr)) return undefined; + const index = Number.parseInt(indexStr, 10); + if (!Number.isFinite(index) || index < 0) return undefined; + return { sessionId, index }; +} + +/** + * kosong's `Message.role` is `'system' | 'user' | 'assistant' | 'tool'` — + * already aligned with SCHEMAS §3's `MessageRole`. We pass-through. + */ +function toProtocolRole(role: ContextMessage['role']): MessageRole { + return role as MessageRole; +} + +/** + * Translate kosong content parts to SCHEMAS §3 content parts. See header + * for the full mapping table. + */ +function mapContentPart(part: ContextMessage['content'][number]): MessageContent { + switch (part.type) { + case 'text': + return { type: 'text', text: part.text }; + case 'think': { + const sig = part.encrypted; + return sig !== undefined + ? { type: 'thinking', thinking: part.think, signature: sig } + : { type: 'thinking', thinking: part.think }; + } + case 'image_url': + return { + type: 'image', + source: { kind: 'url', url: part.imageUrl.url }, + }; + case 'audio_url': + // SCHEMAS §3 has no audio content variant; flatten to a `text` marker + // so the wire shape stays well-typed without inventing new schema. + return { + type: 'text', + text: `[audio:${part.audioUrl.url}]`, + }; + case 'video_url': + return { + type: 'text', + text: `[video:${part.videoUrl.url}]`, + }; + } +} + +/** + * Build the protocol-shaped `Message.content[]` for one ContextMessage. + * + * Order: + * 1. For `tool` role: emit a SINGLE `tool_result` part. The output is the + * flattened text of the kosong message's content parts (most tool + * messages emit a single text). `is_error` is taken from `ContextMessage.isError`. + * 2. For other roles: emit each content part mapped per `mapContentPart`, + * THEN append one `tool_use` part per `ToolCall` (assistant only). + */ +function buildProtocolContent(msg: ContextMessage): MessageContent[] { + if (msg.role === 'tool') { + if (msg.toolCallId === undefined) { + // Defensive — kosong tool messages always carry toolCallId. If absent, + // fall back to text passthrough so we don't lose user-visible content. + return msg.content.map((p) => mapContentPart(p)); + } + const flattenedOutput = msg.content + .map((p) => (p.type === 'text' ? p.text : '')) + .join(''); + const part: MessageContent = msg.isError === true + ? { + type: 'tool_result', + tool_call_id: msg.toolCallId, + output: flattenedOutput, + is_error: true, + } + : { + type: 'tool_result', + tool_call_id: msg.toolCallId, + output: flattenedOutput, + }; + return [part]; + } + + const base = msg.content.map((p) => mapContentPart(p)); + + if (msg.role === 'assistant' && msg.toolCalls.length > 0) { + for (const call of msg.toolCalls) { + let parsedInput: unknown = call.arguments; + if (typeof call.arguments === 'string') { + try { + parsedInput = JSON.parse(call.arguments); + } catch { + parsedInput = call.arguments; + } + } + const part: ToolUseContent = { + type: 'tool_use', + tool_call_id: call.id, + tool_name: call.name, + input: parsedInput, + }; + base.push(part); + } + } + + return base; +} + +/** + * Convert one history-array entry into the protocol's `Message` shape. + * + * `sessionCreatedAtMs` is the session's `createdAt` (ms). We add the index + * so per-message `created_at` increases monotonically across the array. + */ +export function toProtocolMessage( + sessionId: string, + index: number, + msg: ContextMessage, + sessionCreatedAtMs: number, +): Message { + const id = deriveMessageId(sessionId, index); + const role = toProtocolRole(msg.role); + const content = buildProtocolContent(msg); + const createdAtMs = sessionCreatedAtMs + index; + return { + id, + session_id: sessionId, + role, + content, + created_at: new Date(createdAtMs).toISOString(), + }; +} + +export class MessageServiceImpl extends Disposable implements IMessageService { + constructor(@IHarnessBridge private readonly bridge: IHarnessBridge) { + super(); + } + + async list(sid: string, query: MessageListQuery): Promise> { + const summary = await this._requireSession(sid); + const context = await this._getContext(sid); + const all: Message[] = context.history.map((m, idx) => + toProtocolMessage(sid, idx, m, summary.createdAt), + ); + // SCHEMAS §1.3: "缺省返回最近 N 条 (created_at desc)" — newest first. + const desc = [...all].reverse(); + + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = desc.findIndex((m) => m.id === query.after_id); + } + + let slice: Message[]; + if (query.before_id !== undefined && pivotIndex >= 0) { + // before_id = older entries → tail of the desc array, exclusive of pivot. + slice = desc.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + // after_id = newer entries → head of the desc array, exclusive of pivot. + slice = desc.slice(0, pivotIndex); + } else { + slice = desc; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const page = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + // Role filter is applied AFTER pagination — see header. + const filtered = + query.role !== undefined ? page.filter((m) => m.role === query.role) : page; + + return { items: filtered, has_more: hasMore }; + } + + async get(sid: string, mid: string): Promise { + const summary = await this._requireSession(sid); + const parsed = parseMessageId(mid); + if (parsed === undefined || parsed.sessionId !== sid) { + throw new MessageNotFoundError(sid, mid); + } + const context = await this._getContext(sid); + const entry = context.history[parsed.index]; + if (entry === undefined) { + throw new MessageNotFoundError(sid, mid); + } + return toProtocolMessage(sid, parsed.index, entry, summary.createdAt); + } + + /** + * Confirms the session exists and returns its summary (for the timestamp + * base). Throws `SessionNotFoundError` (→ 40401) on miss. + */ + private async _requireSession(sid: string): Promise { + const all = await this.bridge.rpc.listSessions({}); + const summary = all.find((s) => s.id === sid); + if (summary === undefined) { + throw new SessionNotFoundError(sid); + } + return summary; + } + + /** + * Fetch the session's in-memory history via `getContext`. Closed sessions + * may surface an error here — re-thrown as `SessionNotFoundError` so the + * route layer maps it to 40401 (the most defensible mapping when the + * session is not currently loaded into the active session map). + */ + private async _getContext(sid: string): Promise { + try { + return await this.bridge.rpc.getContext({ sessionId: sid, agentId: MAIN_AGENT_ID }); + } catch (err) { + throw new SessionNotFoundError(sid); + } + } +} diff --git a/packages/services/src/impls/prompt-service-impl.ts b/packages/services/src/impls/prompt-service-impl.ts new file mode 100644 index 000000000..2b54c8da9 --- /dev/null +++ b/packages/services/src/impls/prompt-service-impl.ts @@ -0,0 +1,365 @@ +/** + * `PromptServiceImpl` (Chain 4 / P1.4, W7.2; abort logic for Chain 4b / W7.3) — + * adapter between protocol-shaped REST surface and agent-core's `prompt` / + * `cancel` RPC. + * + * **Three responsibilities**: + * + * 1. **Submit**: validate session existence + busy-check, mint a ULID + * `prompt_id`, derive the `user_message_id` (so the response matches + * SCHEMAS §5), and fire-and-forget `bridge.rpc.prompt(...)`. agent-core + * streams events synchronously from inside; they reach WS subscribers + * via the bus. + * + * 2. **Lifecycle observation (W7.2)**: implements `IPromptLifecycleObserver` + * so the daemon's event bus invokes `observeEvent(e)` on every published + * event. We use this to: + * - capture `turn.started` → record `promptId ↔ turnId` mapping (so + * later abort can pass the correct numeric `turnId` to + * `bridge.rpc.cancel({turnId})`). + * - capture `turn.ended` for the prompt's top-level turn → SYNTHESIZE a + * `prompt.completed` (reason='completed' or 'failed') or + * `prompt.aborted` (reason='cancelled') event. The bus then broadcasts + * these. agent-core's event union has no prompt-level types — see W7 + * §critical discovery point #2. + * + * 3. **Abort (W7.3)**: existence-check the prompt id, dispatch + * `bridge.rpc.cancel({sessionId, agentId:'main', turnId?})`. Idempotent: + * subsequent aborts on a completed/aborted prompt return + * `PromptAlreadyCompletedError` (→ envelope code 40903 with + * `data: {aborted: false}` per REST.md §3.5). + * + * **prompt_id ↔ turnId mapping** (W7 §critical discovery point #4): + * - Daemon mints `prompt_` on submit. This is a daemon-only id; agent-core + * knows nothing about it. + * - `turn.started.turnId: number` is the agent-core counterpart. On the FIRST + * `turn.started` after a submit, we associate `promptId ↔ turnId` for the + * session's active prompt. Future `turn.started` events on the same session + * without an intervening submit are nested turns — they don't reset the + * mapping. + * - On `turn.ended` matching the top-level turn (turnId equal to the original + * mapping), we synthesize the lifecycle event and clear `activePromptId`. + * + * **session.busy detection** (W7 §critical discovery point #3): the impl + * maintains `Map` where `PromptState` carries + * `promptId`, `turnId | null`, and a terminal flag. A second submit while a + * non-terminal prompt exists for the same session throws + * `SessionBusyError → 40901`. + * + * **`user_message_id` derivation**: SCHEMAS §5 mandates a `user_message_id` + * in the submit response. Per W7.1's adapter, message ids are + * `msg_{sessionId}_{6-digit-index}`. We don't yet know the index of the new + * user message (it'll be appended to the history during prompt execution). + * Until agent-core surfaces "new message id" inline, we synthesize the id + * from the prompt id itself — `msg_{sessionId}_pending_{promptId}` — and + * note this in STATUS Decisions. Real per-message ids land when agent-core + * exposes a per-message store (deferred to a later chain). + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for type-only + * `Event` / `TurnStartedEvent` etc. Runtime calls go through + * `IHarnessBridge.rpc.`. Lifecycle synthesis emits events through + * `IEventBus.publish` (also a daemon-side interface; agent-core not touched). + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { + Event, + PromptSubmission, + PromptSubmitResult, +} from '@moonshot-ai/protocol'; +import { ulid } from 'ulid'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { IEventBus } from '../interfaces/event-bus'; +import { + IPromptService, + PromptAlreadyCompletedError, + PromptNotFoundError, + SessionBusyError, + type IPromptLifecycleObserver, + type PromptAbortResult, +} from '../interfaces/prompt-service'; +import { SessionNotFoundError } from '../interfaces/session-service'; + +const MAIN_AGENT_ID = 'main'; + +/** + * Per-session "active prompt" state. Cleared on completion/abort. + * + * `turnId === null` when the prompt has been submitted but the first + * `turn.started` hasn't arrived yet (the RPC pair queues calls before + * `ready()` so the gap is small but non-zero in practice). + * + * `terminal === true` is set when `turn.ended` arrives — we keep the record + * around so abort-on-already-completed surfaces as 40903, not 40402. + */ +interface PromptState { + promptId: string; + turnId: number | null; + /** Set on `turn.ended` for the top-level turn (reason='completed'|'failed'). */ + completed: boolean; + /** Set on `turn.ended` with reason='cancelled' or after a successful abort RPC. */ + aborted: boolean; +} + +/** + * Type guard for `turn.started` agent-core events. + */ +function isTurnStarted(e: Event): e is Event & { type: 'turn.started'; turnId: number } { + return (e as { type?: string }).type === 'turn.started'; +} + +/** + * Type guard for `turn.ended` agent-core events. + */ +function isTurnEnded(e: Event): e is Event & { + type: 'turn.ended'; + turnId: number; + reason: 'completed' | 'cancelled' | 'failed'; +} { + return (e as { type?: string }).type === 'turn.ended'; +} + +/** + * `prompt.completed` synthetic event shape. Matches the agent-core `Event` + * type contract (`AgentEvent & { agentId, sessionId }`) so it flows through + * the existing `IEventBus` path. The `type` string is namespaced under + * `prompt.*` (not part of agent-core's union — see service header). + */ +export interface SyntheticPromptCompletedEvent { + readonly type: 'prompt.completed'; + readonly agentId: string; + readonly sessionId: string; + readonly promptId: string; + readonly finishedAt: string; + readonly reason: 'completed' | 'failed'; +} + +/** + * `prompt.aborted` synthetic event shape. + */ +export interface SyntheticPromptAbortedEvent { + readonly type: 'prompt.aborted'; + readonly agentId: string; + readonly sessionId: string; + readonly promptId: string; + readonly abortedAt: string; +} + +export class PromptServiceImpl + extends Disposable + implements IPromptService, IPromptLifecycleObserver +{ + /** Active prompt per session. Cleared on completion / abort emission. */ + private readonly _active = new Map(); + + constructor( + @IHarnessBridge private readonly bridge: IHarnessBridge, + @IEventBus private readonly eventBus: IEventBus, + ) { + super(); + } + + // --- IPromptService -------------------------------------------------------- + + async submit(sid: string, body: PromptSubmission): Promise { + await this._requireSession(sid); + + const existing = this._active.get(sid); + if (existing !== undefined && !existing.completed && !existing.aborted) { + throw new SessionBusyError(sid, existing.promptId); + } + + const promptId = `prompt_${ulid()}`; + const userMessageId = `msg_${sid}_pending_${promptId}`; + + this._active.set(sid, { + promptId, + turnId: null, + completed: false, + aborted: false, + }); + + // Translate protocol MessageContent → agent-core ContentPart. Only text / + // image content survive the kosong-shape boundary; tool_use / tool_result + // / thinking originate from the model, not from client submission. + const input = body.content + .map((part) => { + switch (part.type) { + case 'text': + return { type: 'text' as const, text: part.text }; + case 'image': + if (part.source.kind === 'url') { + return { + type: 'image_url' as const, + imageUrl: { url: part.source.url }, + }; + } + return undefined; + // Other content kinds (file / tool_use / tool_result / thinking) are + // not accepted from client submissions in this stage. + default: + return undefined; + } + }) + .filter((part): part is NonNullable => part !== undefined); + + // Fire-and-forget. agent-core streams events via the SDK side of the + // RPC pair which lands on `BridgeClientAPI.emitEvent → IEventBus.publish`. + // The submit RPC returns synchronously (PromptPayload → void); errors + // would manifest as later `error` events, not as a rejection here. + try { + await this.bridge.rpc.prompt({ + sessionId: sid, + agentId: MAIN_AGENT_ID, + input, + }); + } catch (err) { + // Clear our active-prompt state so the next submit succeeds; surface + // the error to the route layer. + this._active.delete(sid); + throw err; + } + + return { prompt_id: promptId, user_message_id: userMessageId }; + } + + async abort(sid: string, pid: string): Promise { + await this._requireSession(sid); + const state = this._active.get(sid); + if (state === undefined || state.promptId !== pid) { + throw new PromptNotFoundError(sid, pid); + } + if (state.completed || state.aborted) { + throw new PromptAlreadyCompletedError(sid, pid); + } + // Mark aborted optimistically — observeEvent will not re-synthesize. + state.aborted = true; + try { + const cancelArgs: { sessionId: string; agentId: string; turnId?: number } = { + sessionId: sid, + agentId: MAIN_AGENT_ID, + }; + if (state.turnId !== null) cancelArgs.turnId = state.turnId; + await this.bridge.rpc.cancel(cancelArgs); + } catch (err) { + // Roll back the optimistic flag so the route surfaces a real error; + // the caller will see a 50001 (internal) via the global error handler. + state.aborted = false; + throw err; + } + // Synthesize the prompt.aborted event immediately. agent-core may also + // emit a turn.ended(cancelled) later; observeEvent suppresses a second + // synthesis since `state.aborted === true`. + const ev: SyntheticPromptAbortedEvent = { + type: 'prompt.aborted', + agentId: MAIN_AGENT_ID, + sessionId: sid, + promptId: pid, + abortedAt: new Date().toISOString(), + }; + this.eventBus.publish(ev as unknown as Event); + return { aborted: true }; + } + + // --- IPromptLifecycleObserver -------------------------------------------- + + observeEvent(event: Event): readonly Event[] { + const sid = (event as { sessionId?: string }).sessionId; + if (sid === undefined || sid === '') return []; + const state = this._active.get(sid); + if (state === undefined) return []; + + if (isTurnStarted(event)) { + // Capture the FIRST turn.started after submit as the "top-level" turn. + // Subsequent nested turns (e.g. subagent) carry different turnId values + // and are NOT promoted to the prompt's top-level. + if (state.turnId === null) { + state.turnId = event.turnId; + } + return []; + } + + if (isTurnEnded(event)) { + // Only fire on the top-level turn end. Nested turn.ended events fly + // through without prompt-level synthesis. + if (state.turnId === null || event.turnId !== state.turnId) return []; + + // If we already synthesized via abort RPC, don't double-emit. Mark + // completed to prevent stale lookups, but emit nothing. + if (state.aborted) { + this._active.delete(sid); + return []; + } + + const reason = event.reason; + if (reason === 'cancelled') { + // The model produced a cancellation that we didn't initiate via + // abort RPC (or it slipped past the optimistic flag). Synthesize + // prompt.aborted. + state.aborted = true; + const synth: SyntheticPromptAbortedEvent = { + type: 'prompt.aborted', + agentId: MAIN_AGENT_ID, + sessionId: sid, + promptId: state.promptId, + abortedAt: new Date().toISOString(), + }; + this._active.delete(sid); + return [synth as unknown as Event]; + } + + state.completed = true; + const synth: SyntheticPromptCompletedEvent = { + type: 'prompt.completed', + agentId: MAIN_AGENT_ID, + sessionId: sid, + promptId: state.promptId, + finishedAt: new Date().toISOString(), + reason: reason === 'failed' ? 'failed' : 'completed', + }; + this._active.delete(sid); + return [synth as unknown as Event]; + } + return []; + } + + /** + * Test helper — peek at active prompt state. + */ + _activeForTest(sid: string): Readonly | undefined { + const state = this._active.get(sid); + return state === undefined ? undefined : { ...state }; + } + + /** + * Test helper — inject an active prompt record. Used by daemon e2e tests + * that need to exercise the lifecycle-synthesis path WITHOUT driving a + * real `bridge.rpc.prompt(...)` call (which would require an in-memory + * KimiCore loaded with provider credentials). Not part of the public + * contract; the underscore prefix is a "do not use in prod" signal. + */ + _injectActiveForTest(sid: string, promptId: string, turnId: number | null): void { + this._active.set(sid, { + promptId, + turnId, + completed: false, + aborted: false, + }); + } + + // --- internals ----------------------------------------------------------- + + private async _requireSession(sid: string): Promise { + const all = await this.bridge.rpc.listSessions({}); + if (!all.some((s) => s.id === sid)) { + throw new SessionNotFoundError(sid); + } + } + + override dispose(): void { + if (this._isDisposed) return; + this._active.clear(); + super.dispose(); + } +} diff --git a/packages/services/src/impls/session-service-impl.ts b/packages/services/src/impls/session-service-impl.ts new file mode 100644 index 000000000..c71982e20 --- /dev/null +++ b/packages/services/src/impls/session-service-impl.ts @@ -0,0 +1,288 @@ +/** + * `SessionServiceImpl` — adapter between protocol-shaped REST surface and + * agent-core's `CoreAPI` session methods (Chain 2 / P1.2). + * + * Wraps `IHarnessBridge.rpc.{createSession, listSessions, closeSession, + * renameSession, updateSessionMetadata, getSessionMetadata}` and translates: + * + * agent-core (camelCase + number ms) ←→ protocol (snake_case + ISO 'Z') + * + * Field mapping (agent-core → protocol): + * + * SessionSummary.id → Session.id + * SessionSummary.title? → Session.title (default "" or echoed) + * SessionSummary.workDir → Session.metadata.cwd + * SessionSummary.createdAt → Session.created_at (ISO) + * SessionSummary.updatedAt → Session.updated_at (ISO) + * SessionSummary.metadata? → Session.metadata (merged into {cwd}) + * + * SessionMeta.title → Session.title (overrides Summary if get-after-fetch) + * SessionMeta.lastPrompt → no protocol field today (drop) + * SessionMeta.custom → merged into Session.metadata + * + * Fields the daemon FILLS WITH DEFAULTS (CoreAPI does not surface them today — + * documented in `packages/protocol/src/session.ts` header + W6 STATUS): + * + * Session.status → 'idle' (no agent-core surface yet) + * Session.usage → emptySessionUsage() (no surface) + * Session.permission_rules → [] (no enumeration surface) + * Session.message_count → 0 (no surface) + * Session.last_seq → 0 (no surface) + * Session.agent_config.model → echoed from create or '' default + * + * Future chains (W7+) backfill these as agent-core surfaces grow. The wire + * stays stable. + * + * **CoreAPI gap — `get(id)`**: agent-core does NOT expose a single-session + * read returning a full `SessionSummary`. `get(id)` is implemented as + * `listSessions({}) + .find(s => s.id === id)` and throws + * `SessionNotFoundError` (→ 40401) when missing. Documented in W6 STATUS + * Decisions. + * + * **DI wiring**: this class takes `IHarnessBridge` via ctor positional arg. + * `defaultServicesModule()` adds a `SyncDescriptor(SessionServiceImpl)` entry, + * but W2's container has no ctor-arg DI, so the daemon's `start.ts` wires it + * via `ix.createInstance(SessionServiceImpl, a.get(IHarnessBridge))` then + * `services.set(ISessionService, instance)` — same pattern as HarnessBridge + * in W4. The descriptor entry is the canonical declaration; the daemon's + * manual wiring is the runtime path. + * + * **Anti-corruption**: this file imports from `@moonshot-ai/agent-core` only + * for type-only `SessionSummary` / `SessionMeta`. Runtime calls go through + * `IHarnessBridge.rpc.`, not direct CoreAPI consumption. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { JsonObject, SessionMeta, SessionSummary } from '@moonshot-ai/agent-core'; +import { + emptySessionUsage, + type PageResponse, + type Session, + type SessionCreate, + type SessionUpdate, +} from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { + ISessionService, + SessionNotFoundError, + type SessionListQuery, +} from '../interfaces/session-service'; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +/** + * Treat the incoming `metadata` object — schema-validated by zod as + * `{cwd: string}` plus arbitrary `unknown` keys — as a JSON-safe object for + * agent-core's `JsonObject` slot. We don't deep-validate here; clients can + * send non-JSON-serializable values and agent-core will reject at the RPC + * boundary. This cast keeps the adapter narrow and the wire stable. + */ +function asJsonObject(value: Record): JsonObject { + return value as unknown as JsonObject; +} + +/** + * Convert agent-core's `SessionSummary` + optional `SessionMeta` into the + * protocol-level `Session` shape. The optional `meta` argument is the result + * of `getSessionMetadata` — when present, its `title` / `custom` enrich the + * baseline summary; when absent, defaults are used. + * + * `cwd` overrides apply in this priority order: + * 1. `meta.custom.cwd` (set by daemon when update wrote a new cwd). + * 2. `summary.metadata.cwd` (when caller-supplied during create). + * 3. `summary.workDir` (agent-core canonical field). + * + * The merged `Session.metadata` keeps `cwd` plus anything in `meta.custom` + * (excluding daemon-internal `goal` plumbing — that's not protocol surface). + */ +export function toProtocolSession( + summary: SessionSummary, + meta?: SessionMeta | undefined, +): Session { + const summaryMetadata = (summary.metadata ?? {}) as Record; + const customMetadata = (meta?.custom ?? {}) as Record; + const cwd = + (typeof customMetadata['cwd'] === 'string' && (customMetadata['cwd'] as string)) || + (typeof summaryMetadata['cwd'] === 'string' && (summaryMetadata['cwd'] as string)) || + summary.workDir; + + // Strip the internal "goal" key — that's daemon-side runtime state, not + // protocol surface (SCHEMAS §2 doesn't expose it). + const { goal: _drop, ...customWithoutGoal } = customMetadata; + + const mergedMetadata: Session['metadata'] = { + ...customWithoutGoal, + cwd, + }; + + const title = meta?.title ?? summary.title ?? ''; + + return { + id: summary.id, + title, + created_at: new Date(summary.createdAt).toISOString(), + updated_at: new Date(summary.updatedAt).toISOString(), + status: 'idle', + metadata: mergedMetadata, + agent_config: { + // CoreAPI doesn't surface a session's effective model on the listSessions + // path; we leave it empty and let later chains populate via getModel + // (chain 3+). Empty string keeps the schema valid for downstream + // consumers that only inspect known keys. + model: '', + }, + usage: emptySessionUsage(), + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} + +export class SessionServiceImpl extends Disposable implements ISessionService { + constructor(@IHarnessBridge private readonly bridge: IHarnessBridge) { + super(); + } + + async create(input: SessionCreate): Promise { + // SessionCreate.metadata.cwd is REQUIRED by Zod; agent-core's createSession + // also calls `requiredWorkDir(...)` which throws if missing. + const metadataForCore = asJsonObject(input.metadata as Record); + const summary = await this.bridge.rpc.createSession({ + workDir: input.metadata.cwd, + metadata: metadataForCore, + ...(input.agent_config?.model !== undefined ? { model: input.agent_config.model } : {}), + }); + // agent-core's createSession ignores any caller-supplied title — newly + // created sessions get the default `SessionMeta.title = 'New Session'`. + // When the caller supplied a title we apply it via `renameSession` so the + // post-create get reflects it. + if (input.title !== undefined) { + try { + await this.bridge.rpc.renameSession({ sessionId: summary.id, title: input.title }); + } catch { + // If rename fails (e.g. session closed/race), continue with the + // default — the response shape is unchanged. + } + } + const meta = await this.tryGetMeta(summary.id); + return toProtocolSession(summary, meta); + } + + async list(query: SessionListQuery): Promise> { + const all = await this.bridge.rpc.listSessions({}); + // Sort by createdAt desc per REST §1.6 "最近 N 条(按 created_at desc)". + const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt); + + // Cursor: anchor on id. before_id = older than that id; after_id = newer. + // Because the underlying list is desc, "older" = AFTER in the array. + let pivotIndex = -1; + if (query.before_id !== undefined) { + pivotIndex = sorted.findIndex((s) => s.id === query.before_id); + } else if (query.after_id !== undefined) { + pivotIndex = sorted.findIndex((s) => s.id === query.after_id); + } + + let slice: typeof sorted; + if (query.before_id !== undefined && pivotIndex >= 0) { + // before_id = older entries → tail of the desc array, exclusive of pivot + slice = sorted.slice(pivotIndex + 1); + } else if (query.after_id !== undefined && pivotIndex >= 0) { + // after_id = newer entries → head of the desc array, exclusive of pivot + slice = sorted.slice(0, pivotIndex); + } else { + slice = sorted; + } + + const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; + const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); + const pageSummaries = slice.slice(0, pageSize); + const hasMore = slice.length > pageSize; + + // Hydrate each summary with its metadata. We do these in parallel — + // `getSessionMetadata` is in-memory once the session is loaded, so the + // round-trip count is what matters, not bandwidth. + const items = await Promise.all( + pageSummaries.map(async (s) => toProtocolSession(s, await this.tryGetMeta(s.id))), + ); + + // Apply post-hydration status filter if requested. Today all sessions + // are mapped to 'idle' (see header note); the filter is wired now so the + // wire contract is stable when agent-core surfaces a real status enum. + const filtered = + query.status !== undefined ? items.filter((s) => s.status === query.status) : items; + + return { items: filtered, has_more: hasMore }; + } + + async get(id: string): Promise { + const all = await this.bridge.rpc.listSessions({}); + const summary = all.find((s) => s.id === id); + if (summary === undefined) { + throw new SessionNotFoundError(id); + } + const meta = await this.tryGetMeta(id); + return toProtocolSession(summary, meta); + } + + async update(id: string, input: SessionUpdate): Promise { + // Existence check first — gives a deterministic 40401 if the id is wrong. + const all = await this.bridge.rpc.listSessions({}); + const summary = all.find((s) => s.id === id); + if (summary === undefined) { + throw new SessionNotFoundError(id); + } + + // 1) title goes through renameSession. + if (input.title !== undefined) { + await this.bridge.rpc.renameSession({ sessionId: id, title: input.title }); + } + + // 2) metadata patches go through updateSessionMetadata. agent-core's + // SessionMeta has top-level `title` + `custom`; we route protocol's + // `metadata` (catchall) into `custom` so it round-trips on the next get. + const metadataPatch = input.metadata; + if (metadataPatch !== undefined && Object.keys(metadataPatch).length > 0) { + await this.bridge.rpc.updateSessionMetadata({ + sessionId: id, + metadata: { custom: metadataPatch as Record }, + }); + } + + // 3) agent_config + permission_rules: no CoreAPI surface yet — we accept + // the input (schema-validated) but the daemon doesn't persist them + // in this chain. W7+ wires this. Documented in W6 STATUS. + + // Re-fetch to return the post-update Session. + const allAfter = await this.bridge.rpc.listSessions({}); + const summaryAfter = allAfter.find((s) => s.id === id) ?? summary; + const meta = await this.tryGetMeta(id); + return toProtocolSession(summaryAfter, meta); + } + + async delete(id: string): Promise<{ deleted: true }> { + // Existence check — deterministic 40401 even on close. + const all = await this.bridge.rpc.listSessions({}); + const summary = all.find((s) => s.id === id); + if (summary === undefined) { + throw new SessionNotFoundError(id); + } + await this.bridge.rpc.closeSession({ sessionId: id }); + return { deleted: true }; + } + + /** + * Pull a session's metadata; swallow errors (session may not be loaded into + * the active session map yet, in which case `sessionApi(id)` throws). The + * caller falls back to defaults from the summary alone. + */ + private async tryGetMeta(id: string): Promise { + try { + const meta = await this.bridge.rpc.getSessionMetadata({ sessionId: id }); + return meta; + } catch { + return undefined; + } + } +} diff --git a/packages/services/src/impls/task-service-impl.ts b/packages/services/src/impls/task-service-impl.ts new file mode 100644 index 000000000..936f78d6e --- /dev/null +++ b/packages/services/src/impls/task-service-impl.ts @@ -0,0 +1,114 @@ +/** + * `TaskServiceImpl` — adapter between protocol-shaped REST surface and + * agent-core's `getBackground` + `stopBackground` (Chain 8 / P1.8, W9.2). + * + * Wraps `IHarnessBridge.rpc.{getBackground, stopBackground}` and adapts + * `BackgroundTaskInfo` shapes via `task-adapter.toProtocolTask`. + * + * **CoreAPI surface — agent-scoped**: both `getBackground` and `stopBackground` + * live on the `AgentAPI` (which the SessionAPI proxy decorates with + * `WithSessionId>`). We dispatch against agent id `'main'`, + * matching the convention used by `MessageServiceImpl.getContext`. + * + * **Error mapping**: + * - `TaskNotFoundError` — when the task id is absent from the + * session's background-task list. + * - `TaskAlreadyFinishedError` — when the task is in a wire-terminal + * status (`completed|failed|cancelled`). + * + * `cancel` performs a pre-check via `getBackground` so it can emit the + * idempotent 40904 envelope before invoking `stopBackground`. This guards + * against agent-core's `stopBackground` being a fire-and-forget no-op for + * already-finished tasks (no thrown error → we'd return a fake `cancelled:true`). + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `Disposable` base type. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { BackgroundTask } from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { + ITaskService, + TaskAlreadyFinishedError, + TaskNotFoundError, + type TaskListQuery, +} from '../interfaces/task-service'; +import { SessionNotFoundError } from '../interfaces/session-service'; +import { isTerminalStatus, toProtocolTask } from '../adapter/task-adapter'; + +const MAIN_AGENT_ID = 'main'; + +export class TaskServiceImpl extends Disposable implements ITaskService { + constructor(@IHarnessBridge private readonly bridge: IHarnessBridge) { + super(); + } + + async list(sessionId: string, query: TaskListQuery): Promise { + await this._requireSession(sessionId); + const raw = await this._getAllRaw(sessionId); + const all = raw.map((info) => toProtocolTask(sessionId, info)); + if (query.status !== undefined) { + return all.filter((t) => t.status === query.status); + } + return all; + } + + async get(sessionId: string, taskId: string): Promise { + await this._requireSession(sessionId); + const raw = await this._getAllRaw(sessionId); + const found = raw.find((t) => t.taskId === taskId); + if (found === undefined) { + throw new TaskNotFoundError(sessionId, taskId); + } + return toProtocolTask(sessionId, found); + } + + async cancel(sessionId: string, taskId: string): Promise<{ cancelled: true }> { + await this._requireSession(sessionId); + // Pre-fetch so we can distinguish the 40406 (not found) and 40904 (already + // finished) cases deterministically — agent-core's `stopBackground` is a + // fire-and-forget call that doesn't surface this. + const raw = await this._getAllRaw(sessionId); + const found = raw.find((t) => t.taskId === taskId); + if (found === undefined) { + throw new TaskNotFoundError(sessionId, taskId); + } + const wireStatus = toProtocolTask(sessionId, found).status; + if (isTerminalStatus(wireStatus)) { + throw new TaskAlreadyFinishedError(sessionId, taskId, wireStatus); + } + await this.bridge.rpc.stopBackground({ + sessionId, + agentId: MAIN_AGENT_ID, + taskId, + }); + return { cancelled: true }; + } + + // --- internals ------------------------------------------------------------ + + private async _requireSession(sessionId: string): Promise { + const all = await this.bridge.rpc.listSessions({}); + if (!all.some((s) => s.id === sessionId)) { + throw new SessionNotFoundError(sessionId); + } + } + + private async _getAllRaw( + sessionId: string, + ): Promise>[number]>> { + try { + return await this.bridge.rpc.getBackground({ + sessionId, + agentId: MAIN_AGENT_ID, + }); + } catch { + // Session not loaded; treat as empty. + return []; + } + } +} + +void ITaskService; diff --git a/packages/services/src/impls/tool-service-impl.ts b/packages/services/src/impls/tool-service-impl.ts new file mode 100644 index 000000000..f679477fd --- /dev/null +++ b/packages/services/src/impls/tool-service-impl.ts @@ -0,0 +1,67 @@ +/** + * `ToolServiceImpl` — adapter between protocol-shaped REST surface and + * agent-core's `getTools` CoreAPI (Chain 7 / P1.7, W9.1). + * + * Wraps `IHarnessBridge.rpc.getTools({sessionId, agentId})` and maps + * `ToolInfo[]` → `ToolDescriptor[]` via `tool-adapter.toProtocolTool`. + * + * **CoreAPI surface — `getTools` is AGENT-scoped**: agent-core's `getTools` + * payload requires `{sessionId, agentId}` (CoreAPI extends + * `WithSessionId>`). The wire surface + * `GET /v1/tools?session_id=` is "session-effective"; we forward to the + * `'main'` agent. When NO `session_id` is supplied (REST.md §3.8 line 430: + * "不传则返回全局可用列表"), we attempt to use any known session to enumerate + * the global tool registry; when no sessions exist we return an empty list. + * agent-core's `ToolInfo` set is process-global (the registrar holds builtins + * + skills + MCP tools) so any active session surfaces the same set; the + * `active` flag (which we drop) is the only per-agent variation. + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `Disposable` base type. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { ToolDescriptor } from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { IToolService } from '../interfaces/tool-service'; +import { toProtocolTool, type AgentCoreToolInfoLike } from '../adapter/tool-adapter'; + +/** Matches the convention used elsewhere in services (message-service uses 'main'). */ +const MAIN_AGENT_ID = 'main'; + +export class ToolServiceImpl extends Disposable implements IToolService { + constructor(@IHarnessBridge private readonly bridge: IHarnessBridge) { + super(); + } + + async list(sessionId?: string): Promise { + const resolvedSid = sessionId ?? (await this._anyKnownSessionId()); + if (resolvedSid === undefined) return []; + let raw: readonly unknown[]; + try { + raw = await this.bridge.rpc.getTools({ + sessionId: resolvedSid, + agentId: MAIN_AGENT_ID, + }); + } catch { + // Session not loaded into the active session map; return empty rather + // than surface a 500 — the global-list semantics is "best effort". + return []; + } + return raw.map((t) => toProtocolTool(t as AgentCoreToolInfoLike)); + } + + /** + * Find a usable session id when caller hasn't supplied one. Returns the + * most recently created session id, or `undefined` when no sessions exist. + */ + private async _anyKnownSessionId(): Promise { + const all = await this.bridge.rpc.listSessions({}); + if (all.length === 0) return undefined; + const sorted = [...all].sort((a, b) => b.createdAt - a.createdAt); + return sorted[0]?.id; + } +} + +void IToolService; diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 000000000..494b391b6 --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1,66 @@ +/** + * `@moonshot-ai/services` — in-process service container for the kimi-code + * daemon. Houses broker interfaces (reverse-RPC: KimiCore → daemon) and the + * `HarnessBridge` that owns the in-process `KimiCore` instance. + * + * Positive `IXxxService` interfaces (e.g. `ISessionService`) land per Chain + * in Phase 1; W3 ships only the broker (reverse) side + the bridge shell. + */ + +export * from './interfaces'; +export { BridgeClientAPI } from './bridge/bridge-client-api'; +export type { BridgeClientAPIDeps } from './bridge/bridge-client-api'; +export { + HarnessBridge, + IHarnessBridge, + type HarnessBridgeOptions, + type HarnessRPC, +} from './bridge/harness-bridge'; +export { + defaultServicesModule, + type ServiceModuleEntry, +} from './module'; +export { + SessionServiceImpl, + toProtocolSession, +} from './impls/session-service-impl'; +export { + MessageServiceImpl, + deriveMessageId, + parseMessageId, + toProtocolMessage, +} from './impls/message-service-impl'; +export { PromptServiceImpl } from './impls/prompt-service-impl'; +export type { + SyntheticPromptAbortedEvent, + SyntheticPromptCompletedEvent, +} from './impls/prompt-service-impl'; +export { ToolServiceImpl } from './impls/tool-service-impl'; +export { McpServiceImpl } from './impls/mcp-service-impl'; +export { TaskServiceImpl } from './impls/task-service-impl'; + +// Adapter helpers (protocol wire shape ↔ in-process SDK shape). One file per +// reverse-RPC interaction (W8.1: approval, W8.2: question). Daemon REST +// handlers + brokers consume these. +export { + toAgentCoreResponse as approvalToAgentCoreResponse, + toBrokerRequest as approvalToBrokerRequest, + type ToBrokerRequestParams as ApprovalToBrokerRequestParams, +} from './adapter/approval-adapter'; +export { + toAgentCoreResponse as questionToAgentCoreResponse, + toBrokerRequest as questionToBrokerRequest, + dismissedResult as questionDismissedResult, + type QuestionToBrokerRequestParams, +} from './adapter/question-adapter'; +// W9.1 / Chain 7 — Tool + MCP adapter. +export { + toProtocolTool, + toProtocolMcpServer, + type AgentCoreToolInfoLike, +} from './adapter/tool-adapter'; +// W9.2 / Chain 8 — Background Task adapter. +export { toProtocolTask, isTerminalStatus } from './adapter/task-adapter'; +// NOTE: `registerHarnessBridge` (./bridge/lifecycle.ts) is intentionally not +// re-exported. `defaultServicesModule()` is the canonical wiring path; the +// registry-style helper exists only for legacy side-effect-on-import contexts. diff --git a/packages/services/src/interfaces/approval-broker.ts b/packages/services/src/interfaces/approval-broker.ts new file mode 100644 index 000000000..7298242b8 --- /dev/null +++ b/packages/services/src/interfaces/approval-broker.ts @@ -0,0 +1,46 @@ +/** + * Reverse-RPC broker: routes `ApprovalRequest`s coming out of `KimiCore` to a + * waiter (web client over WS in P1.x, mock handler in tests) and resolves the + * promise when the response arrives. + * + * **Shape note (W3 placeholder):** the broker's `request()` returns the + * agent-core in-process `ApprovalResponse` (`{ decision, scope?, feedback?, + * selectedLabel? }`, see `packages/agent-core/src/rpc/sdk-api.ts:10`). + * SCHEMAS.md §6.1 defines a protocol-level `ApprovalResponse` with the same + * fields in snake_case (`selected_label`). The protocol↔in-process adapter + * lives at the daemon/REST boundary (W4+ / Chain 5, see SCHEMAS.md §6.4) — + * brokers stay on the SDK side. When Chain 5 (W8) ships the protocol Zod + * validator, this interface stays SDK-shaped; the REST handler adapts. + * + * `request()` is the agent-facing entry. `resolve()` is the answer-facing + * entry — concrete impls keep a `Map` and resolve from + * REST/WS callbacks. The 60s timeout + queue + WS broadcast are W4/Chain 5. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { ApprovalRequest, ApprovalResponse } from '@moonshot-ai/agent-core'; +import type {} from '@moonshot-ai/protocol'; // type-only marker — keep protocol dep referenced + +// Re-export ApprovalResponse for service-side consumers so they don't have to +// also depend on agent-core directly. +export type { ApprovalRequest, ApprovalResponse }; + +export interface IApprovalBroker { + /** + * Called by the bridge when KimiCore needs user approval. Resolves with the + * user's decision (or a cancelled response if no client is connected / + * timeout elapses — concrete-impl policy). + */ + request(req: ApprovalRequest & { sessionId: string; agentId: string }): Promise; + + /** + * Called by the answer-side (REST handler / TUI / mock) to settle a pending + * `request()` promise. `id` matches `ApprovalRequest.toolCallId` (PLAN D4 — + * the toolCallId is the stable correlation key; W4 may add a separate + * `request_id` if the prefix harmonization decides so). + */ + resolve(id: string, response: ApprovalResponse): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IApprovalBroker = createDecorator('IApprovalBroker'); diff --git a/packages/services/src/interfaces/event-bus.ts b/packages/services/src/interfaces/event-bus.ts new file mode 100644 index 000000000..718cf8c98 --- /dev/null +++ b/packages/services/src/interfaces/event-bus.ts @@ -0,0 +1,26 @@ +/** + * Reverse-RPC broker: emits ordered `Event`s coming out of `KimiCore` to the + * outside world (daemon → WS clients in P1.x; tests can use a no-op impl in W3). + * + * The broker is the receive-end of the in-process RPC bridge: when an agent + * step emits an event, `HarnessBridge`'s `BridgeClientAPI.emitEvent(event)` + * forwards it to `IEventBus.publish(event)`. Concrete impls land in W5/Chain N. + * + * Decorator name `'IEventBus'` is the diagnostic string surfaced in + * `CyclicDependencyError.path` and `'No service registered for identifier ...'` + * messages. Keep it stable across phases. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { Event } from '@moonshot-ai/protocol'; + +export interface IEventBus { + /** + * Publish a fully-formed `Event` to all subscribers. Synchronous; the bridge + * does not await delivery — fan-out is the broker's concern. + */ + publish(event: Event): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IEventBus = createDecorator('IEventBus'); diff --git a/packages/services/src/interfaces/index.ts b/packages/services/src/interfaces/index.ts new file mode 100644 index 000000000..c3435c85c --- /dev/null +++ b/packages/services/src/interfaces/index.ts @@ -0,0 +1,37 @@ +/** + * Barrel for broker interfaces. Each interface pairs a TS type with a + * `createDecorator`-built `ServiceIdentifier` value of the same name. + * + * Positive (daemon→harness) service interfaces (`ISessionService`, ...) + * land here as Phase 1 chains add them. Each gets a paired impl in + * `../impls/` plus a `defaultServicesModule()` entry. + */ + +export { IEventBus } from './event-bus'; +export type { } from './event-bus'; +export { IApprovalBroker } from './approval-broker'; +export type { ApprovalRequest, ApprovalResponse } from './approval-broker'; +export { IQuestionBroker } from './question-broker'; +export type { QuestionRequest, QuestionResult } from './question-broker'; +export { ISessionService, SessionNotFoundError } from './session-service'; +export type { SessionListQuery } from './session-service'; +export { IMessageService, MessageNotFoundError } from './message-service'; +export type { MessageListQuery } from './message-service'; +export { + IPromptService, + PromptAlreadyCompletedError, + PromptNotFoundError, + SessionBusyError, +} from './prompt-service'; +export type { + IPromptLifecycleObserver, + PromptAbortResult, +} from './prompt-service'; +export { IToolService } from './tool-service'; +export { IMcpService, McpServerNotFoundError } from './mcp-service'; +export { + ITaskService, + TaskAlreadyFinishedError, + TaskNotFoundError, +} from './task-service'; +export type { TaskListQuery } from './task-service'; diff --git a/packages/services/src/interfaces/mcp-service.ts b/packages/services/src/interfaces/mcp-service.ts new file mode 100644 index 000000000..9b0d09a64 --- /dev/null +++ b/packages/services/src/interfaces/mcp-service.ts @@ -0,0 +1,60 @@ +/** + * `IMcpService` — daemon-facing MCP server surface (Chain 7 / P1.7, W9.1). + * + * Wraps `IHarnessBridge.rpc.{listMcpServers, reconnectMcpServer}` and adapts + * the agent-core `McpServerInfo` shape into SCHEMAS §8 `McpServer`. The + * adapter lives at `packages/services/src/adapter/tool-adapter.ts`. + * + * **CoreAPI surface used**: + * - `bridge.rpc.listMcpServers({}) => readonly McpServerInfo[]` + * (packages/agent-core/src/rpc/core-api.ts:344). + * - `bridge.rpc.reconnectMcpServer({name})` (line 346). + * + * **Server identity**: REST.md §3.8 uses `{mcp_server_id}` in the path; + * agent-core surfaces only `name`. We treat name-as-id at the wire boundary + * (stable within a daemon process lifetime). + * + * **Error model**: + * - `MCP_SERVER_NOT_FOUND` (40408) is raised by the impl via + * `McpServerNotFoundError`. The route maps it to envelope code 40408. + * + * **Side effects of restart** (REST.md §3.8 line 442): daemon should broadcast + * `event.mcp.disconnected` → `event.mcp.connecting` → `event.mcp.connected|error`. + * That observability arrives once the bridge gets MCP lifecycle observers + * (out of W9 scope; W12+). + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `createDecorator` value used to mint the service identifier. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { McpServer } from '@moonshot-ai/protocol'; + +export interface IMcpService { + /** Return all MCP servers known to the in-process KimiCore. */ + list(): Promise; + + /** + * Trigger an MCP server reconnect. Returns `{ restarting: true }` on a + * successful enqueue. Throws `McpServerNotFoundError` (→ 40408) when the + * server id is not registered. + */ + restart(serverId: string): Promise<{ restarting: true }>; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IMcpService = createDecorator('IMcpService'); + +/** + * Sentinel — daemon's route layer catches this and maps to envelope `code: + * 40408 mcp.server_not_found`. Other thrown errors fall through to W4's + * `installErrorHandler` (→ 50001). + */ +export class McpServerNotFoundError extends Error { + readonly serverId: string; + constructor(serverId: string) { + super(`mcp server ${serverId} does not exist`); + this.name = 'McpServerNotFoundError'; + this.serverId = serverId; + } +} diff --git a/packages/services/src/interfaces/message-service.ts b/packages/services/src/interfaces/message-service.ts new file mode 100644 index 000000000..383101f5c --- /dev/null +++ b/packages/services/src/interfaces/message-service.ts @@ -0,0 +1,76 @@ +/** + * `IMessageService` — daemon-facing message history interface (Chain 3 / P1.3, W7.1). + * + * Wraps `IHarnessBridge.rpc.getContext({sessionId, agentId})` and adapts + * agent-core's `ContextMessage` history shape (kosong `Message` + origin) to + * the protocol's SCHEMAS.md §3 `Message` discriminated-by-content union. + * + * Endpoint mapping (REST.md §3.4): + * GET /v1/sessions/{sid}/messages → list(sid, ListMessagesQuery) + * GET /v1/sessions/{sid}/messages/{mid} → get(sid, mid) + * + * Sentinel errors: + * - `SessionNotFoundError` → 40401 at the route layer + * - `MessageNotFoundError` → 40403 at the route layer + * + * The adapter is documented in `packages/services/src/impls/message-service-impl.ts`. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { + CursorQuery, + Message, + MessageRole, + PageResponse, +} from '@moonshot-ai/protocol'; + +/** + * Listing query — `before_id`/`after_id` + `page_size` mutex is enforced + * by `cursorQuerySchema`. The service layer adds an optional role filter. + */ +export interface MessageListQuery extends CursorQuery { + role?: MessageRole; +} + +export interface IMessageService { + /** + * `GET /v1/sessions/{sid}/messages` — paginated message history. + * + * Default `page_size = 50`, max 100 (REST.md §3.4 / SCHEMAS §1.3). + * Defaults are applied at the route layer. + * + * `before_id` / `after_id` are cursors keyed on message id (ULID, time + * sortable). Result order is `created_at desc`; clients displaying in + * ascending order should `.reverse()`. + * + * Throws `SessionNotFoundError` (→ 40401) when `sid` doesn't exist. + */ + list(sid: string, query: MessageListQuery): Promise>; + + /** + * `GET /v1/sessions/{sid}/messages/{mid}` — single message by id. + * + * Throws `SessionNotFoundError` (→ 40401) when `sid` doesn't exist. + * Throws `MessageNotFoundError` (→ 40403) when the session is known but + * no message with `mid` lives in its history. + */ + get(sid: string, mid: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IMessageService = createDecorator('IMessageService'); + +/** + * Sentinel error — daemon's route layer catches and maps to + * `code: 40403` (message.not_found). + */ +export class MessageNotFoundError extends Error { + readonly sessionId: string; + readonly messageId: string; + constructor(sessionId: string, messageId: string) { + super(`message ${messageId} does not exist in session ${sessionId}`); + this.name = 'MessageNotFoundError'; + this.sessionId = sessionId; + this.messageId = messageId; + } +} diff --git a/packages/services/src/interfaces/prompt-service.ts b/packages/services/src/interfaces/prompt-service.ts new file mode 100644 index 000000000..13fe12594 --- /dev/null +++ b/packages/services/src/interfaces/prompt-service.ts @@ -0,0 +1,135 @@ +/** + * `IPromptService` — daemon-facing prompt submission interface + * (Chain 4 / P1.4, W7.2; abort in Chain 4b / P1.4b, W7.3). + * + * Wraps `IHarnessBridge.rpc.{prompt, cancel}` and tracks per-session "active + * prompt" state for the `session.busy` (40901) and `prompt.already_completed` + * (40903) error mappings. + * + * Endpoint mapping (REST.md §3.5): + * POST /v1/sessions/{sid}/prompts → submit(sid, body) + * POST /v1/sessions/{sid}/prompts/{pid}:abort → abort(sid, pid) + * + * Sentinel errors: + * - `SessionNotFoundError` → 40401 at the route layer + * - `SessionBusyError` → 40901 at the route layer + * - `PromptNotFoundError` → 40402 at the route layer + * - `PromptAlreadyCompletedError` → 40903 at the route layer (NOTE: per + * REST.md §3.5 this is "idempotent success" — wire `data` is + * `{aborted: false, at_seq: }`, envelope.code is 40903) + * + * **Event lifecycle observability**: the service also implements + * `IPromptLifecycleObserver` and is registered with the daemon's event bus + * so it can: + * 1. Capture `turn.started` → record `promptId ↔ turnId` mapping for the + * session's active prompt. + * 2. Capture `turn.ended` (top-level) → synthesize `prompt.completed` / + * `prompt.aborted` events the bus broadcasts to subscribers. agent-core + * doesn't emit these directly — the daemon synthesizes them so clients + * get prompt-level lifecycle without sniffing the turn graph. + * + * Documented further in `packages/services/src/impls/prompt-service-impl.ts`. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { Event, PromptSubmission, PromptSubmitResult } from '@moonshot-ai/protocol'; + +export interface PromptAbortResult { + /** True iff this call performed the cancel (false on idempotent already-completed). */ + aborted: boolean; + /** Per-session seq at the moment the abort was issued (informational). */ + at_seq?: number; +} + +export interface IPromptService { + /** + * `POST /v1/sessions/{sid}/prompts` — submit a prompt for execution. + * + * Throws `SessionNotFoundError` (→ 40401) for unknown `sid`. + * Throws `SessionBusyError` (→ 40901) when another prompt is active. + */ + submit(sid: string, body: PromptSubmission): Promise; + + /** + * `POST /v1/sessions/{sid}/prompts/{pid}:abort` — cancel an in-flight prompt. + * + * Per REST.md §3.5: aborting an already-completed prompt returns + * `PromptAlreadyCompletedError` (→ 40903 with `data.aborted: false`). + * Idempotent calls (same id, multiple aborts) collapse to a single cancel + * RPC + subsequent calls return 40903. + * + * Throws `SessionNotFoundError` (→ 40401) for unknown `sid`. + * Throws `PromptNotFoundError` (→ 40402) when `pid` is unknown for `sid`. + */ + abort(sid: string, pid: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IPromptService = createDecorator('IPromptService'); + +/** + * Optional lifecycle observer surface. The daemon's `IEventBus` impl invokes + * `observe(event)` AFTER fan-out to subscribers. The observer may return zero + * or more synthetic events that the bus then publishes as if they had come + * from the agent. This is how we synthesize `prompt.completed` and + * `prompt.aborted` (agent-core's event union has no such types — see W7 + * prompt §critical discovery point #2). + * + * Keeping it a separate interface lets the EventBus accept any number of + * observers (today just the prompt service; tomorrow potentially a session + * usage aggregator etc.) without growing API surface. + */ +export interface IPromptLifecycleObserver { + /** + * Called by the event bus on EVERY published event. Implementations should + * be fast + side-effect-light; long-running follow-ups must be queued + * elsewhere. Returns an array of derived events (possibly empty) to publish + * after the original event's fan-out completes. + */ + observeEvent(event: Event): readonly Event[]; +} + +/** + * Sentinel — REST → 40901 `session.busy`. Carries the active prompt id so the + * route layer can include it in `details`. + */ +export class SessionBusyError extends Error { + readonly sessionId: string; + readonly activePromptId: string; + constructor(sessionId: string, activePromptId: string) { + super(`session ${sessionId} is busy (prompt ${activePromptId} in flight)`); + this.name = 'SessionBusyError'; + this.sessionId = sessionId; + this.activePromptId = activePromptId; + } +} + +/** + * Sentinel — REST → 40402 `prompt.not_found`. + */ +export class PromptNotFoundError extends Error { + readonly sessionId: string; + readonly promptId: string; + constructor(sessionId: string, promptId: string) { + super(`prompt ${promptId} does not exist in session ${sessionId}`); + this.name = 'PromptNotFoundError'; + this.sessionId = sessionId; + this.promptId = promptId; + } +} + +/** + * Sentinel — REST → 40903 `prompt.already_completed`. Carries the prompt id + * and a flag so the route layer can emit the documented + * `data: {aborted: false}` envelope despite the non-zero code. + */ +export class PromptAlreadyCompletedError extends Error { + readonly sessionId: string; + readonly promptId: string; + constructor(sessionId: string, promptId: string) { + super(`prompt ${promptId} in session ${sessionId} is already completed`); + this.name = 'PromptAlreadyCompletedError'; + this.sessionId = sessionId; + this.promptId = promptId; + } +} diff --git a/packages/services/src/interfaces/question-broker.ts b/packages/services/src/interfaces/question-broker.ts new file mode 100644 index 000000000..e6e3d0aab --- /dev/null +++ b/packages/services/src/interfaces/question-broker.ts @@ -0,0 +1,53 @@ +/** + * Reverse-RPC broker: routes `QuestionRequest`s coming out of `KimiCore` to a + * waiter (web client over WS in P1.x, mock handler in tests) and resolves the + * promise when the response arrives — or `dismiss()`-es it if the user closes + * the panel (SCHEMAS.md §6.3). + * + * **Shape note (W3 placeholder):** the broker returns the in-process + * `QuestionResult = null | QuestionAnswers | QuestionResponse` (see + * `packages/agent-core/src/rpc/sdk-api.ts:48`). SCHEMAS.md §6.2/§6.4 defines + * a protocol-level `QuestionResponse` with a 5-kind discriminated union + * (`single` / `multi` / `other` / `multi_with_other` / `skipped`); the + * protocol↔in-process adapter lives at the daemon boundary (Chain 6 / W8), + * NOT inside the broker interface. This keeps the SDK side of the bridge + * untouched and confines protocol shape decisions to one place. + * + * `dismiss()` exists because Question has a tri-state outcome (resolved with + * partial answers / fully dismissed / timeout) — see SCHEMAS.md §6.3. + * Approval is binary (decision present or not), so it has no `dismiss()`. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { QuestionRequest, QuestionResult } from '@moonshot-ai/agent-core'; +import type {} from '@moonshot-ai/protocol'; // type-only marker — keep protocol dep referenced + +// Re-export for service-side consumers. +export type { QuestionRequest, QuestionResult }; + +export interface IQuestionBroker { + /** + * Called by the bridge when KimiCore needs the user to answer a question. + * Resolves with the in-process `QuestionResult` (null = no handler / fully + * dismissed). Concrete impls own timeout policy. + */ + request(req: QuestionRequest & { sessionId: string; agentId: string }): Promise; + + /** + * Called by the answer-side (REST handler / TUI / mock) to settle a pending + * `request()` with user answers. `id` matches `QuestionRequest`'s correlation + * id (`turnId`+`toolCallId` today; SCHEMAS.md §6.2's `question_id` once + * Chain 6 lands). + */ + resolve(id: string, response: QuestionResult): void; + + /** + * Called when the user dismisses the panel without answering (ESC / close). + * Concrete impls resolve the pending `request()` with the equivalent of + * `dismissedQuestionResult()` (`packages/agent-core` — see SCHEMAS.md §6.3). + */ + dismiss(id: string): void; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IQuestionBroker = createDecorator('IQuestionBroker'); diff --git a/packages/services/src/interfaces/session-service.ts b/packages/services/src/interfaces/session-service.ts new file mode 100644 index 000000000..c8443683d --- /dev/null +++ b/packages/services/src/interfaces/session-service.ts @@ -0,0 +1,97 @@ +/** + * `ISessionService` — daemon-facing session CRUD interface (Chain 2 / P1.2). + * + * Wraps `IHarnessBridge.rpc.{createSession, listSessions, closeSession, + * updateSessionMetadata}` and adapts agent-core's camelCase + number + * timestamps to the protocol's snake_case + ISO 8601 `Z` shape (see SCHEMAS.md + * §2). The adapter is the load-bearing piece of this chain — every later + * service in `@moonshot-ai/services` (messages, prompts, ...) inherits this + * camelCase ↔ snake_case + number ↔ ISO pattern. + * + * **Why a service layer**: REST handlers in `@moonshot-ai/daemon` are + * disallowed from importing `@moonshot-ai/kimi-code-sdk` (anti-corruption + * test). Routes call `accessor.get(ISessionService).(...)`; the + * adapter is here. + * + * **CoreAPI shape gap**: agent-core does NOT expose `getSession(id)` returning + * a full `SessionSummary` — `getSessionMetadata` returns the smaller + * `SessionMeta` shape. `get(id)` is implemented via `listSessions({})` + + * filter, throwing `SessionNotFoundError` (→ 40401) when the id is absent. + * See `SessionServiceImpl` for details + the gap documentation. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { + CursorQuery, + PageResponse, + Session, + SessionCreate, + SessionUpdate, +} from '@moonshot-ai/protocol'; + +/** + * Listing query — `before_id`/`after_id` + `page_size` mutual exclusivity is + * already enforced by `cursorQuerySchema`. The service layer adds an optional + * status filter the daemon layer parses out of the REST query string. + */ +export interface SessionListQuery extends CursorQuery { + status?: import('@moonshot-ai/protocol').SessionStatus; +} + +export interface ISessionService { + /** + * `POST /v1/sessions` — create a new session. Requires `metadata.cwd` + * (agent-core's `createSession` calls `requiredWorkDir`; missing cwd ⇒ throw). + */ + create(input: SessionCreate): Promise; + + /** + * `GET /v1/sessions` — list sessions. Cursor pagination is applied + * client-side over `bridge.rpc.listSessions({})` (the CoreAPI surface + * doesn't take a cursor today — see W6 STATUS Decisions). Default + * `page_size = 20` per REST.md §1.6 is applied at the route layer, not here. + */ + list(query: SessionListQuery): Promise>; + + /** + * `GET /v1/sessions/{id}` — single session by id. Implemented as + * `listSessions({}) + .find(id)`; throws `SessionNotFoundError` (→ 40401) + * when not found. + */ + get(id: string): Promise; + + /** + * `PATCH /v1/sessions/{id}` — partial update. Backed by + * `updateSessionMetadata` for metadata changes; `title` writes through the + * same path (mapped onto agent-core's `SessionMeta.title`). + * Returns the post-update Session. + */ + update(id: string, input: SessionUpdate): Promise; + + /** + * `DELETE /v1/sessions/{id}` — close (= soft-delete in v1) the session. + * Backed by `bridge.rpc.closeSession({sessionId})`. CoreAPI does not + * surface a hard delete; first daemon version conflates close == delete + * (see W6 STATUS Decisions). + * + * Returns `{ deleted: true }` envelope shape per REST §3.3. + */ + delete(id: string): Promise<{ deleted: true }>; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ISessionService = createDecorator('ISessionService'); + +/** + * Sentinel error class — daemon's route layer catches this and maps to + * `code: 40401` (session.not_found). Other errors fall through to the W4 + * `installErrorHandler` (→ 50001 internal). + */ +export class SessionNotFoundError extends Error { + readonly sessionId: string; + constructor(sessionId: string) { + super(`session ${sessionId} does not exist`); + this.name = 'SessionNotFoundError'; + this.sessionId = sessionId; + } +} diff --git a/packages/services/src/interfaces/task-service.ts b/packages/services/src/interfaces/task-service.ts new file mode 100644 index 000000000..7ae867e2a --- /dev/null +++ b/packages/services/src/interfaces/task-service.ts @@ -0,0 +1,85 @@ +/** + * `ITaskService` — daemon-facing background task surface (Chain 8 / P1.8, W9.2). + * + * Wraps `IHarnessBridge.rpc.{getBackground, stopBackground}` and adapts + * `BackgroundTaskInfo` (camelCase + ms timestamps + agent-core literal sets) + * into SCHEMAS §7 `BackgroundTask` (snake_case + ISO + spec literal sets). + * + * **CoreAPI surface used**: + * - `bridge.rpc.getBackground({sessionId, agentId, activeOnly?, limit?}) + * => readonly BackgroundTaskInfo[]` + * (packages/agent-core/src/rpc/core-api.ts:334 + WithSessionId+WithAgentId + * injection). + * - `bridge.rpc.stopBackground({sessionId, agentId, taskId, reason?})` + * (line 323). + * + * **Error model**: + * - `TaskNotFoundError` (→ 40406) when the task id does not exist within + * the session. + * - `TaskAlreadyFinishedError` (→ 40904) when the task has reached a + * terminal status (completed/failed/cancelled/timed_out/killed/lost). + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `createDecorator` value used to mint the service identifier. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { BackgroundTask, BackgroundTaskStatus } from '@moonshot-ai/protocol'; + +export interface TaskListQuery { + readonly status?: BackgroundTaskStatus; +} + +export interface ITaskService { + /** Return the (full) list of background tasks for the session. */ + list(sessionId: string, query: TaskListQuery): Promise; + + /** + * Return a single background task. Throws `TaskNotFoundError` (→ 40406) + * when the task id is not found. + */ + get(sessionId: string, taskId: string): Promise; + + /** + * Cancel a running task. Throws: + * - `TaskNotFoundError` → 40406 + * - `TaskAlreadyFinishedError` → 40904 (daemon emits custom envelope + * with `data:{cancelled:false}`) + */ + cancel(sessionId: string, taskId: string): Promise<{ cancelled: true }>; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ITaskService = createDecorator('ITaskService'); + +/** + * Sentinel — daemon route maps to `code: 40406 task.not_found`. + */ +export class TaskNotFoundError extends Error { + readonly sessionId: string; + readonly taskId: string; + constructor(sessionId: string, taskId: string) { + super(`task ${taskId} does not exist in session ${sessionId}`); + this.name = 'TaskNotFoundError'; + this.sessionId = sessionId; + this.taskId = taskId; + } +} + +/** + * Sentinel — daemon route maps to `code: 40904 task.already_finished`. The + * envelope's `data` shape is `{ cancelled: false }` (REST.md §3.7 idempotent + * shape mirroring 40903 + 40902 precedent). + */ +export class TaskAlreadyFinishedError extends Error { + readonly sessionId: string; + readonly taskId: string; + readonly currentStatus: BackgroundTaskStatus; + constructor(sessionId: string, taskId: string, currentStatus: BackgroundTaskStatus) { + super(`task ${taskId} already finished (status: ${currentStatus})`); + this.name = 'TaskAlreadyFinishedError'; + this.sessionId = sessionId; + this.taskId = taskId; + this.currentStatus = currentStatus; + } +} diff --git a/packages/services/src/interfaces/tool-service.ts b/packages/services/src/interfaces/tool-service.ts new file mode 100644 index 000000000..829a0418b --- /dev/null +++ b/packages/services/src/interfaces/tool-service.ts @@ -0,0 +1,34 @@ +/** + * `IToolService` — daemon-facing read-only tool surface (Chain 7 / P1.7, W9.1). + * + * Wraps `IHarnessBridge.rpc.getTools` and translates agent-core's `ToolInfo` + * (camelCase, includes `'user'` source literal) into SCHEMAS §8 `ToolDescriptor` + * (snake_case, `'skill'` literal). The adapter lives at + * `packages/services/src/adapter/tool-adapter.ts`. + * + * **CoreAPI surface used**: + * - `bridge.rpc.getTools({}) => readonly ToolInfo[]` (packages/agent-core/src/rpc/core-api.ts:333). + * + * **REST.md §3.8 ?session_id behavior**: when caller passes a session_id the + * route currently returns the same global list — agent-core's `getTools` + * doesn't differentiate per-session, and `setActiveTools` is the only + * per-session knob (W7+ wires that). Documented gap in `ToolServiceImpl`. + * + * **Anti-corruption**: imports `@moonshot-ai/agent-core` only for the + * `createDecorator` value used to mint the service identifier. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { ToolDescriptor } from '@moonshot-ai/protocol'; + +export interface IToolService { + /** + * Return the available tool descriptors. When `sessionId` is supplied, the + * impl may return a session-effective subset; today it returns the global + * list (CoreAPI gap documented in the impl). + */ + list(sessionId?: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IToolService = createDecorator('IToolService'); diff --git a/packages/services/src/module.ts b/packages/services/src/module.ts new file mode 100644 index 000000000..931d6b958 --- /dev/null +++ b/packages/services/src/module.ts @@ -0,0 +1,86 @@ +/** + * `defaultServicesModule()` — DI entries shipped by `@moonshot-ai/services`. + * W3 ships ONLY the `HarnessBridge` registration; positive `IXxxService` + * entries (ISessionService etc.) get appended per-Chain in Phase 1. + * + * Callers spread the array into a `ServiceCollection` ctor: + * + * const entries = defaultServicesModule(); + * const collection = new ServiceCollection( + * ...entries.map(([id, descriptor]) => [id, descriptor] as const), + * // ...broker impls... + * ); + * + * Each entry is `[ServiceIdentifier, SyncDescriptor, InstantiationType]` — + * the `InstantiationType` is informational today (W2 treats Delayed as Eager; + * see instantiationService.ts:158 TODO). When delayed-instantiation lands the + * wiring layer can route entries accordingly without touching this module. + * + * Canonical wiring strategy: `defaultServicesModule()` returned to the + * daemon's bootstrap, which builds the `ServiceCollection` once. We do NOT + * use the global `registerSingleton` registry as the canonical path — the + * registry exists for legacy "side-effect on import" wiring and is exposed + * only via `./bridge/lifecycle.ts`'s `registerHarnessBridge` helper (NOT + * re-exported from the package barrel; W3 STATUS.md documents this). + */ + +import { + InstantiationType, + SyncDescriptor, + type ServiceIdentifier, +} from '@moonshot-ai/agent-core'; + +import { HarnessBridge, IHarnessBridge } from './bridge/harness-bridge'; +import { McpServiceImpl } from './impls/mcp-service-impl'; +import { MessageServiceImpl } from './impls/message-service-impl'; +import { PromptServiceImpl } from './impls/prompt-service-impl'; +import { SessionServiceImpl } from './impls/session-service-impl'; +import { TaskServiceImpl } from './impls/task-service-impl'; +import { ToolServiceImpl } from './impls/tool-service-impl'; +import { IMcpService } from './interfaces/mcp-service'; +import { IMessageService } from './interfaces/message-service'; +import { IPromptService } from './interfaces/prompt-service'; +import { ISessionService } from './interfaces/session-service'; +import { ITaskService } from './interfaces/task-service'; +import { IToolService } from './interfaces/tool-service'; + +export type ServiceModuleEntry = readonly [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ServiceIdentifier, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + SyncDescriptor, + InstantiationType, +]; + +export function defaultServicesModule(): ReadonlyArray { + return [ + [IHarnessBridge, new SyncDescriptor(HarnessBridge), InstantiationType.Eager], + // W6.2 / Chain 2 — `ISessionService`. The descriptor lacks staticArguments + // (SessionServiceImpl ctor needs IHarnessBridge). W2 has no ctor-arg DI, + // so this descriptor is informational; the daemon's `start.ts` wires the + // instance via `ix.createInstance(SessionServiceImpl, a.get(IHarnessBridge))` + // then `services.set(ISessionService, instance)`. The descriptor entry + // documents that ISessionService is part of the canonical service set. + [ISessionService, new SyncDescriptor(SessionServiceImpl), InstantiationType.Eager], + // W7.1 / Chain 3 — `IMessageService`. Same wiring story as `ISessionService`: + // `MessageServiceImpl` ctor takes `IHarnessBridge`; W2 has no ctor-arg DI so + // the daemon's `start.ts` calls `ix.createInstance(MessageServiceImpl, a.get(IHarnessBridge))` + // and `services.set(IMessageService, instance)`. The descriptor entry is the + // canonical declaration of the service set. + [IMessageService, new SyncDescriptor(MessageServiceImpl), InstantiationType.Eager], + // W7.2 / Chain 4 — `IPromptService`. Ctor takes `IHarnessBridge` + `IEventBus` + // (it self-registers as a lifecycle observer on the bus so it can synthesize + // `prompt.completed` / `prompt.aborted` from `turn.ended`). Same descriptor + // shape as the others — daemon does manual wiring in start.ts. + [IPromptService, new SyncDescriptor(PromptServiceImpl), InstantiationType.Eager], + // W9.1 / Chain 7 — `IToolService` + `IMcpService`. Both depend only on + // `IHarnessBridge`; daemon's `start.ts` wires them after `IPromptService` + // so reverse-dispose closes them before the bridge. + [IToolService, new SyncDescriptor(ToolServiceImpl), InstantiationType.Eager], + [IMcpService, new SyncDescriptor(McpServiceImpl), InstantiationType.Eager], + // W9.2 / Chain 8 — `ITaskService`. Same ctor-arg-via-`createInstance` + // wiring as IToolService/IMcpService; appended last so reverse-dispose + // closes it first among the new services. + [ITaskService, new SyncDescriptor(TaskServiceImpl), InstantiationType.Eager], + ] as const; +} diff --git a/packages/services/test/approval-adapter.test.ts b/packages/services/test/approval-adapter.test.ts new file mode 100644 index 000000000..624f928ff --- /dev/null +++ b/packages/services/test/approval-adapter.test.ts @@ -0,0 +1,102 @@ +/** + * Approval adapter unit tests (W8.1 / Chain 5). + */ + +import { describe, expect, it } from 'vitest'; + +import type { ApprovalRequest as InProcessApprovalRequest } from '@moonshot-ai/agent-core'; + +import { + approvalToAgentCoreResponse as toAgentCoreResponse, + approvalToBrokerRequest as toBrokerRequest, +} from '../src'; + +describe('approval-adapter · toBrokerRequest (in-process → protocol)', () => { + const inProc: InProcessApprovalRequest = { + turnId: 7, + toolCallId: 'tc_abc', + toolName: 'shell.run', + action: 'Run `rm -rf foo/`', + display: { kind: 'command', command: 'rm -rf foo/', summary: 'rm' } as never, + }; + + it('maps camelCase → snake_case', () => { + const protoReq = toBrokerRequest(inProc, { + approvalId: '01J_APPROVAL', + sessionId: 'sess_x', + createdAt: '2026-06-04T10:30:00.000Z', + expiresAt: '2026-06-04T10:31:00.000Z', + }); + + expect(protoReq).toEqual({ + approval_id: '01J_APPROVAL', + session_id: 'sess_x', + turn_id: 7, + tool_call_id: 'tc_abc', + tool_name: 'shell.run', + action: 'Run `rm -rf foo/`', + tool_input_display: { kind: 'command', command: 'rm -rf foo/', summary: 'rm' }, + created_at: '2026-06-04T10:30:00.000Z', + expires_at: '2026-06-04T10:31:00.000Z', + }); + }); + + it('preserves tool_input_display verbatim (12-arm passthrough)', () => { + const exotic = { kind: 'plan_review', plan: '...', options: [{ label: 'ok' }] } as never; + const protoReq = toBrokerRequest( + { ...inProc, display: exotic }, + { + approvalId: 'a', + sessionId: 's', + createdAt: '2026-06-04T10:30:00.000Z', + expiresAt: '2026-06-04T10:31:00.000Z', + }, + ); + expect(protoReq.tool_input_display).toBe(exotic); + }); + + it('omits turn_id when undefined', () => { + const noTurn = { ...inProc }; + delete (noTurn as { turnId?: number }).turnId; + const protoReq = toBrokerRequest(noTurn, { + approvalId: 'a', + sessionId: 's', + createdAt: '2026-06-04T10:30:00.000Z', + expiresAt: '2026-06-04T10:31:00.000Z', + }); + expect(protoReq.turn_id).toBeUndefined(); + }); +}); + +describe('approval-adapter · toAgentCoreResponse (protocol → in-process)', () => { + it('maps snake_case selected_label → camelCase selectedLabel', () => { + const inProcResp = toAgentCoreResponse({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + selected_label: 'Run command', + }); + expect(inProcResp).toEqual({ + decision: 'approved', + scope: 'session', + feedback: 'looks good', + selectedLabel: 'Run command', + }); + }); + + it('omits optional fields when absent', () => { + const inProcResp = toAgentCoreResponse({ decision: 'rejected' }); + expect(inProcResp).toEqual({ + decision: 'rejected', + scope: undefined, + feedback: undefined, + selectedLabel: undefined, + }); + }); + + it('round-trips a cancelled decision', () => { + const inProcResp = toAgentCoreResponse({ decision: 'cancelled', feedback: 'user closed' }); + expect(inProcResp.decision).toBe('cancelled'); + expect(inProcResp.feedback).toBe('user closed'); + }); +}); diff --git a/packages/services/test/bridge.test.ts b/packages/services/test/bridge.test.ts new file mode 100644 index 000000000..282985e63 --- /dev/null +++ b/packages/services/test/bridge.test.ts @@ -0,0 +1,253 @@ +/** + * W3.2 acceptance: HarnessBridge wires brokers + KimiCore + RPC pair; ready() + * settles; dispose() short-circuits RPC; defaultServicesModule() composes with + * the DI container. + * + * Hermetic strategy: KimiCore wants a real HOME dir / config / Git Bash. We + * point it at an isolated tmp dir per test so it doesn't touch the user's + * `~/.kimi`. The bridge's RPC smoke uses a single round-trip + * (`getCoreInfo`) that doesn't require any external state — exercises the + * full RPC plumbing (core ← createRPC → bridgeClientAPI binding) without + * touching session/plugin/MCP code paths. createSession() smoke is harder + * to make hermetic because it spins up Kaos, hooks, and plugin discovery — + * we leave that to W4+ when daemon-side mocks land. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + InstantiationService, + ServiceCollection, + SyncDescriptor, + type ApprovalRequest, + type ApprovalResponse, + type Event, + type QuestionRequest, + type QuestionResult, +} from '@moonshot-ai/agent-core'; + +import { + BridgeClientAPI, + HarnessBridge, + IApprovalBroker, + IEventBus, + IHarnessBridge, + IQuestionBroker, + defaultServicesModule, +} from '../src'; + +// --- Mock broker impls (per-test fresh instances) ---------------------------- + +class RecordingEventBus implements IEventBus { + readonly events: Event[] = []; + publish(event: Event): void { + this.events.push(event); + } +} + +class RecordingApprovalBroker implements IApprovalBroker { + readonly received: ApprovalRequest[] = []; + readonly resolveCalls: Array<{ id: string; response: ApprovalResponse }> = []; + async request( + req: ApprovalRequest & { sessionId: string; agentId: string }, + ): Promise { + this.received.push(req); + return { decision: 'approved' }; + } + resolve(id: string, response: ApprovalResponse): void { + this.resolveCalls.push({ id, response }); + } +} + +class RecordingQuestionBroker implements IQuestionBroker { + readonly received: QuestionRequest[] = []; + readonly resolveCalls: Array<{ id: string; response: QuestionResult }> = []; + readonly dismissCalls: string[] = []; + async request( + req: QuestionRequest & { sessionId: string; agentId: string }, + ): Promise { + this.received.push(req); + return null; + } + resolve(id: string, response: QuestionResult): void { + this.resolveCalls.push({ id, response }); + } + dismiss(id: string): void { + this.dismissCalls.push(id); + } +} + +// --- Sandbox HOME setup ------------------------------------------------------ + +let tmpHome: string; +let prevHome: string | undefined; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'kimi-services-test-')); + prevHome = process.env['KIMI_HOME']; + process.env['KIMI_HOME'] = tmpHome; +}); + +afterEach(() => { + if (prevHome === undefined) { + delete process.env['KIMI_HOME']; + } else { + process.env['KIMI_HOME'] = prevHome; + } + try { + rmSync(tmpHome, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; tmp dirs are auto-pruned. + } +}); + +function makeBrokers() { + return { + eventBus: new RecordingEventBus(), + approvalBroker: new RecordingApprovalBroker(), + questionBroker: new RecordingQuestionBroker(), + }; +} + +// --- Tests ------------------------------------------------------------------- + +describe('BridgeClientAPI (W3.2)', () => { + it('routes emitEvent / requestApproval / requestQuestion / toolCall to brokers', async () => { + const { eventBus, approvalBroker, questionBroker } = makeBrokers(); + const api = new BridgeClientAPI({ eventBus, approvalBroker, questionBroker }); + + const ev: Event = { + type: 'agent_status_updated', + sessionId: 'sess-1', + agentId: 'main', + status: { state: 'idle' }, + } as unknown as Event; + api.emitEvent(ev); + expect(eventBus.events).toEqual([ev]); + + const approvalReq = { + toolCallId: 'tc-1', + toolName: 'shell.run', + action: 'execute', + display: { kind: 'generic', summary: 'do thing' } as ApprovalRequest['display'], + sessionId: 'sess-1', + agentId: 'main', + }; + const approvalResp = await api.requestApproval(approvalReq); + expect(approvalResp).toEqual({ decision: 'approved' }); + expect(approvalBroker.received).toHaveLength(1); + + const questionReq = { + questions: [{ question: '?', options: [{ label: 'A' }] }], + sessionId: 'sess-1', + agentId: 'main', + }; + const questionResp = await api.requestQuestion(questionReq); + expect(questionResp).toBeNull(); + expect(questionBroker.received).toHaveLength(1); + + const toolResp = await api.toolCall({ + toolCallId: 'tc-2', + args: {}, + sessionId: 'sess-1', + agentId: 'main', + }); + expect(toolResp.isError).toBe(true); + expect(toolResp.output).toMatch(/SDK custom tool calls are not supported/); + }); +}); + +describe('HarnessBridge direct construction (W3.2)', () => { + it('constructs, exposes a callable rpc proxy, and ready() resolves', async () => { + const { eventBus, approvalBroker, questionBroker } = makeBrokers(); + const bridge = new HarnessBridge({ homeDir: tmpHome }, eventBus, approvalBroker, questionBroker); + try { + // ready() resolves once the SDK side of the RPC pair has bound. + await expect(bridge.ready()).resolves.toBeUndefined(); + expect(typeof bridge.rpc.getCoreInfo).toBe('function'); + } finally { + bridge.dispose(); + } + }); + + it('rpc round-trip through createRPC reaches KimiCore (getCoreInfo smoke)', async () => { + const { eventBus, approvalBroker, questionBroker } = makeBrokers(); + const bridge = new HarnessBridge({ homeDir: tmpHome }, eventBus, approvalBroker, questionBroker); + try { + await bridge.ready(); + // getCoreInfo is a pure read on KimiCore (no session/plugin state). It + // round-trips through the full createRPC pair (serialize → core → + // serialize back) — that's the bridge smoke we care about. + const info = await bridge.rpc.getCoreInfo({}); + expect(info).toHaveProperty('version'); + expect(typeof info.version).toBe('string'); + } finally { + bridge.dispose(); + } + }); + + it('dispose is idempotent and short-circuits subsequent rpc calls', async () => { + const { eventBus, approvalBroker, questionBroker } = makeBrokers(); + const bridge = new HarnessBridge({ homeDir: tmpHome }, eventBus, approvalBroker, questionBroker); + await bridge.ready(); + bridge.dispose(); + bridge.dispose(); // second call must be a no-op + + await expect(bridge.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); + }); +}); + +describe('defaultServicesModule() composition (W3.2)', () => { + it('returns a HarnessBridge descriptor that composes with the DI container', async () => { + const { eventBus, approvalBroker, questionBroker } = makeBrokers(); + const moduleEntries = defaultServicesModule(); + // W6.2 added ISessionService; the array grows as Chains land. We assert + // IHarnessBridge is the FIRST entry (its position matters because the + // daemon's start.ts uses createInstance + services.set on top of the + // descriptor — the order documents the canonical construction sequence). + expect(moduleEntries.length).toBeGreaterThanOrEqual(1); + expect(moduleEntries[0]![0]).toBe(IHarnessBridge); + expect(moduleEntries[0]![1]).toBeInstanceOf(SyncDescriptor); + + const services = new ServiceCollection( + [IEventBus, eventBus], + [IApprovalBroker, approvalBroker], + [IQuestionBroker, questionBroker], + // Spread module entries — the W2 ServiceCollection ctor accepts + // `ReadonlyArray`. We use the descriptor as the + // "value" so the container constructs it lazily; HarnessBridge ctor's + // first three args (eventBus/approvalBroker/questionBroker) come from + // the static-arguments slot only when SyncDescriptor passes them, but + // for the direct-construction case below we use createInstance. + ...moduleEntries.map(([id, desc]) => [id, desc] as const), + ); + const ix = new InstantiationService(services); + + try { + // createInstance with explicit ctor-arg passthrough (W2 has no ctor-arg + // DI injection yet; see W2 README + handoff notes). We pull the brokers + // out of the accessor and hand them to the ctor literally. + const bridge = ix.invokeFunction((a) => { + return ix.createInstance( + HarnessBridge, + a.get(IEventBus), + a.get(IApprovalBroker), + a.get(IQuestionBroker), + { homeDir: tmpHome }, + ); + }); + try { + await bridge.ready(); + expect(typeof bridge.rpc.getCoreInfo).toBe('function'); + } finally { + bridge.dispose(); + } + } finally { + ix.dispose(); + } + }); +}); diff --git a/packages/services/test/interfaces.test.ts b/packages/services/test/interfaces.test.ts new file mode 100644 index 000000000..c46d2bd2f --- /dev/null +++ b/packages/services/test/interfaces.test.ts @@ -0,0 +1,210 @@ +/** + * W3.1 acceptance: the three broker decorators are typed correctly, can be + * registered in a `ServiceCollection`, resolved through `InstantiationService`, + * and surface their diagnostic names in not-registered errors. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + InstantiationService, + ServiceCollection, +} from '@moonshot-ai/agent-core'; +import type { ApprovalRequest, Event, QuestionRequest } from '@moonshot-ai/agent-core'; + +import { + IApprovalBroker, + IEventBus, + IQuestionBroker, + type ApprovalResponse, + type QuestionResult, +} from '../src'; + +class FakeEventBus implements IEventBus { + readonly events: Event[] = []; + publish(event: Event): void { + this.events.push(event); + } +} + +class FakeApprovalBroker implements IApprovalBroker { + readonly received: ApprovalRequest[] = []; + resolveCalls: Array<{ id: string; response: ApprovalResponse }> = []; + async request( + req: ApprovalRequest & { sessionId: string; agentId: string }, + ): Promise { + this.received.push(req); + return { decision: 'approved' }; + } + resolve(id: string, response: ApprovalResponse): void { + this.resolveCalls.push({ id, response }); + } +} + +class FakeQuestionBroker implements IQuestionBroker { + readonly received: QuestionRequest[] = []; + resolveCalls: Array<{ id: string; response: QuestionResult }> = []; + dismissCalls: string[] = []; + async request( + req: QuestionRequest & { sessionId: string; agentId: string }, + ): Promise { + this.received.push(req); + return null; + } + resolve(id: string, response: QuestionResult): void { + this.resolveCalls.push({ id, response }); + } + dismiss(id: string): void { + this.dismissCalls.push(id); + } +} + +function makeFakeEvent(): Event { + // Minimal AgentStatusUpdatedEvent shape — the union narrows by `type`. + return { + type: 'agent_status_updated', + sessionId: 'sess-1', + agentId: 'main', + status: { state: 'idle' }, + } as unknown as Event; +} + +function makeFakeApproval(): ApprovalRequest & { sessionId: string; agentId: string } { + return { + toolCallId: 'tc-1', + toolName: 'shell.run', + action: 'execute', + display: { kind: 'generic', summary: 'do thing' } as ApprovalRequest['display'], + sessionId: 'sess-1', + agentId: 'main', + }; +} + +function makeFakeQuestion(): QuestionRequest & { sessionId: string; agentId: string } { + return { + questions: [ + { + question: 'Which?', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + sessionId: 'sess-1', + agentId: 'main', + }; +} + +describe('@moonshot-ai/services · interfaces (W3.1)', () => { + it('registers all three brokers in a ServiceCollection and resolves them through InstantiationService', () => { + const bus = new FakeEventBus(); + const approvals = new FakeApprovalBroker(); + const questions = new FakeQuestionBroker(); + + const services = new ServiceCollection( + [IEventBus, bus], + [IApprovalBroker, approvals], + [IQuestionBroker, questions], + ); + const ix = new InstantiationService(services); + + try { + ix.invokeFunction((accessor) => { + expect(accessor.get(IEventBus)).toBe(bus); + expect(accessor.get(IApprovalBroker)).toBe(approvals); + expect(accessor.get(IQuestionBroker)).toBe(questions); + }); + } finally { + ix.dispose(); + } + }); + + it('end-to-end smoke: invokes broker methods via the accessor', async () => { + const bus = new FakeEventBus(); + const approvals = new FakeApprovalBroker(); + const questions = new FakeQuestionBroker(); + + const services = new ServiceCollection( + [IEventBus, bus], + [IApprovalBroker, approvals], + [IQuestionBroker, questions], + ); + const ix = new InstantiationService(services); + + try { + const event = makeFakeEvent(); + ix.invokeFunction((a) => a.get(IEventBus).publish(event)); + expect(bus.events).toEqual([event]); + + const approval = makeFakeApproval(); + const approvalResp = await ix.invokeFunction((a) => + a.get(IApprovalBroker).request(approval), + ); + expect(approvalResp).toEqual({ decision: 'approved' }); + expect(approvals.received).toHaveLength(1); + + const question = makeFakeQuestion(); + const questionResp = await ix.invokeFunction((a) => + a.get(IQuestionBroker).request(question), + ); + expect(questionResp).toBeNull(); + expect(questions.received).toHaveLength(1); + } finally { + ix.dispose(); + } + }); + + it('resolve/dismiss broker methods are wired through the same DI value', () => { + const approvals = new FakeApprovalBroker(); + const questions = new FakeQuestionBroker(); + + const services = new ServiceCollection( + [IApprovalBroker, approvals], + [IQuestionBroker, questions], + ); + const ix = new InstantiationService(services); + + try { + ix.invokeFunction((a) => { + a.get(IApprovalBroker).resolve('tc-1', { decision: 'rejected', feedback: 'no' }); + a.get(IQuestionBroker).resolve('q-1', { answers: { q_1: 'A' } }); + a.get(IQuestionBroker).dismiss('q-2'); + }); + + expect(approvals.resolveCalls).toEqual([ + { id: 'tc-1', response: { decision: 'rejected', feedback: 'no' } }, + ]); + expect(questions.resolveCalls).toEqual([ + { id: 'q-1', response: { answers: { q_1: 'A' } } }, + ]); + expect(questions.dismissCalls).toEqual(['q-2']); + } finally { + ix.dispose(); + } + }); + + it('looking up an unregistered broker throws with the decorator diagnostic name', () => { + const ix = new InstantiationService(new ServiceCollection()); + try { + expect(() => ix.invokeFunction((a) => a.get(IEventBus))).toThrow(/IEventBus/); + expect(() => ix.invokeFunction((a) => a.get(IApprovalBroker))).toThrow(/IApprovalBroker/); + expect(() => ix.invokeFunction((a) => a.get(IQuestionBroker))).toThrow(/IQuestionBroker/); + } finally { + ix.dispose(); + } + }); + + it('IEventBus / IApprovalBroker / IQuestionBroker are callable ServiceIdentifiers (compile-time guard)', () => { + // The const half of the dual export must be usable as a ServiceCollection key + // and as a `createDecorator` brand value. We exercise both at runtime to + // also catch any accidental swap of the value with the type. + expect(typeof IEventBus).toBe('function'); + expect(typeof IApprovalBroker).toBe('function'); + expect(typeof IQuestionBroker).toBe('function'); + + // Avoid an unused-import warning on the type-only re-export. + const _typeProbe: ApprovalResponse | QuestionResult = null; + void _typeProbe; + // And use vi to keep the import surface (helpful when running with strict + // unused-imports lints in the future). + vi.fn(); + }); +}); diff --git a/packages/services/test/message-service.test.ts b/packages/services/test/message-service.test.ts new file mode 100644 index 000000000..3476ddd69 --- /dev/null +++ b/packages/services/test/message-service.test.ts @@ -0,0 +1,327 @@ +/** + * `MessageServiceImpl` (Chain 3 / P1.3, W7.1) unit tests. + * + * Hermetic: a fake `IHarnessBridge` returns canned `SessionSummary[]` from + * `listSessions` and a canned `AgentContextData.history` from `getContext`. + * + * Coverage: + * - list pagination (default/before_id/after_id/page_size; has_more) + * - role filter + * - kosong ContentPart → SCHEMAS MessageContent adapter (text / think / + * image_url / audio_url / video_url) + * - assistant message with toolCalls → tool_use content parts appended + * - tool role message → tool_result single content part with output text + * - tool message with isError=true → tool_result.is_error: true + * - get(sid, mid) round-trip + MessageNotFoundError on invalid id + * - SessionNotFoundError on unknown sid + * - deriveMessageId / parseMessageId round-trip + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + AgentContextData, + ContextMessage, + SessionSummary, +} from '@moonshot-ai/agent-core'; + +import { + type IHarnessBridge, + type HarnessRPC, + MessageNotFoundError, + MessageServiceImpl, + SessionNotFoundError, + deriveMessageId, + parseMessageId, + toProtocolMessage, +} from '../src'; + +const SESSION_ID = 'sess_01HZTEST'; +const SESSION_CREATED_AT = 1_700_000_000_000; + +function makeFakeBridge( + sessions: SessionSummary[], + history: ContextMessage[], +): IHarnessBridge { + const rpc: Partial = { + listSessions: vi.fn().mockImplementation(async () => sessions), + getContext: vi.fn().mockImplementation(async (): Promise => { + return { history, tokenCount: 0 }; + }), + }; + return { + rpc: rpc as HarnessRPC, + ready: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + }; +} + +function mkSummary(id = SESSION_ID): SessionSummary { + return { + id, + workDir: '/tmp/ws', + sessionDir: `/tmp/sessions/${id}`, + createdAt: SESSION_CREATED_AT, + updatedAt: SESSION_CREATED_AT, + }; +} + +function mkUserMessage(text: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + } as ContextMessage; +} + +function mkAssistantMessage(text: string, toolCalls: ContextMessage['toolCalls'] = []): ContextMessage { + return { + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls, + } as ContextMessage; +} + +describe('deriveMessageId / parseMessageId', () => { + it('round-trips a derived id', () => { + const id = deriveMessageId('sess_01HABC', 3); + expect(id).toBe('msg_sess_01HABC_000003'); + expect(parseMessageId(id)).toEqual({ sessionId: 'sess_01HABC', index: 3 }); + }); + + it('parses preserves the full session id including underscores', () => { + const id = deriveMessageId('sess_with_under_score', 12); + expect(parseMessageId(id)).toEqual({ + sessionId: 'sess_with_under_score', + index: 12, + }); + }); + + it('returns undefined for malformed ids', () => { + expect(parseMessageId('not_a_message_id')).toBeUndefined(); + expect(parseMessageId('msg_no_index_here_')).toBeUndefined(); + expect(parseMessageId('msg_sess_-1')).toBeUndefined(); + }); +}); + +describe('toProtocolMessage content adapter', () => { + it('maps text content', () => { + const m = mkUserMessage('hello'); + const out = toProtocolMessage(SESSION_ID, 0, m, SESSION_CREATED_AT); + expect(out.content).toEqual([{ type: 'text', text: 'hello' }]); + expect(out.created_at).toBe(new Date(SESSION_CREATED_AT).toISOString()); + }); + + it('maps think → thinking with optional signature', () => { + const m: ContextMessage = { + role: 'assistant', + content: [{ type: 'think', think: 'I am thinking', encrypted: 'sig' }], + toolCalls: [], + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 1, m, SESSION_CREATED_AT); + expect(out.content[0]).toEqual({ + type: 'thinking', + thinking: 'I am thinking', + signature: 'sig', + }); + }); + + it('maps image_url → image source kind=url', () => { + const m: ContextMessage = { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url: 'https://a.png' } }], + toolCalls: [], + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 0, m, SESSION_CREATED_AT); + expect(out.content[0]).toEqual({ + type: 'image', + source: { kind: 'url', url: 'https://a.png' }, + }); + }); + + it('flattens audio_url and video_url to text markers', () => { + const m: ContextMessage = { + role: 'user', + content: [ + { type: 'audio_url', audioUrl: { url: 'https://a.mp3' } }, + { type: 'video_url', videoUrl: { url: 'https://a.mp4' } }, + ], + toolCalls: [], + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 0, m, SESSION_CREATED_AT); + expect(out.content).toEqual([ + { type: 'text', text: '[audio:https://a.mp3]' }, + { type: 'text', text: '[video:https://a.mp4]' }, + ]); + }); + + it('appends tool_use content parts for assistant toolCalls', () => { + const m: ContextMessage = { + role: 'assistant', + content: [{ type: 'text', text: 'using tool' }], + toolCalls: [ + { + type: 'function', + id: 'call_1', + name: 'Bash', + arguments: '{"command":"ls"}', + }, + ], + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 2, m, SESSION_CREATED_AT); + expect(out.content).toHaveLength(2); + expect(out.content[1]).toEqual({ + type: 'tool_use', + tool_call_id: 'call_1', + tool_name: 'Bash', + input: { command: 'ls' }, + }); + }); + + it('treats tool-role messages as a single tool_result content part', () => { + const m: ContextMessage = { + role: 'tool', + content: [{ type: 'text', text: 'output' }], + toolCalls: [], + toolCallId: 'call_1', + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 3, m, SESSION_CREATED_AT); + expect(out.role).toBe('tool'); + expect(out.content).toEqual([ + { type: 'tool_result', tool_call_id: 'call_1', output: 'output' }, + ]); + }); + + it('marks isError=true tool messages with is_error: true', () => { + const m: ContextMessage = { + role: 'tool', + content: [{ type: 'text', text: 'fail' }], + toolCalls: [], + toolCallId: 'call_1', + isError: true, + } as ContextMessage; + const out = toProtocolMessage(SESSION_ID, 4, m, SESSION_CREATED_AT); + expect(out.content[0]).toMatchObject({ + type: 'tool_result', + tool_call_id: 'call_1', + is_error: true, + }); + }); +}); + +describe('MessageServiceImpl', () => { + let impl: MessageServiceImpl; + let bridge: IHarnessBridge; + + beforeEach(() => { + bridge = makeFakeBridge( + [mkSummary()], + [ + mkUserMessage('one'), + mkAssistantMessage('two'), + mkUserMessage('three'), + mkAssistantMessage('four'), + mkUserMessage('five'), + ], + ); + impl = new MessageServiceImpl(bridge); + }); + + afterEach(() => { + impl.dispose(); + }); + + it('list defaults: returns history in desc order (newest first)', async () => { + const page = await impl.list(SESSION_ID, {}); + expect(page.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([ + 'five', + 'four', + 'three', + 'two', + 'one', + ]); + expect(page.has_more).toBe(false); + }); + + it('list page_size = 2 returns first 2 newest + has_more=true', async () => { + const page = await impl.list(SESSION_ID, { page_size: 2 }); + expect(page.items).toHaveLength(2); + expect(page.has_more).toBe(true); + }); + + it('list before_id returns OLDER entries', async () => { + // before_id = third-newest entry, which is "three" (index 2 in history) + const id = deriveMessageId(SESSION_ID, 2); + const page = await impl.list(SESSION_ID, { before_id: id, page_size: 10 }); + expect(page.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([ + 'two', + 'one', + ]); + expect(page.has_more).toBe(false); + }); + + it('list after_id returns NEWER entries', async () => { + // after_id = third-newest entry "three" + const id = deriveMessageId(SESSION_ID, 2); + const page = await impl.list(SESSION_ID, { after_id: id, page_size: 10 }); + expect(page.items.map((m) => (m.content[0] as { text: string }).text)).toEqual([ + 'five', + 'four', + ]); + expect(page.has_more).toBe(false); + }); + + it('list filters by role AFTER pagination', async () => { + const page = await impl.list(SESSION_ID, { role: 'user' }); + expect(page.items.every((m) => m.role === 'user')).toBe(true); + }); + + it('list throws SessionNotFoundError for unknown sid', async () => { + await expect(impl.list('sess_missing', {})).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + + it('get returns the right message', async () => { + // index 0 = "one", id of which is deriveMessageId(SESSION_ID, 0) + const id = deriveMessageId(SESSION_ID, 0); + const m = await impl.get(SESSION_ID, id); + expect(m.id).toBe(id); + expect((m.content[0] as { text: string }).text).toBe('one'); + }); + + it('get throws MessageNotFoundError for an id that points to a missing index', async () => { + const fake = deriveMessageId(SESSION_ID, 999); + await expect(impl.get(SESSION_ID, fake)).rejects.toBeInstanceOf( + MessageNotFoundError, + ); + }); + + it('get throws MessageNotFoundError for a malformed id', async () => { + await expect(impl.get(SESSION_ID, 'msg_garbage')).rejects.toBeInstanceOf( + MessageNotFoundError, + ); + }); + + it('get throws MessageNotFoundError when mid points at a DIFFERENT session', async () => { + const otherSessionId = deriveMessageId('sess_other', 0); + await expect(impl.get(SESSION_ID, otherSessionId)).rejects.toBeInstanceOf( + MessageNotFoundError, + ); + }); + + it('get throws SessionNotFoundError for unknown sid', async () => { + const id = deriveMessageId('sess_unknown', 0); + await expect(impl.get('sess_unknown', id)).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + + it('page_size 0 falls back to safety minimum 1', async () => { + // The route layer is supposed to reject page_size=0 via 40001; if it + // somehow reaches the impl (e.g. internal call) we clamp to 1 rather + // than divide-by-zero or return nothing for an empty page. + const page = await impl.list(SESSION_ID, { page_size: 0 }); + expect(page.items).toHaveLength(1); + }); +}); diff --git a/packages/services/test/prompt-service.test.ts b/packages/services/test/prompt-service.test.ts new file mode 100644 index 000000000..54eacc967 --- /dev/null +++ b/packages/services/test/prompt-service.test.ts @@ -0,0 +1,358 @@ +/** + * `PromptServiceImpl` (Chain 4 / P1.4, W7.2) unit tests. + * + * Hermetic: a fake `IHarnessBridge` returns canned session list + records + * the `prompt` / `cancel` payloads. A stub `IEventBus` collects published + * events into an array we can inspect. + * + * Coverage: + * - submit(sid, body) returns {prompt_id, user_message_id} + * - submit registers an active prompt → busy detection on second submit + * - submit translates protocol content → kosong content (text + image_url) + * - submit on unknown sid → SessionNotFoundError + * - submit on a session with an active completed/aborted prompt succeeds + * - observeEvent on `turn.started` captures turnId + * - observeEvent on `turn.ended` (top-level, completed) synthesizes + * prompt.completed + * - observeEvent on `turn.ended` with reason=cancelled synthesizes + * prompt.aborted + * - observeEvent ignores non-top-level (nested) turn.ended events + * - observeEvent on events for an unknown session is a no-op + * - abort() rejects PromptNotFoundError when no active prompt + * - abort() returns {aborted: true} + publishes prompt.aborted + * - second abort() → PromptAlreadyCompletedError (40903) + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { + Event, + SessionSummary, +} from '@moonshot-ai/agent-core'; + +import { + type IEventBus, + type IHarnessBridge, + type HarnessRPC, + PromptAlreadyCompletedError, + PromptNotFoundError, + PromptServiceImpl, + SessionBusyError, + SessionNotFoundError, +} from '../src'; + +const SID = 'sess_01PT'; +const SESSION_CREATED_AT = 1_700_000_000_000; + +function mkSummary(id = SID): SessionSummary { + return { + id, + workDir: '/tmp/ws', + sessionDir: `/tmp/sessions/${id}`, + createdAt: SESSION_CREATED_AT, + updatedAt: SESSION_CREATED_AT, + }; +} + +interface RpcRecord { + promptCalls: unknown[]; + cancelCalls: unknown[]; +} + +function makeBridge( + sessions: SessionSummary[] = [mkSummary()], +): { bridge: IHarnessBridge; record: RpcRecord } { + const record: RpcRecord = { promptCalls: [], cancelCalls: [] }; + const rpc: Partial = { + listSessions: vi.fn().mockImplementation(async () => sessions), + prompt: vi.fn().mockImplementation(async (payload) => { + record.promptCalls.push(payload); + }), + cancel: vi.fn().mockImplementation(async (payload) => { + record.cancelCalls.push(payload); + }), + }; + const bridge: IHarnessBridge = { + rpc: rpc as HarnessRPC, + ready: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + }; + return { bridge, record }; +} + +function makeBus(): { bus: IEventBus; events: Event[] } { + const events: Event[] = []; + const bus: IEventBus = { + publish: (e: Event) => { + events.push(e); + }, + }; + return { bus, events }; +} + +describe('PromptServiceImpl.submit (W7.2)', () => { + it('returns ULID-shaped prompt_id + user_message_id derived from it', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + const result = await impl.submit(SID, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(result.prompt_id).toMatch(/^prompt_[0-9A-HJKMNP-TV-Z]{26}$/); + expect(result.user_message_id).toMatch(/^msg_sess_01PT_pending_prompt_/); + }); + + it('translates text + image content to kosong ContentParts', async () => { + const { bridge, record } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { + content: [ + { type: 'text', text: 'hello' }, + { type: 'image', source: { kind: 'url', url: 'https://a.png' } }, + ], + }); + expect(record.promptCalls).toHaveLength(1); + const payload = record.promptCalls[0] as { + sessionId: string; + agentId: string; + input: Array>; + }; + expect(payload.sessionId).toBe(SID); + expect(payload.agentId).toBe('main'); + expect(payload.input).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'image_url', imageUrl: { url: 'https://a.png' } }, + ]); + }); + + it('throws SessionBusyError when a non-terminal prompt is already active', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { content: [{ type: 'text', text: 'one' }] }); + await expect( + impl.submit(SID, { content: [{ type: 'text', text: 'two' }] }), + ).rejects.toBeInstanceOf(SessionBusyError); + }); + + it('throws SessionNotFoundError on unknown session id', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await expect( + impl.submit('sess_missing', { content: [{ type: 'text', text: 'hi' }] }), + ).rejects.toBeInstanceOf(SessionNotFoundError); + }); + + it('clears active state if bridge.prompt() rejects', async () => { + const sessions = [mkSummary()]; + const promptMock = vi + .fn<(...args: unknown[]) => Promise>() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValue(undefined); + const rpc: Partial = { + listSessions: vi.fn().mockResolvedValue(sessions), + prompt: promptMock, + cancel: vi.fn().mockImplementation(async () => undefined), + }; + const bridge: IHarnessBridge = { + rpc: rpc as HarnessRPC, + ready: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + }; + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await expect( + impl.submit(SID, { content: [{ type: 'text', text: 'x' }] }), + ).rejects.toThrowError(/boom/); + // A second submit must succeed (state was cleared). + await impl.submit(SID, { content: [{ type: 'text', text: 'x' }] }); + }); +}); + +describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { + it('captures turnId on the first turn.started after submit', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); + impl.observeEvent({ + type: 'turn.started', + turnId: 42, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(impl._activeForTest(SID)?.turnId).toBe(42); + }); + + it('ignores subsequent turn.started events (treated as nested turns)', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); + impl.observeEvent({ + type: 'turn.started', + turnId: 42, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + impl.observeEvent({ + type: 'turn.started', + turnId: 99, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(impl._activeForTest(SID)?.turnId).toBe(42); + }); + + it('synthesizes prompt.completed on top-level turn.ended (reason=completed)', async () => { + const { bridge } = makeBridge(); + const { bus, events } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + const submit = await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); + impl.observeEvent({ + type: 'turn.started', + turnId: 7, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + const derived = impl.observeEvent({ + type: 'turn.ended', + turnId: 7, + reason: 'completed', + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(derived).toHaveLength(1); + const synth = derived[0] as unknown as { + type: string; + promptId: string; + reason: string; + }; + expect(synth.type).toBe('prompt.completed'); + expect(synth.promptId).toBe(submit.prompt_id); + expect(synth.reason).toBe('completed'); + // The bus.publish wasn't called from observeEvent itself — the bus is the + // caller and is responsible for republishing the derived events. + expect(events).toHaveLength(0); + // Active state cleared. + expect(impl._activeForTest(SID)).toBeUndefined(); + }); + + it('synthesizes prompt.aborted on top-level turn.ended (reason=cancelled)', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); + impl.observeEvent({ + type: 'turn.started', + turnId: 8, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + const derived = impl.observeEvent({ + type: 'turn.ended', + turnId: 8, + reason: 'cancelled', + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(derived).toHaveLength(1); + expect((derived[0] as unknown as { type: string }).type).toBe('prompt.aborted'); + }); + + it('ignores nested turn.ended (different turnId) so prompt stays active', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); + impl.observeEvent({ + type: 'turn.started', + turnId: 1, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + const derived = impl.observeEvent({ + type: 'turn.ended', + turnId: 99, + reason: 'completed', + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(derived).toEqual([]); + expect(impl._activeForTest(SID)?.completed).toBe(false); + }); + + it('is a no-op for events on a session with no active prompt', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + const derived = impl.observeEvent({ + type: 'turn.ended', + turnId: 1, + reason: 'completed', + sessionId: SID, + agentId: 'main', + } as unknown as Event); + expect(derived).toEqual([]); + }); +}); + +describe('PromptServiceImpl.abort (W7.3)', () => { + it('throws PromptNotFoundError when no active prompt for the session', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + await expect(impl.abort(SID, 'prompt_xyz')).rejects.toBeInstanceOf( + PromptNotFoundError, + ); + }); + + it('returns {aborted: true} and publishes prompt.aborted', async () => { + const { bridge, record } = makeBridge(); + const { bus, events } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + const submit = await impl.submit(SID, { + content: [{ type: 'text', text: 'hi' }], + }); + impl.observeEvent({ + type: 'turn.started', + turnId: 5, + origin: { kind: 'user' }, + sessionId: SID, + agentId: 'main', + } as unknown as Event); + const result = await impl.abort(SID, submit.prompt_id); + expect(result.aborted).toBe(true); + // bridge.rpc.cancel called with the captured turnId. + expect(record.cancelCalls).toHaveLength(1); + expect(record.cancelCalls[0]).toEqual({ + sessionId: SID, + agentId: 'main', + turnId: 5, + }); + // prompt.aborted published. + expect(events).toHaveLength(1); + expect((events[0] as unknown as { type: string }).type).toBe('prompt.aborted'); + }); + + it('throws PromptAlreadyCompletedError on the second abort', async () => { + const { bridge } = makeBridge(); + const { bus } = makeBus(); + const impl = new PromptServiceImpl(bridge, bus); + const submit = await impl.submit(SID, { + content: [{ type: 'text', text: 'hi' }], + }); + await impl.abort(SID, submit.prompt_id); + await expect(impl.abort(SID, submit.prompt_id)).rejects.toBeInstanceOf( + PromptAlreadyCompletedError, + ); + }); +}); diff --git a/packages/services/test/question-adapter.test.ts b/packages/services/test/question-adapter.test.ts new file mode 100644 index 000000000..8ad291d79 --- /dev/null +++ b/packages/services/test/question-adapter.test.ts @@ -0,0 +1,194 @@ +/** + * Question adapter unit tests (W8.2 / Chain 6). + * + * Covers SCHEMAS §6.4 5-kind ↔ Record normalization + * verbatim. + */ + +import { describe, expect, it } from 'vitest'; + +import type { QuestionRequest as InProcessQuestionRequest } from '@moonshot-ai/agent-core'; + +import { + questionDismissedResult as dismissedResult, + questionToAgentCoreResponse as toAgentCoreResponse, + questionToBrokerRequest as toBrokerRequest, +} from '../src'; + +describe('question-adapter · toBrokerRequest (in-process → protocol)', () => { + const inProc: InProcessQuestionRequest = { + turnId: 7, + toolCallId: 'tc_q', + questions: [ + { + question: 'Which animal?', + header: 'Pets', + body: 'pick one', + options: [ + { label: 'Cat' }, + { label: 'Dog' }, + ], + multiSelect: false, + }, + { + question: 'Which colors?', + options: [ + { label: 'Red' }, + { label: 'Green' }, + { label: 'Blue' }, + ], + multiSelect: true, + otherLabel: 'Other', + }, + ], + }; + + it('synthesizes stable q_ + opt__ ids and maps fields', () => { + const protoReq = toBrokerRequest(inProc, { + questionId: '01J_QUESTION', + sessionId: 'sess_x', + createdAt: '2026-06-04T10:30:00.000Z', + expiresAt: '2026-06-04T10:31:00.000Z', + }); + + expect(protoReq.question_id).toBe('01J_QUESTION'); + expect(protoReq.session_id).toBe('sess_x'); + expect(protoReq.turn_id).toBe(7); + expect(protoReq.tool_call_id).toBe('tc_q'); + + expect(protoReq.questions).toHaveLength(2); + expect(protoReq.questions[0]?.id).toBe('q_0'); + expect(protoReq.questions[0]?.options[0]?.id).toBe('opt_0_0'); + expect(protoReq.questions[0]?.options[1]?.id).toBe('opt_0_1'); + expect(protoReq.questions[0]?.header).toBe('Pets'); + expect(protoReq.questions[0]?.body).toBe('pick one'); + expect(protoReq.questions[0]?.multi_select).toBe(false); + + expect(protoReq.questions[1]?.id).toBe('q_1'); + expect(protoReq.questions[1]?.options.map((o) => o.id)).toEqual([ + 'opt_1_0', + 'opt_1_1', + 'opt_1_2', + ]); + expect(protoReq.questions[1]?.multi_select).toBe(true); + expect(protoReq.questions[1]?.allow_other).toBe(true); + expect(protoReq.questions[1]?.other_label).toBe('Other'); + }); + + it('omits turn_id / tool_call_id when SDK does not provide them', () => { + const minimal: InProcessQuestionRequest = { + questions: [ + { + question: '?', + options: [{ label: 'A' }, { label: 'B' }], + }, + ], + }; + const protoReq = toBrokerRequest(minimal, { + questionId: 'q', + sessionId: 's', + createdAt: '2026-06-04T10:30:00.000Z', + expiresAt: '2026-06-04T10:31:00.000Z', + }); + expect(protoReq.turn_id).toBeUndefined(); + expect(protoReq.tool_call_id).toBeUndefined(); + }); +}); + +describe('question-adapter · toAgentCoreResponse · SCHEMAS §6.4 verbatim', () => { + it("'single' → answers[qid] = option_id", () => { + const inProc = toAgentCoreResponse({ + answers: { q_0: { kind: 'single', option_id: 'opt_0_1' } }, + }); + expect(inProc.answers).toEqual({ q_0: 'opt_0_1' }); + }); + + it("'multi' → answers[qid] = option_ids.join(',') (lossy)", () => { + const inProc = toAgentCoreResponse({ + answers: { + q_0: { kind: 'multi', option_ids: ['opt_0_0', 'opt_0_2'] }, + }, + }); + expect(inProc.answers).toEqual({ q_0: 'opt_0_0,opt_0_2' }); + }); + + it("'other' → answers[qid] = text", () => { + const inProc = toAgentCoreResponse({ + answers: { q_0: { kind: 'other', text: 'Hippopotamus' } }, + }); + expect(inProc.answers).toEqual({ q_0: 'Hippopotamus' }); + }); + + it("'multi_with_other' → [...option_ids, other_text].join(',')", () => { + const inProc = toAgentCoreResponse({ + answers: { + q_0: { + kind: 'multi_with_other', + option_ids: ['opt_0_0', 'opt_0_1'], + other_text: 'Custom', + }, + }, + }); + expect(inProc.answers).toEqual({ q_0: 'opt_0_0,opt_0_1,Custom' }); + }); + + it("'skipped' → entry OMITTED entirely from the record", () => { + const inProc = toAgentCoreResponse({ + answers: { + q_0: { kind: 'single', option_id: 'opt_0_0' }, + q_1: { kind: 'skipped' }, + q_2: { kind: 'other', text: 'Custom' }, + }, + }); + expect(inProc.answers).toEqual({ + q_0: 'opt_0_0', + q_2: 'Custom', + }); + expect(Object.keys(inProc.answers)).not.toContain('q_1'); + }); + + it('handles a mixed 4-item response with one skipped (e2e prompt acceptance)', () => { + const inProc = toAgentCoreResponse({ + answers: { + q_0: { kind: 'single', option_id: 'opt_0_0' }, + q_1: { kind: 'multi', option_ids: ['opt_1_0', 'opt_1_1'] }, + q_2: { kind: 'other', text: 'Hippopotamus' }, + q_3: { kind: 'skipped' }, + }, + method: 'click', + }); + expect(inProc.answers).toEqual({ + q_0: 'opt_0_0', + q_1: 'opt_1_0,opt_1_1', + q_2: 'Hippopotamus', + }); + // method 'click' is NOT in agent-core's in-process method union — dropped. + expect((inProc as { method?: string }).method).toBeUndefined(); + }); + + it("keeps agent-core method values like 'enter' / 'space' / 'number_key'", () => { + const inProc = toAgentCoreResponse({ + answers: { q_0: { kind: 'skipped' } }, + method: 'enter', + }); + expect((inProc as { method?: string }).method).toBe('enter'); + }); + + it('produces an empty answers record when ALL questions are skipped (partial-answer marker, NOT dismiss)', () => { + const inProc = toAgentCoreResponse({ + answers: { + q_0: { kind: 'skipped' }, + q_1: { kind: 'skipped' }, + }, + }); + expect(inProc.answers).toEqual({}); + // Distinct from dismissedResult() which returns null. + expect(inProc).not.toBeNull(); + }); +}); + +describe('question-adapter · dismissedResult helper', () => { + it('returns null (== SCHEMAS §6.3 dismiss path)', () => { + expect(dismissedResult()).toBeNull(); + }); +}); diff --git a/packages/services/test/session-service.test.ts b/packages/services/test/session-service.test.ts new file mode 100644 index 000000000..952e6a6ab --- /dev/null +++ b/packages/services/test/session-service.test.ts @@ -0,0 +1,362 @@ +/** + * `SessionServiceImpl` (Chain 2 / P1.2) unit tests. + * + * Hermetic: we mock `IHarnessBridge` with an in-memory `rpc` proxy whose + * methods return controllable promises. No KimiCore, no agent-core RPC pair + * — the adapter is exercised against a fake bridge. + * + * Test cases cover: + * - create → toProtocolSession (camelCase ↔ snake_case + number → ISO) + * - list pagination (default/before_id/after_id/page_size; has_more) + * - get + SessionNotFoundError → 40401 mapping at the daemon layer + * - update (title-only / metadata-only / both / empty) + * - delete returning {deleted: true} + * - toProtocolSession field defaults for fields agent-core doesn't surface + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + CreateSessionPayload, + RenameSessionPayload, + SessionMeta, + SessionSummary, + UpdateSessionMetadataPayload, +} from '@moonshot-ai/agent-core'; +import { emptySessionUsage, type Session } from '@moonshot-ai/protocol'; + +import { + type IHarnessBridge, + type HarnessRPC, + SessionNotFoundError, + SessionServiceImpl, + toProtocolSession, +} from '../src'; + +type WithSessionId = T & { readonly sessionId: string }; + +interface FakeBridgeState { + sessions: SessionSummary[]; + metas: Map; + closedIds: string[]; + renamedTitles: Map; + metadataPatches: Map; +} + +/** + * Build a tiny fake `IHarnessBridge` whose `rpc` proxy implements just the + * five session methods the impl uses. Each method delegates to an in-memory + * state object the test owns. + */ +function makeFakeBridge(state: FakeBridgeState): IHarnessBridge { + const rpc: Partial = { + createSession: vi + .fn() + .mockImplementation(async (payload: CreateSessionPayload): Promise => { + const id = payload.id ?? `sess_${state.sessions.length + 1}`; + const created: SessionSummary = { + id, + workDir: payload.workDir, + sessionDir: `/tmp/sessions/${id}`, + createdAt: 1_000_000 + state.sessions.length * 1_000, + updatedAt: 1_000_000 + state.sessions.length * 1_000, + metadata: payload.metadata, + title: undefined, + }; + state.sessions.push(created); + return created; + }), + listSessions: vi.fn().mockImplementation(async (): Promise => { + return state.sessions; + }), + closeSession: vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + state.closedIds.push(sessionId); + }), + renameSession: vi + .fn() + .mockImplementation(async (payload: WithSessionId) => { + state.renamedTitles.set(payload.sessionId, payload.title); + // Reflect into the metadata map so subsequent `getSessionMetadata` + // returns the updated title (mirrors real KimiCore behavior). + const existing = state.metas.get(payload.sessionId); + if (existing !== undefined) { + state.metas.set(payload.sessionId, { ...existing, title: payload.title }); + } else { + state.metas.set(payload.sessionId, { + title: payload.title, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + isCustomTitle: true, + agents: {}, + custom: {}, + }); + } + }), + updateSessionMetadata: vi + .fn() + .mockImplementation( + async (payload: WithSessionId) => { + state.metadataPatches.set(payload.sessionId, payload.metadata); + }, + ), + getSessionMetadata: vi + .fn() + .mockImplementation(async ({ sessionId }: { sessionId: string }): Promise => { + const found = state.metas.get(sessionId); + if (found === undefined) { + throw new Error(`no metadata for ${sessionId}`); + } + return found; + }), + }; + return { + rpc: rpc as HarnessRPC, + ready: async () => undefined, + dispose: () => undefined, + }; +} + +function freshState(): FakeBridgeState { + return { + sessions: [], + metas: new Map(), + closedIds: [], + renamedTitles: new Map(), + metadataPatches: new Map(), + }; +} + +let state: FakeBridgeState; +let svc: SessionServiceImpl; + +beforeEach(() => { + state = freshState(); + svc = new SessionServiceImpl(makeFakeBridge(state)); +}); + +afterEach(() => { + svc.dispose(); +}); + +describe('toProtocolSession adapter', () => { + it('converts camelCase + number timestamps to snake_case + ISO Z', () => { + const summary: SessionSummary = { + id: 'sess_01', + title: 'Hello', + workDir: '/tmp/wd', + sessionDir: '/tmp/sd', + createdAt: 1_000_000_000_000, + updatedAt: 1_000_000_001_000, + }; + const proto = toProtocolSession(summary); + expect(proto.id).toBe('sess_01'); + expect(proto.title).toBe('Hello'); + expect(proto.metadata.cwd).toBe('/tmp/wd'); + expect(proto.created_at).toBe(new Date(1_000_000_000_000).toISOString()); + expect(proto.updated_at).toBe(new Date(1_000_000_001_000).toISOString()); + expect(proto.created_at.endsWith('Z')).toBe(true); + }); + + it('fills documented defaults when CoreAPI does not surface a field', () => { + const summary: SessionSummary = { + id: 'sess_02', + workDir: '/tmp/wd2', + sessionDir: '/tmp/sd2', + createdAt: 0, + updatedAt: 0, + }; + const proto = toProtocolSession(summary); + expect(proto.status).toBe('idle'); + expect(proto.usage).toEqual(emptySessionUsage()); + expect(proto.permission_rules).toEqual([]); + expect(proto.message_count).toBe(0); + expect(proto.last_seq).toBe(0); + expect(proto.agent_config.model).toBe(''); + expect(proto.title).toBe(''); + }); + + it('enriches title + cwd from SessionMeta when available', () => { + const summary: SessionSummary = { + id: 'sess_03', + workDir: '/tmp/orig', + sessionDir: '/tmp/sd3', + createdAt: 0, + updatedAt: 0, + }; + const meta: SessionMeta = { + title: 'Renamed via meta', + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + isCustomTitle: true, + agents: {}, + custom: { cwd: '/tmp/cwd-from-meta', other_key: 'x' }, + }; + const proto = toProtocolSession(summary, meta); + expect(proto.title).toBe('Renamed via meta'); + expect(proto.metadata.cwd).toBe('/tmp/cwd-from-meta'); + expect(proto.metadata['other_key']).toBe('x'); + }); + + it('strips the internal "goal" metadata key', () => { + const summary: SessionSummary = { + id: 'sess_04', + workDir: '/tmp/wd', + sessionDir: '/tmp/sd', + createdAt: 0, + updatedAt: 0, + }; + const meta: SessionMeta = { + title: 't', + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + isCustomTitle: false, + agents: {}, + custom: { goal: { secret: 'state' }, keep: 'me' }, + }; + const proto = toProtocolSession(summary, meta); + expect(proto.metadata['goal']).toBeUndefined(); + expect(proto.metadata['keep']).toBe('me'); + }); +}); + +describe('SessionServiceImpl.create', () => { + it('calls bridge.rpc.createSession with workDir = metadata.cwd and returns a protocol Session', async () => { + const session = await svc.create({ + metadata: { cwd: '/tmp/foo' }, + title: 'My session', + }); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0]!.workDir).toBe('/tmp/foo'); + expect(session.metadata.cwd).toBe('/tmp/foo'); + // title is echoed back even when CoreAPI doesn't reflect it (gap doc). + expect(session.title).toBe('My session'); + expect(session.created_at.endsWith('Z')).toBe(true); + }); + + it('passes model through to the agent_config when supplied', async () => { + await svc.create({ + metadata: { cwd: '/tmp/x' }, + agent_config: { model: 'moonshot-v1-128k' }, + }); + const created = state.sessions[0]!; + expect((state.sessions as SessionSummary[])[0]!.metadata?.['cwd']).toBe('/tmp/x'); + void created; + }); +}); + +describe('SessionServiceImpl.list', () => { + beforeEach(async () => { + // Seed 3 sessions in increasing createdAt order. + await svc.create({ metadata: { cwd: '/tmp/a' } }); + await svc.create({ metadata: { cwd: '/tmp/b' } }); + await svc.create({ metadata: { cwd: '/tmp/c' } }); + }); + + it('returns descending-by-createdAt order with default page size', async () => { + const page = await svc.list({}); + expect(page.items).toHaveLength(3); + expect(page.items[0]!.metadata.cwd).toBe('/tmp/c'); + expect(page.items[2]!.metadata.cwd).toBe('/tmp/a'); + expect(page.has_more).toBe(false); + }); + + it('honors page_size and surfaces has_more', async () => { + const page = await svc.list({ page_size: 2 }); + expect(page.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']); + expect(page.has_more).toBe(true); + }); + + it('before_id returns older sessions only', async () => { + const all = await svc.list({}); + const pivotId = all.items[0]!.id; // newest + const olderPage = await svc.list({ before_id: pivotId }); + expect(olderPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/b', '/tmp/a']); + }); + + it('after_id returns newer sessions only', async () => { + const all = await svc.list({}); + const pivotId = all.items[2]!.id; // oldest + const newerPage = await svc.list({ after_id: pivotId }); + expect(newerPage.items.map((s) => s.metadata.cwd)).toEqual(['/tmp/c', '/tmp/b']); + }); + + it('status filter applies post-hydration', async () => { + // Today everything maps to 'idle'; non-matching filter returns [] + const empty = await svc.list({ status: 'running' }); + expect(empty.items).toEqual([]); + const idle = await svc.list({ status: 'idle' }); + expect(idle.items.length).toBe(3); + }); +}); + +describe('SessionServiceImpl.get', () => { + it('returns the matching session', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/x' } }); + const found = await svc.get(created.id); + expect(found.id).toBe(created.id); + expect(found.metadata.cwd).toBe('/tmp/x'); + }); + + it('throws SessionNotFoundError for an unknown id', async () => { + await expect(svc.get('does-not-exist')).rejects.toBeInstanceOf(SessionNotFoundError); + await expect(svc.get('does-not-exist')).rejects.toThrow(/does not exist/); + }); +}); + +describe('SessionServiceImpl.update', () => { + let created: Session; + + beforeEach(async () => { + created = await svc.create({ metadata: { cwd: '/tmp/u' } }); + }); + + it('rejects updates to missing sessions with SessionNotFoundError', async () => { + await expect(svc.update('does-not-exist', { title: 'x' })).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + + it('routes title through bridge.rpc.renameSession', async () => { + await svc.update(created.id, { title: 'Renamed' }); + expect(state.renamedTitles.get(created.id)).toBe('Renamed'); + // Title is reflected via the next get (impl re-fetches metadata). + expect(state.metadataPatches.has(created.id)).toBe(false); + }); + + it('routes metadata patch through bridge.rpc.updateSessionMetadata (into .custom)', async () => { + await svc.update(created.id, { metadata: { custom_field: 'x' } }); + const patch = state.metadataPatches.get(created.id); + expect(patch).toEqual({ custom: { custom_field: 'x' } }); + }); + + it('handles both title + metadata in a single update', async () => { + await svc.update(created.id, { title: 'New', metadata: { tag: 'a' } }); + expect(state.renamedTitles.get(created.id)).toBe('New'); + expect(state.metadataPatches.get(created.id)).toEqual({ custom: { tag: 'a' } }); + }); + + it('is a no-op when update body is empty', async () => { + await svc.update(created.id, {}); + expect(state.renamedTitles.size).toBe(0); + expect(state.metadataPatches.size).toBe(0); + }); + + it('returns the post-update Session shape', async () => { + const after = await svc.update(created.id, { title: 'Renamed' }); + expect(after.id).toBe(created.id); + expect(after.metadata.cwd).toBe('/tmp/u'); + }); +}); + +describe('SessionServiceImpl.delete', () => { + it('calls bridge.rpc.closeSession and returns { deleted: true }', async () => { + const created = await svc.create({ metadata: { cwd: '/tmp/d' } }); + const result = await svc.delete(created.id); + expect(result).toEqual({ deleted: true }); + expect(state.closedIds).toEqual([created.id]); + }); + + it('throws SessionNotFoundError on a missing id', async () => { + await expect(svc.delete('does-not-exist')).rejects.toBeInstanceOf(SessionNotFoundError); + }); +}); diff --git a/packages/services/test/task-service.test.ts b/packages/services/test/task-service.test.ts new file mode 100644 index 000000000..d27c490fe --- /dev/null +++ b/packages/services/test/task-service.test.ts @@ -0,0 +1,246 @@ +/** + * `TaskServiceImpl` (Chain 8 / P1.8, W9.2) unit tests. + * + * Hermetic: mocks `IHarnessBridge` with an in-memory `rpc` proxy. Coverage: + * - kind mapping (process/agent/question → bash/subagent/tool) + * - status mapping (running/completed/failed/timed_out/killed/lost → wire) + * - timestamp synthesis (created_at = started_at from startedAt; completed_at + * omitted when endedAt is null) + * - list/get/cancel happy paths + * - TaskNotFoundError → 40406 (list miss + get miss + cancel miss) + * - TaskAlreadyFinishedError → 40904 (cancel on terminal status) + * - SessionNotFoundError → 40401 (session existence check) + */ + +import { describe, expect, it } from 'vitest'; + +import type { + BackgroundTaskInfo, + SessionSummary, + StopBackgroundPayload, +} from '@moonshot-ai/agent-core'; + +import { + type IHarnessBridge, + type HarnessRPC, + SessionNotFoundError, + TaskAlreadyFinishedError, + TaskNotFoundError, + TaskServiceImpl, + toProtocolTask, +} from '../src'; + +interface FakeState { + sessions: SessionSummary[]; + tasksBySession: Map; + stopCalls: Array; +} + +function makeBridge(state: FakeState): IHarnessBridge { + const rpc: Partial = { + listSessions: async () => state.sessions, + getBackground: async (p: { sessionId: string; agentId: string; activeOnly?: boolean }) => + state.tasksBySession.get(p.sessionId) ?? [], + stopBackground: async ( + p: StopBackgroundPayload & { sessionId: string; agentId: string }, + ) => { + state.stopCalls.push(p); + }, + }; + return { + rpc: rpc as HarnessRPC, + ready: async () => undefined, + dispose: () => undefined, + }; +} + +function session(id: string): SessionSummary { + return { + id, + workDir: '/tmp', + sessionDir: `/tmp/sd-${id}`, + createdAt: 0, + updatedAt: 0, + }; +} + +function bashTask( + taskId: string, + status: BackgroundTaskInfo['status'], + endedAt: number | null = null, +): BackgroundTaskInfo { + return { + taskId, + kind: 'process', + description: 'pnpm install', + status, + startedAt: 1_000_000, + endedAt, + command: 'pnpm install', + pid: 1234, + exitCode: status === 'completed' ? 0 : null, + }; +} + +function fresh(): FakeState { + return { sessions: [], tasksBySession: new Map(), stopCalls: [] }; +} + +// --- Adapter -------------------------------------------------------------- + +describe('toProtocolTask adapter', () => { + it('maps process → bash with synthesized created_at/started_at', () => { + const out = toProtocolTask('s1', bashTask('t1', 'running')); + expect(out.kind).toBe('bash'); + expect(out.status).toBe('running'); + expect(out.session_id).toBe('s1'); + expect(out.id).toBe('t1'); + expect(out.created_at).toBe(out.started_at); + expect(out.created_at.endsWith('Z')).toBe(true); + expect(out.completed_at).toBeUndefined(); + }); + + it('surfaces completed_at when endedAt is set', () => { + const out = toProtocolTask('s1', bashTask('t1', 'completed', 1_001_000)); + expect(out.status).toBe('completed'); + expect(out.completed_at).toBe(new Date(1_001_000).toISOString()); + }); + + it("maps 'timed_out' → 'failed' (lossy)", () => { + const out = toProtocolTask('s1', bashTask('t1', 'timed_out')); + expect(out.status).toBe('failed'); + }); + + it("maps 'killed' → 'cancelled'", () => { + const out = toProtocolTask('s1', bashTask('t1', 'killed')); + expect(out.status).toBe('cancelled'); + }); + + it("maps 'lost' → 'failed' (lossy)", () => { + const out = toProtocolTask('s1', bashTask('t1', 'lost')); + expect(out.status).toBe('failed'); + }); + + it("maps 'agent' kind → 'subagent'", () => { + const info: BackgroundTaskInfo = { + taskId: 't_a', + kind: 'agent', + description: 'sub', + status: 'running', + startedAt: 0, + endedAt: null, + }; + expect(toProtocolTask('s', info).kind).toBe('subagent'); + }); + + it("maps 'question' kind → 'tool'", () => { + const info: BackgroundTaskInfo = { + taskId: 't_q', + kind: 'question', + description: 'q', + status: 'running', + startedAt: 0, + endedAt: null, + questionCount: 1, + }; + expect(toProtocolTask('s', info).kind).toBe('tool'); + }); +}); + +// --- Service impl --------------------------------------------------------- + +describe('TaskServiceImpl.list', () => { + it('throws SessionNotFoundError on unknown session', async () => { + const svc = new TaskServiceImpl(makeBridge(fresh())); + await expect(svc.list('unknown', {})).rejects.toBeInstanceOf(SessionNotFoundError); + }); + + it('returns adapted tasks for the session', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'running'), bashTask('t2', 'completed', 1_001_000)]); + const svc = new TaskServiceImpl(makeBridge(state)); + const out = await svc.list('s1', {}); + expect(out).toHaveLength(2); + expect(out[0]!.status).toBe('running'); + expect(out[1]!.status).toBe('completed'); + }); + + it('filters by status when query.status is set', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [ + bashTask('t1', 'running'), + bashTask('t2', 'completed', 1_001_000), + bashTask('t3', 'killed', 1_002_000), // → 'cancelled' + ]); + const svc = new TaskServiceImpl(makeBridge(state)); + expect((await svc.list('s1', { status: 'running' })).map((t) => t.id)).toEqual(['t1']); + expect((await svc.list('s1', { status: 'cancelled' })).map((t) => t.id)).toEqual(['t3']); + }); +}); + +describe('TaskServiceImpl.get', () => { + it('throws TaskNotFoundError for unknown id', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', []); + const svc = new TaskServiceImpl(makeBridge(state)); + await expect(svc.get('s1', 'nope')).rejects.toBeInstanceOf(TaskNotFoundError); + }); + + it('returns the adapted task by id', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'running')]); + const svc = new TaskServiceImpl(makeBridge(state)); + const task = await svc.get('s1', 't1'); + expect(task.id).toBe('t1'); + }); +}); + +describe('TaskServiceImpl.cancel', () => { + it('throws TaskNotFoundError for unknown id', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', []); + const svc = new TaskServiceImpl(makeBridge(state)); + await expect(svc.cancel('s1', 'nope')).rejects.toBeInstanceOf(TaskNotFoundError); + }); + + it('throws TaskAlreadyFinishedError when status is completed', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'completed', 1_001_000)]); + const svc = new TaskServiceImpl(makeBridge(state)); + await expect(svc.cancel('s1', 't1')).rejects.toBeInstanceOf(TaskAlreadyFinishedError); + }); + + it('throws TaskAlreadyFinishedError when status is failed (terminal)', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'failed', 1_001_000)]); + const svc = new TaskServiceImpl(makeBridge(state)); + await expect(svc.cancel('s1', 't1')).rejects.toBeInstanceOf(TaskAlreadyFinishedError); + }); + + it('throws TaskAlreadyFinishedError when agent-core status is killed (→ cancelled)', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'killed', 1_001_000)]); + const svc = new TaskServiceImpl(makeBridge(state)); + await expect(svc.cancel('s1', 't1')).rejects.toBeInstanceOf(TaskAlreadyFinishedError); + }); + + it('calls bridge.rpc.stopBackground({taskId}) for a running task', async () => { + const state = fresh(); + state.sessions.push(session('s1')); + state.tasksBySession.set('s1', [bashTask('t1', 'running')]); + const svc = new TaskServiceImpl(makeBridge(state)); + const result = await svc.cancel('s1', 't1'); + expect(result).toEqual({ cancelled: true }); + expect(state.stopCalls).toHaveLength(1); + expect(state.stopCalls[0]!.taskId).toBe('t1'); + expect(state.stopCalls[0]!.sessionId).toBe('s1'); + }); +}); diff --git a/packages/services/test/tool-service.test.ts b/packages/services/test/tool-service.test.ts new file mode 100644 index 000000000..3d423675d --- /dev/null +++ b/packages/services/test/tool-service.test.ts @@ -0,0 +1,243 @@ +/** + * `ToolServiceImpl` + `McpServiceImpl` (Chain 7 / P1.7, W9.1) unit tests. + * + * Hermetic: mocks `IHarnessBridge` with an in-memory `rpc` proxy. Exercises: + * - tool source mapping: 'builtin' / 'user'→'skill' / 'mcp' + mcp_server_id parse + * - mcp server status mapping (all 5 agent-core literals → 4 wire literals) + * - transport pass-through + * - last_error surfaced via `error?` + * - McpServerNotFoundError raised for unknown server id + * - empty-session-list behavior (returns [] / throws not found) + */ + +import { describe, expect, it } from 'vitest'; + +import type { + EmptyPayload, + McpServerInfo, + ReconnectMcpServerPayload, + SessionSummary, +} from '@moonshot-ai/agent-core'; + +import { + type IHarnessBridge, + type HarnessRPC, + McpServerNotFoundError, + McpServiceImpl, + ToolServiceImpl, + toProtocolMcpServer, + toProtocolTool, +} from '../src'; +import type { AgentCoreToolInfoLike } from '../src'; + +interface FakeBridgeState { + sessions: SessionSummary[]; + tools: AgentCoreToolInfoLike[]; + mcpServers: McpServerInfo[]; + reconnectCalls: ReconnectMcpServerPayload[]; +} + +function makeFakeBridge(state: FakeBridgeState): IHarnessBridge { + const rpc: Partial = { + listSessions: async () => state.sessions, + getTools: async (_p: unknown) => state.tools as unknown as readonly never[], + listMcpServers: async (_p: EmptyPayload & { sessionId: string }) => state.mcpServers, + reconnectMcpServer: async ( + p: ReconnectMcpServerPayload & { sessionId: string }, + ) => { + state.reconnectCalls.push(p); + }, + }; + return { + rpc: rpc as HarnessRPC, + ready: async () => undefined, + dispose: () => undefined, + }; +} + +function fakeSession(id: string, createdAt: number): SessionSummary { + return { + id, + workDir: '/tmp/wd', + sessionDir: `/tmp/sd-${id}`, + createdAt, + updatedAt: createdAt, + }; +} + +function freshState(): FakeBridgeState { + return { sessions: [], tools: [], mcpServers: [], reconnectCalls: [] }; +} + +// --- Adapter tests ---------------------------------------------------------- + +describe('toProtocolTool adapter', () => { + it("maps builtin source as-is and emits input_schema = null", () => { + const out = toProtocolTool({ name: 'Bash', description: 'd', source: 'builtin' }); + expect(out.source).toBe('builtin'); + expect(out.input_schema).toBeNull(); + expect(out.mcp_server_id).toBeUndefined(); + }); + + it("maps agent-core 'user' source to wire 'skill'", () => { + const out = toProtocolTool({ name: 'mySkill', description: 'd', source: 'user' }); + expect(out.source).toBe('skill'); + }); + + it("parses mcp_server_id from qualified mcp tool name 'mcp:lark:search'", () => { + const out = toProtocolTool({ + name: 'mcp:lark:search', + description: 'd', + source: 'mcp', + }); + expect(out.source).toBe('mcp'); + expect(out.mcp_server_id).toBe('lark'); + }); + + it('omits mcp_server_id when the mcp tool name lacks the conventional prefix', () => { + const out = toProtocolTool({ + name: 'oddly_named', + description: 'd', + source: 'mcp', + }); + expect(out.mcp_server_id).toBeUndefined(); + }); +}); + +describe('toProtocolMcpServer adapter', () => { + function base( + overrides: Partial & Pick, + ): McpServerInfo { + return { + name: 'lark', + transport: 'stdio', + toolCount: 3, + ...overrides, + } as McpServerInfo; + } + + it("maps 'pending' → 'connecting'", () => { + expect(toProtocolMcpServer(base({ status: 'pending' })).status).toBe('connecting'); + }); + it("passes 'connected' through", () => { + expect(toProtocolMcpServer(base({ status: 'connected' })).status).toBe('connected'); + }); + it("maps 'failed' → 'error'", () => { + expect(toProtocolMcpServer(base({ status: 'failed' })).status).toBe('error'); + }); + it("maps 'disabled' → 'disconnected'", () => { + expect(toProtocolMcpServer(base({ status: 'disabled' })).status).toBe('disconnected'); + }); + it("maps 'needs-auth' → 'error' and surfaces last_error from error?", () => { + const out = toProtocolMcpServer( + base({ status: 'needs-auth', error: 'visit https://auth' }), + ); + expect(out.status).toBe('error'); + expect(out.last_error).toBe('visit https://auth'); + }); + it('adopts name-as-id', () => { + expect(toProtocolMcpServer(base({ status: 'connected', name: 'foo' })).id).toBe('foo'); + }); + it("does not set last_error when info.error is undefined or empty", () => { + expect(toProtocolMcpServer(base({ status: 'connected' })).last_error).toBeUndefined(); + expect( + toProtocolMcpServer(base({ status: 'connected', error: '' })).last_error, + ).toBeUndefined(); + }); +}); + +// --- Service impl tests ----------------------------------------------------- + +describe('ToolServiceImpl.list', () => { + it('returns [] when no sessions exist (CoreAPI gap)', async () => { + const svc = new ToolServiceImpl(makeFakeBridge(freshState())); + const out = await svc.list(); + expect(out).toEqual([]); + }); + + it('returns adapted tools using the most-recent session id', async () => { + const state = freshState(); + state.sessions.push(fakeSession('s_old', 1)); + state.sessions.push(fakeSession('s_new', 2)); + state.tools.push( + { name: 'Bash', description: 'b', source: 'builtin' }, + { name: 'mcp:lark:search', description: 'l', source: 'mcp' }, + ); + const svc = new ToolServiceImpl(makeFakeBridge(state)); + const out = await svc.list(); + expect(out).toHaveLength(2); + expect(out[0]!.source).toBe('builtin'); + expect(out[1]!.source).toBe('mcp'); + expect(out[1]!.mcp_server_id).toBe('lark'); + }); + + it('returns [] when getTools throws (session not loaded)', async () => { + const state = freshState(); + state.sessions.push(fakeSession('s', 1)); + const bridge = makeFakeBridge(state); + (bridge.rpc as HarnessRPC).getTools = async () => { + throw new Error('session not loaded'); + }; + const svc = new ToolServiceImpl(bridge); + expect(await svc.list()).toEqual([]); + }); +}); + +describe('McpServiceImpl.list', () => { + it('returns [] when no sessions exist (registrar not reachable)', async () => { + const svc = new McpServiceImpl(makeFakeBridge(freshState())); + expect(await svc.list()).toEqual([]); + }); + + it('returns adapted servers from the most-recent session', async () => { + const state = freshState(); + state.sessions.push(fakeSession('s', 1)); + state.mcpServers.push({ + name: 'lark', + transport: 'stdio', + status: 'connected', + toolCount: 7, + }); + const svc = new McpServiceImpl(makeFakeBridge(state)); + const out = await svc.list(); + expect(out).toHaveLength(1); + expect(out[0]!.id).toBe('lark'); + expect(out[0]!.tool_count).toBe(7); + }); +}); + +describe('McpServiceImpl.restart', () => { + it('throws McpServerNotFoundError when no sessions exist', async () => { + const svc = new McpServiceImpl(makeFakeBridge(freshState())); + await expect(svc.restart('lark')).rejects.toBeInstanceOf(McpServerNotFoundError); + }); + + it('throws McpServerNotFoundError when the id is not in the registrar', async () => { + const state = freshState(); + state.sessions.push(fakeSession('s', 1)); + state.mcpServers.push({ + name: 'lark', + transport: 'stdio', + status: 'connected', + toolCount: 1, + }); + const svc = new McpServiceImpl(makeFakeBridge(state)); + await expect(svc.restart('unknown')).rejects.toBeInstanceOf(McpServerNotFoundError); + }); + + it('calls bridge.rpc.reconnectMcpServer({name}) and returns {restarting:true}', async () => { + const state = freshState(); + state.sessions.push(fakeSession('s', 1)); + state.mcpServers.push({ + name: 'lark', + transport: 'stdio', + status: 'connected', + toolCount: 1, + }); + const svc = new McpServiceImpl(makeFakeBridge(state)); + const result = await svc.restart('lark'); + expect(result).toEqual({ restarting: true }); + expect(state.reconnectCalls).toHaveLength(1); + expect(state.reconnectCalls[0]!.name).toBe('lark'); + }); +}); diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json new file mode 100644 index 000000000..baa15b2b1 --- /dev/null +++ b/packages/services/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "experimentalDecorators": true + }, + "include": ["src", "test", "../agent-core/src/prompt-modules.d.ts"] +} diff --git a/packages/services/tsdown.config.ts b/packages/services/tsdown.config.ts new file mode 100644 index 000000000..cbaf69fe9 --- /dev/null +++ b/packages/services/tsdown.config.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: false, + outDir: 'dist', + clean: true, + alias: { + '@moonshot-ai/kimi-code-sdk': fileURLToPath( + new URL('../node-sdk/src/index.ts', import.meta.url), + ), + '@moonshot-ai/agent-core': fileURLToPath( + new URL('../agent-core/src/index.ts', import.meta.url), + ), + '@moonshot-ai/protocol': fileURLToPath( + new URL('../protocol/src/index.ts', import.meta.url), + ), + }, + deps: { + alwaysBundle: [/^@moonshot-ai\//], + neverBundle: [], + }, +}); diff --git a/packages/services/vitest.config.ts b/packages/services/vitest.config.ts new file mode 100644 index 000000000..0187817e3 --- /dev/null +++ b/packages/services/vitest.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +export default defineConfig({ + plugins: [rawTextPlugin()], + resolve: { + alias: { + '@moonshot-ai/kimi-code-sdk': fileURLToPath( + new URL('../node-sdk/src/index.ts', import.meta.url), + ), + '@moonshot-ai/agent-core': fileURLToPath( + new URL('../agent-core/src/index.ts', import.meta.url), + ), + '@moonshot-ai/protocol': fileURLToPath( + new URL('../protocol/src/index.ts', import.meta.url), + ), + '@moonshot-ai/kimi-code-oauth': fileURLToPath( + new URL('../oauth/src/index.ts', import.meta.url), + ), + }, + }, + test: { + name: 'services', + include: ['test/**/*.{test,e2e}.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f2ea795..e07ca7b5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@moonshot-ai/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter + '@moonshot-ai/daemon': + specifier: workspace:^ + version: link:../../packages/daemon '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../../packages/oauth @@ -291,7 +294,7 @@ importers: version: 7.5.13 undici: specifier: ^7.27.1 - version: 7.27.1 + version: 7.27.2 yauzl: specifier: ^3.3.0 version: 3.3.0 @@ -330,6 +333,49 @@ importers: specifier: ^3.3.1 version: 3.3.1 + packages/daemon: + dependencies: + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 + '@moonshot-ai/agent-core': + specifier: workspace:^ + version: link:../agent-core + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol + '@moonshot-ai/services': + specifier: workspace:^ + version: link:../services + chokidar: + specifier: ^4.0.3 + version: 4.0.3 + fastify: + specifier: ^5.1.0 + version: 5.8.5 + ignore: + specifier: ^5.3.2 + version: 5.3.2 + pino: + specifier: ^9.5.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.0.0 + version: 13.1.3 + ulid: + specifier: ^3.0.1 + version: 3.0.2 + ws: + specifier: ^8.18.0 + version: 8.20.0 + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@types/ws': + specifier: ^8.18.0 + version: 8.18.1 + packages/kaos: dependencies: pathe: @@ -434,6 +480,21 @@ importers: specifier: 'catalog:' version: 4.3.6 + packages/services: + dependencies: + '@moonshot-ai/agent-core': + specifier: workspace:^ + version: link:../agent-core + '@moonshot-ai/kimi-code-sdk': + specifier: workspace:^ + version: link:../node-sdk + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol + ulid: + specifier: ^3.0.1 + version: 3.0.2 + packages/telemetry: {} packages: @@ -1249,6 +1310,33 @@ packages: cpu: [x64] os: [win32] + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@google/genai@1.49.0': resolution: {integrity: sha512-hO69Zl0H3x+L0KL4stl1pLYgnqnwHoLqtKy6MRlNnW8TAxjqMdOUVafomKd4z1BePkzoxJWbYILny9a2Zk43VQ==} engines: {node: '>=20.0.0'} @@ -1591,6 +1679,9 @@ packages: cpu: [x64] os: [win32] + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2442,6 +2533,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2596,6 +2690,9 @@ packages: a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2697,10 +2794,17 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2820,6 +2924,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3126,6 +3234,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -3256,6 +3367,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -3382,6 +3496,12 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3389,12 +3509,27 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3425,6 +3560,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3589,6 +3728,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -3675,6 +3817,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3848,6 +3994,10 @@ packages: jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -3870,6 +4020,9 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -3917,6 +4070,9 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -4215,6 +4371,9 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4313,6 +4472,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -4455,6 +4618,23 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -4498,6 +4678,12 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -4517,6 +4703,9 @@ packages: engines: {node: '>=18'} hasBin: true + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -4534,6 +4723,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4576,6 +4768,14 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4633,6 +4833,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -4729,6 +4933,14 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4742,6 +4954,9 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4894,6 +5109,9 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4912,6 +5130,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4983,6 +5205,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -5031,6 +5257,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5054,6 +5283,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -5184,8 +5417,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.27.1: - resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} unicode-emoji-modifier-base@1.0.0: @@ -6323,6 +6556,41 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/busboy@3.2.0': {} + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + '@google/genai@1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -6627,6 +6895,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.59.0': optional: true + '@pinojs/redact@0.4.0': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -7288,6 +7558,10 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.17 @@ -7476,6 +7750,8 @@ snapshots: a-sync-waterfall@1.0.1: {} + abstract-logging@2.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -7580,10 +7856,17 @@ snapshots: async-function@1.0.0: {} + atomic-sleep@1.0.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + bail@2.0.2: {} balanced-match@4.0.4: {} @@ -7692,6 +7975,10 @@ snapshots: chardet@2.1.1: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chownr@3.0.0: {} cjs-module-lexer@1.4.3: {} @@ -8020,6 +8307,8 @@ snapshots: dataloader@1.4.0: {} + dateformat@4.6.3: {} + dayjs@1.11.21: {} debug@4.4.3: @@ -8127,6 +8416,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -8381,6 +8674,10 @@ snapshots: extendable-error@0.1.7: {} + fast-copy@4.0.3: {} + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -8391,10 +8688,45 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} fast-uri@3.1.0: {} + fastify-plugin@5.1.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -8429,6 +8761,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -8626,6 +8964,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + help-me@5.0.0: {} + highlight.js@10.7.3: {} hono@4.12.14: {} @@ -8698,6 +9038,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.4.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -8859,6 +9201,8 @@ snapshots: jose@6.2.2: {} + joycon@3.1.1: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -8878,6 +9222,10 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.2 @@ -8925,6 +9273,12 @@ snapshots: layout-base@2.0.1: {} + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: optional: true @@ -9334,6 +9688,8 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minimist@1.2.8: {} + minipass@7.1.3: {} minisearch@7.2.0: {} @@ -9411,6 +9767,8 @@ snapshots: obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -9546,6 +9904,46 @@ snapshots: pify@4.0.1: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + pkce-challenge@5.0.1: {} pkg-pr-new@0.0.75: {} @@ -9581,6 +9979,10 @@ snapshots: pretty-bytes@7.1.0: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 @@ -9616,6 +10018,11 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} qs@6.15.1: @@ -9628,6 +10035,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -9667,6 +10076,10 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readdirp@4.1.2: {} + + real-require@0.2.0: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -9759,6 +10172,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.0: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -9937,6 +10352,12 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} scheduler@0.27.0: {} @@ -9948,6 +10369,8 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -10118,6 +10541,10 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.6.1: {} @@ -10131,6 +10558,8 @@ snapshots: speakingurl@14.0.1: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} ssh2@1.17.0: @@ -10213,6 +10642,8 @@ snapshots: strip-bom@3.0.0: {} + strip-json-comments@5.0.3: {} + stylis@4.4.0: {} superjson@2.2.6: @@ -10258,6 +10689,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -10275,6 +10710,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.1: {} + toidentifier@1.0.1: {} tokenx@1.3.0: {} @@ -10398,7 +10835,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.27.1: {} + undici@7.27.2: {} unicode-emoji-modifier-base@1.0.0: {} From 94b2c73ff81fda18f71b19c73382e6f2476b19e0 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 5 Jun 2026 15:05:17 +0800 Subject: [PATCH 002/255] feat(daemon): add OpenAPI docs for REST routes - add Swagger/OpenAPI generation and /documentation UI\n- move REST route registration under /api/v1\n- generate route schemas from Zod definitions\n- add daemon Swagger e2e coverage and dev daemon scripts --- apps/kimi-code/package.json | 1 + apps/kimi-code/tsconfig.dev.json | 14 ++ package.json | 1 + packages/daemon/package.json | 5 +- packages/daemon/src/lock.ts | 16 +- packages/daemon/src/middleware/schema.ts | 135 +++++++++++ packages/daemon/src/routes/action-suffix.ts | 2 +- packages/daemon/src/routes/approvals.ts | 17 +- packages/daemon/src/routes/files.ts | 54 +++-- packages/daemon/src/routes/fs.ts | 43 ++-- packages/daemon/src/routes/messages.ts | 36 ++- packages/daemon/src/routes/meta.ts | 22 +- packages/daemon/src/routes/prompts.ts | 36 ++- packages/daemon/src/routes/questions.ts | 20 +- packages/daemon/src/routes/sessions.ts | 91 ++++++-- packages/daemon/src/routes/tasks.ts | 54 +++-- packages/daemon/src/routes/tools.ts | 57 +++-- packages/daemon/src/services/file-store.ts | 19 +- .../daemon/src/services/fs-path-safety.ts | 2 +- packages/daemon/src/services/rest-gateway.ts | 2 +- packages/daemon/src/services/ws-gateway.ts | 8 +- packages/daemon/src/start.ts | 211 +++++++++++------- packages/daemon/src/version.ts | 2 +- packages/daemon/src/ws/connection.ts | 2 +- packages/daemon/src/ws/protocol.ts | 2 +- packages/daemon/test/approval.e2e.test.ts | 16 +- packages/daemon/test/error-handler.test.ts | 16 +- packages/daemon/test/files.e2e.test.ts | 26 +-- packages/daemon/test/fs-basic.e2e.test.ts | 38 ++-- packages/daemon/test/fs-batch.e2e.test.ts | 30 +-- packages/daemon/test/fs-download.e2e.test.ts | 36 +-- packages/daemon/test/fs-git.e2e.test.ts | 20 +- packages/daemon/test/fs-search.e2e.test.ts | 32 +-- packages/daemon/test/fs-watch.e2e.test.ts | 4 +- packages/daemon/test/lock.test.ts | 4 +- packages/daemon/test/messages.e2e.test.ts | 30 +-- packages/daemon/test/meta.e2e.test.ts | 26 +-- packages/daemon/test/prompt.e2e.test.ts | 14 +- packages/daemon/test/question.e2e.test.ts | 22 +- packages/daemon/test/sessions.e2e.test.ts | 60 ++--- packages/daemon/test/swagger.e2e.test.ts | 88 ++++++++ packages/daemon/test/tasks.e2e.test.ts | 34 +-- packages/daemon/test/tools.e2e.test.ts | 36 +-- packages/daemon/test/ws-abort.e2e.test.ts | 20 +- packages/daemon/test/ws-broadcast.e2e.test.ts | 2 +- packages/daemon/test/ws-handshake.e2e.test.ts | 6 +- packages/daemon/test/ws-resync.e2e.test.ts | 2 +- .../services/src/impls/mcp-service-impl.ts | 4 +- pnpm-lock.yaml | 110 +++++++++ 49 files changed, 1075 insertions(+), 453 deletions(-) create mode 100644 apps/kimi-code/tsconfig.dev.json create mode 100644 packages/daemon/src/middleware/schema.ts create mode 100644 packages/daemon/test/swagger.e2e.test.ts diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e1d692a46..4491ac5dd 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -56,6 +56,7 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:daemon": "tsx watch --tsconfig ./tsconfig.dev.json --clear-screen=false --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", diff --git a/apps/kimi-code/tsconfig.dev.json b/apps/kimi-code/tsconfig.dev.json new file mode 100644 index 000000000..9e4df279a --- /dev/null +++ b/apps/kimi-code/tsconfig.dev.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src", + "test", + "../../packages/*/src/**/*.ts", + "../../packages/*/src/**/*.tsx", + "../../packages/*/test/**/*.ts", + "../../packages/agent-core/src/prompt-modules.d.ts" + ] +} diff --git a/package.json b/package.json index dd7aef4cb..b95793444 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "pnpm -r run build", "build:packages": "pnpm -r --filter './packages/*' run build", "dev:cli": "pnpm -C apps/kimi-code run dev", + "dev:daemon": "pnpm -C apps/kimi-code run dev:daemon", "build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace", "vis": "pnpm -C apps/vis run dev", "dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev", diff --git a/packages/daemon/package.json b/packages/daemon/package.json index aa55bc5df..b41815de6 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -34,6 +34,8 @@ }, "dependencies": { "@fastify/multipart": "^10.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.6", "@moonshot-ai/agent-core": "workspace:^", "@moonshot-ai/protocol": "workspace:^", "@moonshot-ai/services": "workspace:^", @@ -44,7 +46,8 @@ "pino-pretty": "^13.0.0", "ulid": "^3.0.1", "ws": "^8.18.0", - "zod": "catalog:" + "zod": "catalog:", + "zod-to-json-schema": "^3.25.2" }, "devDependencies": { "@types/ws": "^8.18.0" diff --git a/packages/daemon/src/lock.ts b/packages/daemon/src/lock.ts index 915cc26e3..c2cf0faca 100644 --- a/packages/daemon/src/lock.ts +++ b/packages/daemon/src/lock.ts @@ -1,10 +1,11 @@ /** * Filesystem lock for single-instance daemon enforcement (ROADMAP P0.12). * - * The lock is a small JSON file at `~/.kimi/daemon/lock` (overridable for - * tests). It records the live daemon's `pid`, `started_at`, and `port`. - * Acquisition is exclusive (`O_WRONLY | O_CREAT | O_EXCL`) — racing daemons - * can't both win. + * The lock is a small JSON file at `/daemon/lock` (defaults + * to `~/.kimi-code/daemon/lock`; overridable via `KIMI_CODE_HOME` env or + * `lockPath` for tests). It records the live daemon's `pid`, `started_at`, + * and `port`. Acquisition is exclusive (`O_WRONLY | O_CREAT | O_EXCL`) — + * racing daemons can't both win. * * Stale lock takeover: when a lock file exists, we ping the recorded pid via * `process.kill(pid, 0)`. Node's `kill` does NOT send a signal when sig is 0 — @@ -32,10 +33,11 @@ import { writeFileSync, openSync, } from 'node:fs'; -import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -export const DEFAULT_LOCK_DIR = join(homedir(), '.kimi', 'daemon'); +import { resolveKimiHome } from '@moonshot-ai/agent-core'; + +export const DEFAULT_LOCK_DIR = join(resolveKimiHome(), 'daemon'); export const DEFAULT_LOCK_PATH = join(DEFAULT_LOCK_DIR, 'lock'); /** JSON shape stored in the lock file. snake_case to match operator-facing logs. */ @@ -46,7 +48,7 @@ export interface LockContents { } export interface AcquireLockOptions { - /** Override default `~/.kimi/daemon/lock` — used in tests. */ + /** Override default `/daemon/lock` — used in tests. */ lockPath?: string; /** Port the daemon will bind to. Recorded in the lock file for diagnostics. */ port: number; diff --git a/packages/daemon/src/middleware/schema.ts b/packages/daemon/src/middleware/schema.ts new file mode 100644 index 000000000..147cd1243 --- /dev/null +++ b/packages/daemon/src/middleware/schema.ts @@ -0,0 +1,135 @@ +/** + * Zod → JSON Schema conversion helpers for Fastify `@fastify/swagger`. + * + * All daemon REST responses are wrapped in a uniform envelope + * `{ code, msg, data, request_id }`. The helpers here let route modules + * declare their OpenAPI schema by re-using the same Zod schemas that + * already drive runtime validation — no second source of truth. + */ + +import { envelopeSchema } from '@moonshot-ai/protocol'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { z } from 'zod'; + +/** + * Convert a Zod schema to a plain JSON Schema object suitable for + * Fastify's `schema` option. + * + * We drop the top-level `$schema` key because Fastify/OpenAPI inline + * schemas don't need it. + */ +export function jsonSchema(schema: z.ZodTypeAny): Record { + const converted = zodToJsonSchema(schema as never, { + target: 'openApi3', + name: 'Schema', + }) as Record; + // The wrapper adds a `$schema` and `additionalProperties` when a name + // is given; we only want the inner object shape. + if (converted['$schema'] !== undefined) { + delete converted['$schema']; + } + if (converted['additionalProperties'] !== undefined) { + delete converted['additionalProperties']; + } + // When using `name`, zod-to-json-schema wraps in `definitions.Schema`. + // We prefer the inline shape. + if (converted['definitions'] !== undefined) { + const definitions = converted['definitions'] as Record; + if (definitions['Schema'] !== undefined) { + return definitions['Schema'] as Record; + } + } + return converted; +} + +/** + * Wrap a data Zod schema in the daemon's envelope shape and return its + * JSON Schema representation. + */ +export function envelopeJsonSchema( + dataSchema: z.ZodTypeAny, +): Record { + return jsonSchema(envelopeSchema(dataSchema)); +} + +/** + * Build a Fastify route-schema bag from Zod schemas + metadata. + * + * All Zod fields are automatically converted via `jsonSchema()`. + * The `response` map values are also wrapped in envelopes unless you + * pass an explicit `rawResponse` option. + */ +export interface RouteSchemaOptions { + /** Request body Zod schema. */ + body?: z.ZodTypeAny; + /** Query-string Zod schema. */ + querystring?: z.ZodTypeAny; + /** Route params Zod schema. */ + params?: z.ZodTypeAny; + /** + * Response schema map: status code → Zod schema. + * Each schema is automatically wrapped in the envelope. + * Use `rawResponse` if you need an unwrapped schema (e.g. binary + * download success path). + */ + response?: Record; + /** + * Response schema map that is NOT envelope-wrapped. + * Useful for the `200` on binary-stream endpoints. + */ + rawResponse?: Record>; + description?: string; + summary?: string; + tags?: string[]; + operationId?: string; + consumes?: string[]; + produces?: string[]; +} + +export function buildRouteSchema(options: RouteSchemaOptions): Record { + const schema: Record = {}; + + if (options.body) { + schema['body'] = jsonSchema(options.body); + } + if (options.querystring) { + schema['querystring'] = jsonSchema(options.querystring); + } + if (options.params) { + schema['params'] = jsonSchema(options.params); + } + if (options.response || options.rawResponse) { + const responses: Record = {}; + if (options.response) { + for (const [code, zodSchema] of Object.entries(options.response)) { + responses[String(code)] = envelopeJsonSchema(zodSchema); + } + } + if (options.rawResponse) { + for (const [code, rawSchema] of Object.entries(options.rawResponse)) { + responses[String(code)] = rawSchema; + } + } + schema['response'] = responses; + } + if (options.description) { + schema['description'] = options.description; + } + if (options.summary) { + schema['summary'] = options.summary; + } + if (options.tags) { + schema['tags'] = options.tags; + } + if (options.operationId) { + schema['operationId'] = options.operationId; + } + if (options.consumes) { + schema['consumes'] = options.consumes; + } + if (options.produces) { + schema['produces'] = options.produces; + } + + return schema; +} diff --git a/packages/daemon/src/routes/action-suffix.ts b/packages/daemon/src/routes/action-suffix.ts index a5f127208..858e03c88 100644 --- a/packages/daemon/src/routes/action-suffix.ts +++ b/packages/daemon/src/routes/action-suffix.ts @@ -45,7 +45,7 @@ export interface ParseActionSuffixOptions { * When set, a bare `` (no action suffix) is accepted and reported as * `{kind:'bare'}`. When `undefined`, bare ids are rejected with * `unsupported action: ` — appropriate for resources where every - * REST action is an explicit `:verb` (e.g. `/v1/sessions/{sid}/prompts/`). + * REST action is an explicit `:verb` (e.g. `/sessions/{sid}/prompts/`). */ readonly defaultAction?: TAction; /** diff --git a/packages/daemon/src/routes/approvals.ts b/packages/daemon/src/routes/approvals.ts index 2520a850e..0ffda5405 100644 --- a/packages/daemon/src/routes/approvals.ts +++ b/packages/daemon/src/routes/approvals.ts @@ -1,9 +1,9 @@ /** - * `/v1/sessions/{sid}/approvals/{aid}` REST route (Chain 5 / P1.5, W8.1). + * `/sessions/{sid}/approvals/{aid}` REST route (Chain 5 / P1.5, W8.1). * * 1 endpoint (REST.md §3.6): * - * POST /v1/sessions/{sid}/approvals/{aid} body: ApprovalResponse + * POST /sessions/{sid}/approvals/{aid} body: ApprovalResponse * data: { resolved: true, resolved_at } * * Error mapping (REST.md §3.6): @@ -26,6 +26,7 @@ import { approvalResolveRequestSchema, + approvalResolveResultSchema, ErrorCode, type ApprovalResolveRequest, type ApprovalResolveResult, @@ -39,6 +40,7 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateBody, validateParams } from '../middleware/validate.js'; import { DaemonApprovalBroker, @@ -47,7 +49,7 @@ import { interface ApprovalRouteHost { post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -65,12 +67,19 @@ export function registerApprovalsRoutes( ix: IInstantiationService, ): void { app.post( - '/v1/sessions/:session_id/approvals/:approval_id', + '/sessions/:session_id/approvals/:approval_id', { preHandler: [ validateParams(approvalParamsSchema), validateBody(approvalResolveRequestSchema), ], + schema: buildRouteSchema({ + description: 'Resolve an approval request', + tags: ['approvals'], + params: approvalParamsSchema, + body: approvalResolveRequestSchema, + response: { 200: approvalResolveResultSchema }, + }), }, async (req, reply) => { try { diff --git a/packages/daemon/src/routes/files.ts b/packages/daemon/src/routes/files.ts index fa1166e99..67b36e4da 100644 --- a/packages/daemon/src/routes/files.ts +++ b/packages/daemon/src/routes/files.ts @@ -1,11 +1,11 @@ /** - * `/v1/files*` REST routes (W12.2 / Chain 15 / P1.15). + * `/files*` REST routes (W12.2 / Chain 15 / P1.15). * * Three endpoints: * - * POST /v1/files multipart upload → FileMeta envelope - * GET /v1/files/{file_id} binary stream (NO envelope) or 40407 envelope - * DELETE /v1/files/{file_id} `{deleted: true}` envelope + * POST /files multipart upload → FileMeta envelope + * GET /files/{file_id} binary stream (NO envelope) or 40407 envelope + * DELETE /files/{file_id} `{deleted: true}` envelope * * **`@fastify/multipart` registration**: this module registers the * plugin against the captured Fastify instance on first call. The @@ -37,13 +37,16 @@ import multipart from '@fastify/multipart'; import { ErrorCode, deleteFileParamSchema, + deleteFileResponseSchema, getFileParamSchema, + uploadFileResponseSchema, } from '@moonshot-ai/protocol'; import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateParams } from '../middleware/validate.js'; import { DEFAULT_MAX_UPLOAD_BYTES, @@ -65,6 +68,7 @@ interface FilesRouteHost { register(plugin: unknown, opts?: unknown): unknown; post( path: string, + options: { schema?: Record }, handler: ( req: FastifyRequestLike, reply: FilesReply, @@ -72,7 +76,7 @@ interface FilesRouteHost { ): unknown; get( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: FastifyRequestLike, reply: FilesReply, @@ -80,7 +84,7 @@ interface FilesRouteHost { ): unknown; delete( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: FastifyRequestLike, reply: FilesReply, @@ -128,12 +132,19 @@ export function registerFilesRoutes( }, }); - // POST /v1/files ---------------------------------------------------- + // POST /files ---------------------------------------------------- // // `multipart/form-data` with required `file` field + optional `name` // / `expires_in_sec` fields. We stream the `file` directly into // `IFileStore.save` (no in-memory buffering). - app.post('/v1/files', async (req, reply) => { + app.post('/files', { + schema: buildRouteSchema({ + description: 'Upload a file', + tags: ['files'], + consumes: ['multipart/form-data'], + response: { 200: uploadFileResponseSchema }, + }), + }, async (req, reply) => { try { if (!req.file) { reply.send( @@ -211,14 +222,21 @@ export function registerFilesRoutes( } }); - // GET /v1/files/{file_id} ------------------------------------------- + // GET /files/{file_id} ------------------------------------------- // // Architectural exception: the ONLY endpoint that does not use the // envelope on success (REST.md §3.10 line 691). 404 still returns a // JSON envelope; clients distinguish by `Content-Type`. app.get( - '/v1/files/:file_id', - { preHandler: [validateParams(getFileParamSchema)] }, + '/files/:file_id', + { + preHandler: [validateParams(getFileParamSchema)], + schema: buildRouteSchema({ + description: 'Download a file by ID', + tags: ['files'], + params: getFileParamSchema, + }), + }, async (req, reply) => { try { const { file_id } = req.params as { file_id: string }; @@ -246,10 +264,18 @@ export function registerFilesRoutes( }, ); - // DELETE /v1/files/{file_id} ---------------------------------------- + // DELETE /files/{file_id} ---------------------------------------- app.delete( - '/v1/files/:file_id', - { preHandler: [validateParams(deleteFileParamSchema)] }, + '/files/:file_id', + { + preHandler: [validateParams(deleteFileParamSchema)], + schema: buildRouteSchema({ + description: 'Delete a file by ID', + tags: ['files'], + params: deleteFileParamSchema, + response: { 200: deleteFileResponseSchema }, + }), + }, async (req, reply) => { try { const { file_id } = req.params as { file_id: string }; diff --git a/packages/daemon/src/routes/fs.ts b/packages/daemon/src/routes/fs.ts index 86109a307..ac5ad354a 100644 --- a/packages/daemon/src/routes/fs.ts +++ b/packages/daemon/src/routes/fs.ts @@ -1,10 +1,10 @@ /** - * `/v1/sessions/{sid}/fs:*` REST routes (W10 / Chains 9 + 10). + * `/sessions/{sid}/fs:*` REST routes (W10 / Chains 9 + 10). * * Endpoints landed in W10.1 (Chain 9): * - * POST /v1/sessions/{sid}/fs:list → FsListResponse - * POST /v1/sessions/{sid}/fs:read → FsReadResponse + * POST /sessions/{sid}/fs:list → FsListResponse + * POST /sessions/{sid}/fs:read → FsReadResponse * * W10.2 (Chain 10) extends this module with `:list_many`, `:stat`, and * `:stat_many` — same dispatch shape, different per-action handlers. @@ -64,6 +64,7 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateParams } from '../middleware/validate.js'; import { FsIsBinaryError, @@ -86,7 +87,7 @@ import { FsPathEscapesError } from '../services/fs-path-safety.js'; interface FsRouteHost { post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -94,7 +95,7 @@ interface FsRouteHost { ): unknown; get( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; @@ -144,9 +145,9 @@ export function registerFsRoutes( app: FsRouteHost, ix: IInstantiationService, ): void { - // POST /v1/sessions/{sid}/fs: + // POST /sessions/{sid}/fs: // - // Fastify path: `/v1/sessions/:session_id/:tail`. We capture the FULL + // Fastify path: `/sessions/:session_id/:tail`. We capture the FULL // final segment (`fs:list`, `fs:read`, ...) and split locally — Fastify's // `::` colon-escape collapses both colons into a literal `:` STATIC // path, NOT a literal `:` followed by a param, so we can't isolate the @@ -156,8 +157,17 @@ export function registerFsRoutes( // this route — sibling routes (`messages`, `prompts`, `tasks`, etc.) // claim the bare-segment paths. app.post( - '/v1/sessions/:session_id/:tail', - { preHandler: [validateParams(sessionIdAndTailParamSchema)] }, + '/sessions/:session_id/:tail', + { + preHandler: [validateParams(sessionIdAndTailParamSchema)], + schema: buildRouteSchema({ + description: + 'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, search, grep, git_status.', + tags: ['fs'], + operationId: 'fsAction', + params: sessionIdAndTailParamSchema, + }), + }, async (req, reply) => { const { session_id, tail } = req.params as { session_id: string; @@ -230,13 +240,13 @@ export function registerFsRoutes( ); // --------------------------------------------------------------------- - // GET /v1/sessions/{sid}/fs/* — Chain 13 (W11.3) streaming download. + // GET /sessions/{sid}/fs/* — Chain 13 (W11.3) streaming download. // // **Architectural exception**: REST.md §3.9 line 558 — the ONLY GET in // the daemon's REST surface with a verb in the URL (`:download` // suffix). HTTP semantics dictate GET for downloads. // - // URL pattern (REST.md §3.9 line 562): `GET /v1/sessions/{sid}/fs/{path}:download` + // URL pattern (REST.md §3.9 line 562): `GET /sessions/{sid}/fs/{path}:download` // `{path}` retains forward slashes; Fastify's `*` wildcard captures // everything after `fs/`. We then peel off the `:download` action // suffix and validate the path through `IFsService.resolveDownload`. @@ -255,8 +265,15 @@ export function registerFsRoutes( // documented one-way escape hatch per REST.md §3.9 line 571). // --------------------------------------------------------------------- app.get( - '/v1/sessions/:session_id/fs/*', - { preHandler: [] }, + '/sessions/:session_id/fs/*', + { + preHandler: [], + schema: buildRouteSchema({ + description: 'Download a file from the session workspace', + tags: ['fs'], + operationId: 'downloadFile', + }), + }, async (req, reply) => { const { session_id, '*': wildcard } = req.params as { session_id: string; diff --git a/packages/daemon/src/routes/messages.ts b/packages/daemon/src/routes/messages.ts index b8c6ab2a6..a2e0e0fdc 100644 --- a/packages/daemon/src/routes/messages.ts +++ b/packages/daemon/src/routes/messages.ts @@ -1,10 +1,10 @@ /** - * `/v1/sessions/{session_id}/messages*` REST routes (Chain 3 / P1.3, W7.1). + * `/sessions/{session_id}/messages*` REST routes (Chain 3 / P1.3, W7.1). * * 2 endpoints (REST.md §3.4): * - * GET /v1/sessions/{sid}/messages query: ListMessages data: Page - * GET /v1/sessions/{sid}/messages/{mid} - data: Message + * GET /sessions/{sid}/messages query: ListMessages data: Page + * GET /sessions/{sid}/messages/{mid} - data: Message * * Validation: query is coerced + checked by `messagesListQueryCoercion` * (`z.coerce.number()` for `page_size`, mutex re-asserted via superRefine, @@ -26,6 +26,8 @@ import { ErrorCode, + getMessageResponseSchema, + listMessagesResponseSchema, messageRoleSchema, type ListMessagesQuery, } from '@moonshot-ai/protocol'; @@ -39,6 +41,7 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateParams, validateQuery } from '../middleware/validate.js'; /** @@ -48,7 +51,7 @@ import { validateParams, validateQuery } from '../middleware/validate.js'; interface MessageRouteHost { get( path: string, - options: { preHandler: unknown[] } | undefined, + options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( req: { id: string; query: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -98,14 +101,21 @@ export function registerMessagesRoutes( app: MessageRouteHost, ix: IInstantiationService, ): void { - // GET /v1/sessions/{session_id}/messages -------------------------------- + // GET /sessions/{session_id}/messages -------------------------------- app.get( - '/v1/sessions/:session_id/messages', + '/sessions/:session_id/messages', { preHandler: [ validateParams(sessionIdParamSchema), validateQuery(messagesListQueryCoercion), ], + schema: buildRouteSchema({ + description: 'List messages for a session', + tags: ['messages'], + params: sessionIdParamSchema, + querystring: messagesListQueryCoercion, + response: { 200: listMessagesResponseSchema }, + }), }, async (req, reply) => { try { @@ -121,10 +131,18 @@ export function registerMessagesRoutes( }, ); - // GET /v1/sessions/{session_id}/messages/{message_id} ------------------- + // GET /sessions/{session_id}/messages/{message_id} ------------------- app.get( - '/v1/sessions/:session_id/messages/:message_id', - { preHandler: [validateParams(messageIdParamSchema)] }, + '/sessions/:session_id/messages/:message_id', + { + preHandler: [validateParams(messageIdParamSchema)], + schema: buildRouteSchema({ + description: 'Get a message by ID', + tags: ['messages'], + params: messageIdParamSchema, + response: { 200: getMessageResponseSchema }, + }), + }, async (req, reply) => { try { const { session_id, message_id } = req.params as { diff --git a/packages/daemon/src/routes/meta.ts b/packages/daemon/src/routes/meta.ts index 30af86524..bda70251e 100644 --- a/packages/daemon/src/routes/meta.ts +++ b/packages/daemon/src/routes/meta.ts @@ -1,5 +1,5 @@ /** - * `GET /v1/meta` route handler — Chain 1 / P1.1. + * `GET /meta` route handler — Chain 1 / P1.1. * * Returns the daemon's `daemon_version`, declared `capabilities` literal map, * a per-process `server_id` (ULID minted at boot — reset on every restart so @@ -19,7 +19,10 @@ * `getDaemonVersion()` — no indirection through services or agent-core. */ +import { metaResponseSchema } from '@moonshot-ai/protocol'; + import { okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import type { MetaResponse } from '@moonshot-ai/protocol'; /** @@ -32,6 +35,7 @@ import type { MetaResponse } from '@moonshot-ai/protocol'; interface RouteHost { get( path: string, + options: { schema?: Record }, handler: ( req: { id: string }, reply: { send(payload: unknown): void }, @@ -64,7 +68,17 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void started_at: opts.startedAt, }); - app.get('/v1/meta', async (req, reply) => { - reply.send(okEnvelope(data, req.id)); - }); + app.get( + '/meta', + { + schema: buildRouteSchema({ + description: 'Get daemon metadata', + tags: ['meta'], + response: { 200: metaResponseSchema }, + }), + }, + async (req, reply) => { + reply.send(okEnvelope(data, req.id)); + }, + ); } diff --git a/packages/daemon/src/routes/prompts.ts b/packages/daemon/src/routes/prompts.ts index 5ff0db5b1..e7756e6bf 100644 --- a/packages/daemon/src/routes/prompts.ts +++ b/packages/daemon/src/routes/prompts.ts @@ -1,11 +1,11 @@ /** - * `/v1/sessions/{sid}/prompts*` REST routes (Chain 4 / P1.4, W7.2; + * `/sessions/{sid}/prompts*` REST routes (Chain 4 / P1.4, W7.2; * abort handler extended in Chain 4b / W7.3). * * 2 endpoints (REST.md §3.5): * - * POST /v1/sessions/{sid}/prompts body: PromptSubmission data: PromptSubmitResult - * POST /v1/sessions/{sid}/prompts/{pid}:abort body: empty data: { aborted, at_seq? } + * POST /sessions/{sid}/prompts body: PromptSubmission data: PromptSubmitResult + * POST /sessions/{sid}/prompts/{pid}:abort body: empty data: { aborted, at_seq? } * * **Error mapping**: * - `SessionNotFoundError` → 40401 @@ -26,7 +26,9 @@ import { ErrorCode, + promptAbortResponseSchema, promptSubmissionSchema, + promptSubmitResultSchema, type PromptSubmission, } from '@moonshot-ai/protocol'; import { @@ -41,13 +43,14 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateBody, validateParams } from '../middleware/validate.js'; import { parseActionSuffix } from './action-suffix.js'; interface PromptRouteHost { post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -67,14 +70,21 @@ export function registerPromptsRoutes( app: PromptRouteHost, ix: IInstantiationService, ): void { - // POST /v1/sessions/{session_id}/prompts --------------------------------- + // POST /sessions/{session_id}/prompts --------------------------------- app.post( - '/v1/sessions/:session_id/prompts', + '/sessions/:session_id/prompts', { preHandler: [ validateParams(sessionIdParamSchema), validateBody(promptSubmissionSchema), ], + schema: buildRouteSchema({ + description: 'Submit a prompt to a session', + tags: ['prompts'], + params: sessionIdParamSchema, + body: promptSubmissionSchema, + response: { 200: promptSubmitResultSchema }, + }), }, async (req, reply) => { try { @@ -90,7 +100,7 @@ export function registerPromptsRoutes( }, ); - // POST /v1/sessions/{session_id}/prompts/{prompt_id}:abort --------------- + // POST /sessions/{session_id}/prompts/{prompt_id}:abort --------------- // Fastify's path syntax doesn't allow a literal `:abort` suffix on a // colon-prefixed param (`:prompt_id:abort` parses ambiguously). REST.md // §3.5 specifies the action-suffix syntax `{prompt_id}:abort`. We register @@ -98,8 +108,16 @@ export function registerPromptsRoutes( // with `:abort` via the shared `parseActionSuffix` helper (4th call site // shared since W9.1). app.post( - '/v1/sessions/:session_id/prompts/:tail', - { preHandler: [] }, + '/sessions/:session_id/prompts/:tail', + { + preHandler: [], + schema: buildRouteSchema({ + description: 'Abort a running prompt', + tags: ['prompts'], + operationId: 'abortPrompt', + response: { 200: promptAbortResponseSchema }, + }), + }, async (req, reply) => { try { const { session_id, tail } = req.params as { diff --git a/packages/daemon/src/routes/questions.ts b/packages/daemon/src/routes/questions.ts index 3c237bfd9..5e1b57f71 100644 --- a/packages/daemon/src/routes/questions.ts +++ b/packages/daemon/src/routes/questions.ts @@ -1,5 +1,5 @@ /** - * `/v1/sessions/{sid}/questions/{qid}*` REST routes (Chain 6 / P1.6, W8.2). + * `/sessions/{sid}/questions/{qid}*` REST routes (Chain 6 / P1.6, W8.2). * * 2 endpoints (REST.md §3.6), both serviced by a SINGLE Fastify route handler * because Fastify cannot disambiguate `:question_id` vs `:question_id:dismiss` @@ -7,11 +7,11 @@ * sole tail; questions has both a bare resolve and a `:dismiss` so we MUST * use the tail-parser for both): * - * POST /v1/sessions/{sid}/questions/{qid} (resolve) + * POST /sessions/{sid}/questions/{qid} (resolve) * body: QuestionResponse (5-kind answers map + method?+ note?) * data: { resolved: true, resolved_at } * - * POST /v1/sessions/{sid}/questions/{qid}:dismiss (first-class + * POST /sessions/{sid}/questions/{qid}:dismiss (first-class * body: empty dismiss) * envelope: code: 40909, data: { dismissed: true, dismissed_at } * @@ -33,6 +33,7 @@ import { ErrorCode, questionResolveRequestSchema, + questionResolveResultSchema, type QuestionResolveRequest, type QuestionResolveResult, } from '@moonshot-ai/protocol'; @@ -45,6 +46,7 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateParams } from '../middleware/validate.js'; import { parseActionSuffix } from './action-suffix.js'; import { DaemonQuestionBroker } from '../services/question-broker.js'; @@ -52,7 +54,7 @@ import { DaemonQuestionBroker } from '../services/question-broker.js'; interface QuestionRouteHost { post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -71,9 +73,17 @@ export function registerQuestionsRoutes( ): void { // Single route capturing both the resolve and dismiss paths via `:tail`. app.post( - '/v1/sessions/:session_id/questions/:tail', + '/sessions/:session_id/questions/:tail', { preHandler: [validateParams(tailParamsSchema)], + schema: buildRouteSchema({ + description: 'Resolve or dismiss a question', + tags: ['questions'], + operationId: 'resolveOrDismissQuestion', + params: tailParamsSchema, + body: questionResolveRequestSchema, + response: { 200: questionResolveResultSchema }, + }), }, async (req, reply) => { const { tail } = req.params as { session_id: string; tail: string }; diff --git a/packages/daemon/src/routes/sessions.ts b/packages/daemon/src/routes/sessions.ts index 8acde984c..939da32f5 100644 --- a/packages/daemon/src/routes/sessions.ts +++ b/packages/daemon/src/routes/sessions.ts @@ -1,13 +1,13 @@ /** - * `/v1/sessions/*` REST routes (Chain 2 / P1.2). + * `/sessions/*` REST routes (Chain 2 / P1.2). * * 5 endpoints (REST.md §3.3): * - * POST /v1/sessions body: SessionCreate data: Session - * GET /v1/sessions query: ListSessions data: Page - * GET /v1/sessions/{id} - data: Session - * PATCH /v1/sessions/{id} body: SessionUpdate data: Session - * DELETE /v1/sessions/{id} - data: { deleted: true } + * POST /sessions body: SessionCreate data: Session + * GET /sessions query: ListSessions data: Page + * GET /sessions/{id} - data: Session + * PATCH /sessions/{id} body: SessionUpdate data: Session + * DELETE /sessions/{id} - data: { deleted: true } * * Each handler validates input with the Zod `validateBody` / `validateQuery` * preHandler (40001 on failure with `details` path), invokes @@ -29,6 +29,9 @@ import { ErrorCode, createSessionRequestSchema, + deleteSessionResponseSchema, + pageResponseSchema, + sessionSchema, sessionStatusSchema, updateSessionRequestSchema, type SessionCreate, @@ -44,6 +47,7 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateBody, validateParams, validateQuery } from '../middleware/validate.js'; /** @@ -53,7 +57,7 @@ import { validateBody, validateParams, validateQuery } from '../middleware/valid interface SessionRouteHost { post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -61,7 +65,7 @@ interface SessionRouteHost { ): unknown; get( path: string, - options: { preHandler: unknown[] } | undefined, + options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( req: { id: string; query: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -70,7 +74,7 @@ interface SessionRouteHost { // Fastify exposes `patch` and `delete` as instance methods. patch( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -78,7 +82,7 @@ interface SessionRouteHost { ): unknown; delete( path: string, - options: { preHandler: unknown[] } | undefined, + options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( req: { id: string; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -126,10 +130,18 @@ export function registerSessionsRoutes( app: SessionRouteHost, ix: IInstantiationService, ): void { - // POST /v1/sessions ------------------------------------------------------ + // POST /sessions ------------------------------------------------------ app.post( - '/v1/sessions', - { preHandler: [validateBody(createSessionRequestSchema)] }, + '/sessions', + { + preHandler: [validateBody(createSessionRequestSchema)], + schema: buildRouteSchema({ + description: 'Create a new session', + tags: ['sessions'], + body: createSessionRequestSchema, + response: { 200: sessionSchema }, + }), + }, async (req, reply) => { try { const body = req.body as SessionCreate; @@ -141,10 +153,18 @@ export function registerSessionsRoutes( }, ); - // GET /v1/sessions ------------------------------------------------------- + // GET /sessions ------------------------------------------------------- app.get( - '/v1/sessions', - { preHandler: [validateQuery(sessionsListQueryCoercion)] }, + '/sessions', + { + preHandler: [validateQuery(sessionsListQueryCoercion)], + schema: buildRouteSchema({ + description: 'List sessions', + tags: ['sessions'], + querystring: sessionsListQueryCoercion, + response: { 200: pageResponseSchema(sessionSchema) }, + }), + }, async (req, reply) => { try { const query = req.query as SessionListQuery; @@ -156,10 +176,18 @@ export function registerSessionsRoutes( }, ); - // GET /v1/sessions/{session_id} ------------------------------------------ + // GET /sessions/{session_id} ------------------------------------------ app.get( - '/v1/sessions/:session_id', - { preHandler: [validateParams(sessionIdParamSchema)] }, + '/sessions/:session_id', + { + preHandler: [validateParams(sessionIdParamSchema)], + schema: buildRouteSchema({ + description: 'Get a session by ID', + tags: ['sessions'], + params: sessionIdParamSchema, + response: { 200: sessionSchema }, + }), + }, async (req, reply) => { try { const { session_id } = req.params as { session_id: string }; @@ -171,14 +199,21 @@ export function registerSessionsRoutes( }, ); - // PATCH /v1/sessions/{session_id} ---------------------------------------- + // PATCH /sessions/{session_id} ---------------------------------------- app.patch( - '/v1/sessions/:session_id', + '/sessions/:session_id', { preHandler: [ validateParams(sessionIdParamSchema), validateBody(updateSessionRequestSchema), ], + schema: buildRouteSchema({ + description: 'Update a session', + tags: ['sessions'], + params: sessionIdParamSchema, + body: updateSessionRequestSchema, + response: { 200: sessionSchema }, + }), }, async (req, reply) => { try { @@ -194,10 +229,18 @@ export function registerSessionsRoutes( }, ); - // DELETE /v1/sessions/{session_id} --------------------------------------- + // DELETE /sessions/{session_id} --------------------------------------- app.delete( - '/v1/sessions/:session_id', - { preHandler: [validateParams(sessionIdParamSchema)] }, + '/sessions/:session_id', + { + preHandler: [validateParams(sessionIdParamSchema)], + schema: buildRouteSchema({ + description: 'Delete a session', + tags: ['sessions'], + params: sessionIdParamSchema, + response: { 200: deleteSessionResponseSchema }, + }), + }, async (req, reply) => { try { const { session_id } = req.params as { session_id: string }; diff --git a/packages/daemon/src/routes/tasks.ts b/packages/daemon/src/routes/tasks.ts index 205782f69..d35ac5500 100644 --- a/packages/daemon/src/routes/tasks.ts +++ b/packages/daemon/src/routes/tasks.ts @@ -1,12 +1,12 @@ /** - * `/v1/sessions/{sid}/tasks*` REST routes (Chain 8 / P1.8, W9.2). + * `/sessions/{sid}/tasks*` REST routes (Chain 8 / P1.8, W9.2). * * 3 endpoints (REST.md §3.7): * - * GET /v1/sessions/{sid}/tasks query: {status?} data: {items[]} - * GET /v1/sessions/{sid}/tasks/{tid} query: {with_output?, + * GET /sessions/{sid}/tasks query: {status?} data: {items[]} + * GET /sessions/{sid}/tasks/{tid} query: {with_output?, * output_bytes?} data: BackgroundTask - * POST /v1/sessions/{sid}/tasks/{tid}:cancel body: empty data: {cancelled:true} + * POST /sessions/{sid}/tasks/{tid}:cancel body: empty data: {cancelled:true} * * **Error mapping**: * - `SessionNotFoundError` → envelope `code: 40401` @@ -24,8 +24,11 @@ import { ErrorCode, + cancelTaskResultSchema, getTaskQuerySchema, + getTaskResponseSchema, listTasksQuerySchema, + listTasksResponseSchema, type ListTasksQuery, } from '@moonshot-ai/protocol'; import { @@ -39,13 +42,14 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateParams, validateQuery } from '../middleware/validate.js'; import { parseActionSuffix } from './action-suffix.js'; interface TasksRouteHost { get( path: string, - options: { preHandler: unknown[] } | undefined, + options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( req: { id: string; query: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -53,7 +57,7 @@ interface TasksRouteHost { ): unknown; post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -74,14 +78,21 @@ export function registerTasksRoutes( app: TasksRouteHost, ix: IInstantiationService, ): void { - // GET /v1/sessions/{session_id}/tasks ------------------------------------ + // GET /sessions/{session_id}/tasks ------------------------------------ app.get( - '/v1/sessions/:session_id/tasks', + '/sessions/:session_id/tasks', { preHandler: [ validateParams(sessionIdParamSchema), validateQuery(listTasksQuerySchema), ], + schema: buildRouteSchema({ + description: 'List background tasks for a session', + tags: ['tasks'], + params: sessionIdParamSchema, + querystring: listTasksQuerySchema, + response: { 200: listTasksResponseSchema }, + }), }, async (req, reply) => { try { @@ -97,14 +108,21 @@ export function registerTasksRoutes( }, ); - // GET /v1/sessions/{session_id}/tasks/{task_id} -------------------------- + // GET /sessions/{session_id}/tasks/{task_id} -------------------------- app.get( - '/v1/sessions/:session_id/tasks/:task_id', + '/sessions/:session_id/tasks/:task_id', { preHandler: [ validateParams(sessionAndTaskIdParamSchema), validateQuery(getTaskQuerySchema), ], + schema: buildRouteSchema({ + description: 'Get a background task by ID', + tags: ['tasks'], + params: sessionAndTaskIdParamSchema, + querystring: getTaskQuerySchema, + response: { 200: getTaskResponseSchema }, + }), }, async (req, reply) => { try { @@ -122,14 +140,22 @@ export function registerTasksRoutes( }, ); - // POST /v1/sessions/{session_id}/tasks/{task_id}:cancel ------------------ + // POST /sessions/{session_id}/tasks/{task_id}:cancel ------------------ // // Fastify routes the GET `/:task_id` and the POST `/:tail` against the // same Trie prefix. Using `/:task_id:cancel`-style would collide; we // capture `:tail` and demand the `:cancel` suffix via the shared parser. app.post( - '/v1/sessions/:session_id/tasks/:tail', - { preHandler: [] }, + '/sessions/:session_id/tasks/:tail', + { + preHandler: [], + schema: buildRouteSchema({ + description: 'Cancel a background task', + tags: ['tasks'], + operationId: 'cancelTask', + response: { 200: cancelTaskResultSchema }, + }), + }, async (req, reply) => { try { const { session_id, tail } = req.params as { @@ -149,7 +175,7 @@ export function registerTasksRoutes( } if (parsed.kind === 'bare') { // POST without `:cancel` is not a defined action; the bare GET - // form serves `/v1/.../tasks/{tid}`. + // form serves `/.../tasks/{tid}`. reply.send( errEnvelope( ErrorCode.VALIDATION_FAILED, diff --git a/packages/daemon/src/routes/tools.ts b/packages/daemon/src/routes/tools.ts index e40212733..49df132bf 100644 --- a/packages/daemon/src/routes/tools.ts +++ b/packages/daemon/src/routes/tools.ts @@ -1,11 +1,11 @@ /** - * `/v1/tools` + `/v1/mcp/servers*` REST routes (Chain 7 / P1.7, W9.1). + * `/tools` + `/mcp/servers*` REST routes (Chain 7 / P1.7, W9.1). * * 3 endpoints (REST.md §3.8): * - * GET /v1/tools query: {session_id?} data: {tools: ToolDescriptor[]} - * GET /v1/mcp/servers - data: {servers: McpServer[]} - * POST /v1/mcp/servers/{mcp_server_id}:restart body: empty data: {restarting: true} + * GET /tools query: {session_id?} data: {tools: ToolDescriptor[]} + * GET /mcp/servers - data: {servers: McpServer[]} + * POST /mcp/servers/{mcp_server_id}:restart body: empty data: {restarting: true} * * **Error mapping**: * - `McpServerNotFoundError` → envelope `code: 40408 mcp.server_not_found`. @@ -21,7 +21,10 @@ import { ErrorCode, + listMcpServersResponseSchema, listToolsQuerySchema, + listToolsResponseSchema, + restartMcpServerResultSchema, type ListToolsQuery, } from '@moonshot-ai/protocol'; import { IMcpService, IToolService, McpServerNotFoundError } from '@moonshot-ai/services'; @@ -30,13 +33,14 @@ import { z } from 'zod'; import type { IInstantiationService } from '@moonshot-ai/agent-core'; import { errEnvelope, okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; import { validateQuery } from '../middleware/validate.js'; import { parseActionSuffix } from './action-suffix.js'; interface ToolsRouteHost { get( path: string, - options: { preHandler: unknown[] } | undefined, + options: { preHandler: unknown[]; schema?: Record } | undefined, handler: ( req: { id: string; query: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -44,7 +48,7 @@ interface ToolsRouteHost { ): unknown; post( path: string, - options: { preHandler: unknown[] }, + options: { preHandler: unknown[]; schema?: Record }, handler: ( req: { id: string; body: unknown; params: unknown }, reply: { send(payload: unknown): unknown }, @@ -56,10 +60,18 @@ export function registerToolsRoutes( app: ToolsRouteHost, ix: IInstantiationService, ): void { - // GET /v1/tools ---------------------------------------------------------- + // GET /tools ---------------------------------------------------------- app.get( - '/v1/tools', - { preHandler: [validateQuery(listToolsQuerySchema)] }, + '/tools', + { + preHandler: [validateQuery(listToolsQuerySchema)], + schema: buildRouteSchema({ + description: 'List available tools', + tags: ['tools'], + querystring: listToolsQuerySchema, + response: { 200: listToolsResponseSchema }, + }), + }, async (req, reply) => { try { const query = req.query as ListToolsQuery; @@ -73,8 +85,15 @@ export function registerToolsRoutes( }, ); - // GET /v1/mcp/servers ---------------------------------------------------- - app.get('/v1/mcp/servers', { preHandler: [] }, async (req, reply) => { + // GET /mcp/servers ---------------------------------------------------- + app.get('/mcp/servers', { + preHandler: [], + schema: buildRouteSchema({ + description: 'List configured MCP servers', + tags: ['tools'], + response: { 200: listMcpServersResponseSchema }, + }), + }, async (req, reply) => { try { const servers = await ix.invokeFunction((a) => a.get(IMcpService).list()); reply.send(okEnvelope({ servers }, req.id)); @@ -83,10 +102,18 @@ export function registerToolsRoutes( } }); - // POST /v1/mcp/servers/{mcp_server_id}:restart --------------------------- + // POST /mcp/servers/{mcp_server_id}:restart --------------------------- app.post( - '/v1/mcp/servers/:tail', - { preHandler: [] }, + '/mcp/servers/:tail', + { + preHandler: [], + schema: buildRouteSchema({ + description: 'Restart an MCP server by ID', + tags: ['tools'], + operationId: 'restartMcpServer', + response: { 200: restartMcpServerResultSchema }, + }), + }, async (req, reply) => { try { const { tail } = req.params as { tail: string }; @@ -102,7 +129,7 @@ export function registerToolsRoutes( return; } if (parsed.kind === 'bare') { - // No bare form for /v1/mcp/servers/{id} — only :restart. + // No bare form for /mcp/servers/{id} — only :restart. reply.send( errEnvelope( ErrorCode.VALIDATION_FAILED, diff --git a/packages/daemon/src/services/file-store.ts b/packages/daemon/src/services/file-store.ts index a13bf717b..10ec88669 100644 --- a/packages/daemon/src/services/file-store.ts +++ b/packages/daemon/src/services/file-store.ts @@ -1,11 +1,12 @@ /** * `IFileStore` — daemon-OWN files store (W12.2 / Chain 15, P1.15). * - * **Responsibility**: persist uploaded blobs under `~/.kimi/files/`, - * maintain a JSON index of `FileMeta` records, and serve them back by - * `file_id` for download / delete. Streams writes (no in-memory - * buffering) and enforces the 50MB size cap DURING the streaming write - * — abort on overrun, then delete the partial blob. + * **Responsibility**: persist uploaded blobs under `/files/` + * (defaults to `~/.kimi-code/files/`; overridable via `KIMI_CODE_HOME` env + * or `options.homeDir`), maintain a JSON index of `FileMeta` records, and + * serve them back by `file_id` for download / delete. Streams writes (no + * in-memory buffering) and enforces the 50MB size cap DURING the streaming + * write — abort on overrun, then delete the partial blob. * * **Daemon-OWN distinction**: like `IFsService` / `IFsWatcher`, the * store is NOT a thin wrapper around an `IHarnessBridge` call. @@ -49,7 +50,6 @@ */ import { createWriteStream, promises as fsp } from 'node:fs'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import { pipeline } from 'node:stream/promises'; import type { Readable } from 'node:stream'; @@ -59,6 +59,7 @@ import { ulid } from 'ulid'; import { Disposable, createDecorator, + resolveKimiHome, } from '@moonshot-ai/agent-core'; import type { FileMeta } from '@moonshot-ai/protocol'; @@ -157,8 +158,8 @@ export const IFileStore = createDecorator('IFileStore'); export interface FileStoreOptions { /** * Base directory containing the `files/` subdir + `index.json`. In - * production this is `/.kimi` or the OS home; - * tests pass a tmpdir under `~/.kimi-test-...`. + * production this is `` (defaults to `~/.kimi-code`); + * tests pass a tmpdir under `~/.kimi-code-test-...`. */ homeDir?: string; /** Override the 50 MB cap (tests set this to something tiny). */ @@ -186,7 +187,7 @@ export class FileStoreImpl extends Disposable implements IFileStore { @ILogger private readonly logger: ILogger, ) { super(); - const home = options.homeDir ?? join(homedir(), '.kimi'); + const home = options.homeDir ?? resolveKimiHome(); this.baseDir = join(home, 'files'); this.indexPath = join(this.baseDir, 'index.json'); this.maxUploadBytes = options.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES; diff --git a/packages/daemon/src/services/fs-path-safety.ts b/packages/daemon/src/services/fs-path-safety.ts index c84085fb1..804e3a5bb 100644 --- a/packages/daemon/src/services/fs-path-safety.ts +++ b/packages/daemon/src/services/fs-path-safety.ts @@ -2,7 +2,7 @@ * Path-safety primitives (REST.md §4.4) — the central correctness piece of * Chain 9 / W10.1. * - * Every `path` flowing into `/v1/sessions/{sid}/fs:*` MUST pass through + * Every `path` flowing into `/sessions/{sid}/fs:*` MUST pass through * `resolveSafePath(cwd, input)` BEFORE being touched by Node `fs.promises`. * Skipping the guard is a path-traversal bug. * diff --git a/packages/daemon/src/services/rest-gateway.ts b/packages/daemon/src/services/rest-gateway.ts index 730ec383d..89a22c304 100644 --- a/packages/daemon/src/services/rest-gateway.ts +++ b/packages/daemon/src/services/rest-gateway.ts @@ -30,7 +30,7 @@ import { Disposable, createDecorator } from '@moonshot-ai/agent-core'; * default generics that surfaces at the route-options level. * * W5.1: `server` (the raw Node `http.Server` Fastify wraps) is required so - * `WSGateway` can attach a typed `'upgrade'` handler for `/v1/ws` without + * `WSGateway` can attach a typed `'upgrade'` handler for `/api/v1/ws` without * pulling in `fastify-websocket`. Fastify exposes `app.server` after * `await app.ready()` (or after `listen()`); we add the typed property here * rather than widening to `any` — the anti-corruption discipline matters. diff --git a/packages/daemon/src/services/ws-gateway.ts b/packages/daemon/src/services/ws-gateway.ts index f64991d8e..d2957d2e1 100644 --- a/packages/daemon/src/services/ws-gateway.ts +++ b/packages/daemon/src/services/ws-gateway.ts @@ -2,7 +2,7 @@ * `IWSGateway` (W5.1 / P0.15) — WebSocket gateway. * * Owns a `ws.WebSocketServer` in `noServer` mode and attaches an `'upgrade'` - * handler to the Fastify-exposed raw `http.Server`. WS path is `/v1/ws` + * handler to the Fastify-exposed raw `http.Server`. WS path is `/api/v1/ws` * (WS.md §1.1). On upgrade we instantiate a `WsConnection`, register it in * `IConnectionRegistry`, and let the connection drive its own handshake + * heartbeat. @@ -21,7 +21,7 @@ * * Why `noServer` mode (not `port:`): Fastify already owns the HTTP server. * We share it — every WS handshake passes through Fastify's listener, gets - * intercepted by our `'upgrade'` handler, and only `/v1/ws` paths are + * intercepted by our `'upgrade'` handler, and only `/api/v1/ws` paths are * upgraded; other paths get an immediate `socket.destroy()` (defensive). * * `dispose()` is reverse-order safe: @@ -47,7 +47,7 @@ import { ISessionClientsService } from './session-clients.js'; import { WsConnection, type AbortHandler, type FsWatchHandler } from '../ws/connection.js'; /** WS endpoint path. WS.md §1.1. */ -export const WS_PATH = '/v1/ws'; +export const WS_PATH = '/api/v1/ws'; export interface IWSGateway { /** Number of currently-attached WS connections. */ @@ -127,7 +127,7 @@ export class WSGateway extends Disposable implements IWSGateway { } private onUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void { - // Restrict to `/v1/ws` (with optional query string per WS.md §1.1). + // Restrict to `/api/v1/ws` (with optional query string per WS.md §1.1). const url = req.url ?? ''; const path = url.split('?', 1)[0]; if (path !== WS_PATH) { diff --git a/packages/daemon/src/start.ts b/packages/daemon/src/start.ts index a073f7c67..3f69fce7a 100644 --- a/packages/daemon/src/start.ts +++ b/packages/daemon/src/start.ts @@ -26,6 +26,8 @@ import { } from '@moonshot-ai/services'; import { ErrorCode } from '@moonshot-ai/protocol'; import Fastify from 'fastify'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; import { ulid } from 'ulid'; import { promises as fspPromises } from 'node:fs'; import { sep as nodePathSep, relative as nodePathRelativeNative } from 'node:path'; @@ -168,19 +170,30 @@ export async function startDaemon(opts: DaemonStartOptions): Promise { - return reply.send(okEnvelope({ ok: true }, req.id)); - }); - - // W6.1 / Chain 1 — `/v1/meta`. Pure daemon-self info, no DI needed. Mint - // the per-process server_id + boot timestamp once at registration time - // (ROADMAP P1.1; REST.md §3.1). - const serverId = ulid(); - const startedAt = new Date().toISOString(); - registerMetaRoute(app, { - daemonVersion: getDaemonVersion(), - serverId, - startedAt, + // Register @fastify/swagger BEFORE routes so it can collect schema + // metadata via the `onRoute` hook. + const daemonVersion = getDaemonVersion(); + await app.register(swagger, { + openapi: { + info: { + title: 'Kimi Code Daemon API', + description: + 'REST API for the Kimi Code local daemon. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.', + version: daemonVersion, + }, + tags: [ + { name: 'meta', description: 'Daemon metadata' }, + { name: 'sessions', description: 'Session lifecycle' }, + { name: 'messages', description: 'Message history' }, + { name: 'prompts', description: 'Prompt submission & abort' }, + { name: 'approvals', description: 'Approval resolution' }, + { name: 'questions', description: 'Question resolution & dismiss' }, + { name: 'tools', description: 'Tool & MCP server management' }, + { name: 'tasks', description: 'Background tasks' }, + { name: 'fs', description: 'Filesystem operations' }, + { name: 'files', description: 'File upload & download' }, + ], + }, }); // Seed the container with the two pre-built instances. They become "live" @@ -206,69 +219,115 @@ export async function startDaemon(opts: DaemonStartOptions): Promise[0], ix); - // W7.1 / Chain 3 — register `/v1/sessions/{sid}/messages*` routes. Same - // wiring story: handlers resolve `IMessageService` per-request through ix. - registerMessagesRoutes(app as unknown as Parameters[0], ix); - // W7.2 / Chain 4 — register `/v1/sessions/{sid}/prompts*` routes (submit + - // abort). Submit triggers `bridge.rpc.prompt(...)` whose synchronous event - // stream lands on `IEventBus → WS broadcast`. Abort is the REST fallback - // for the WS abort message handled at `ws/connection.ts` (Chain 4b / W7.3). - registerPromptsRoutes(app as unknown as Parameters[0], ix); - // W8.1 / Chain 5 — register `/v1/sessions/{sid}/approvals/{aid}` route. - // The reverse-RPC path: agent-core → bridge → DaemonApprovalBroker → WS - // `event.approval.requested`. The REST handler completes the round-trip - // by calling `IApprovalBroker.resolve(aid, body)`. - registerApprovalsRoutes( - app as unknown as Parameters[0], - ix, - ); - // W8.2 / Chain 6 — register `/v1/sessions/{sid}/questions/{qid}*` routes. - // Same reverse-RPC pattern as approval, with first-class `:dismiss` - // (SCHEMAS §6.3) and 5-kind discriminated-union answer normalization - // (SCHEMAS §6.4) done by the services adapter at REST-boundary time. - registerQuestionsRoutes( - app as unknown as Parameters[0], - ix, - ); - // W9.1 / Chain 7 — register `/v1/tools` + `/v1/mcp/servers*` routes. - // Read-only `getTools` + `listMcpServers` plus `:restart` action — the 4th - // call site of the `:tail` action-suffix pattern, now extracted into - // `routes/action-suffix.ts`. - registerToolsRoutes( - app as unknown as Parameters[0], - ix, - ); - // W9.2 / Chain 8 — register `/v1/sessions/{sid}/tasks*` routes. - // list/get/cancel with 40406 + 40904 + the 5th `:tail` (action :cancel). - registerTasksRoutes( - app as unknown as Parameters[0], - ix, - ); - // W10 / Chains 9 + 10 — register `/v1/sessions/{sid}/fs:*` routes. - // POST :list / :read / :list_many / :stat / :stat_many — daemon-OWN - // service, no agent-core bridge involved. Path safety is the central - // correctness concern; every input path flows through - // `resolveSafePath(cwd, input)` before any Node fs syscall. - registerFsRoutes( - app as unknown as Parameters[0], - ix, - ); + // Register all REST routes under a single `/api/v1` prefix so individual + // route modules don't hardcode the version segment. + await app.register(async (apiV1) => { + apiV1.get('/healthz', { + schema: { + description: 'Health check', + response: { + 200: { + type: 'object', + properties: { + code: { type: 'number' }, + msg: { type: 'string' }, + data: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + }, + request_id: { type: 'string' }, + }, + }, + }, + }, + }, async (req, reply) => { + return reply.send(okEnvelope({ ok: true }, req.id)); + }); - // W12.2 / Chain 15 — register `/v1/files*` routes (upload / download / - // delete). Registers `@fastify/multipart` lazily on the captured - // Fastify instance. Anti-corruption invariant: handlers resolve - // `IFileStore` via the DI accessor; no SDK imports. - registerFilesRoutes( - app as unknown as Parameters[0], - ix, - ); + // W6.1 / Chain 1 — `/meta`. Pure daemon-self info, no DI needed. Mint + // the per-process server_id + boot timestamp once at registration time + // (ROADMAP P1.1; REST.md §3.1). + const serverId = ulid(); + const startedAt = new Date().toISOString(); + registerMetaRoute(apiV1, { + daemonVersion, + serverId, + startedAt, + }); + + // W6.2 / Chain 2 — register `/sessions/*` routes. The route module + // captures `ix` by reference; per-request `accessor.get(ISessionService)` + // dispatches against whatever's in the container at that moment. We + // populate ISessionService below; by the time the first request lands the + // container is fully wired (we await app.ready() + bridge.ready() before + // listen() opens the socket). + registerSessionsRoutes(apiV1 as unknown as Parameters[0], ix); + // W7.1 / Chain 3 — register `/sessions/{sid}/messages*` routes. Same + // wiring story: handlers resolve `IMessageService` per-request through ix. + registerMessagesRoutes(apiV1 as unknown as Parameters[0], ix); + // W7.2 / Chain 4 — register `/sessions/{sid}/prompts*` routes (submit + + // abort). Submit triggers `bridge.rpc.prompt(...)` whose synchronous event + // stream lands on `IEventBus → WS broadcast`. Abort is the REST fallback + // for the WS abort message handled at `ws/connection.ts` (Chain 4b / W7.3). + registerPromptsRoutes(apiV1 as unknown as Parameters[0], ix); + // W8.1 / Chain 5 — register `/sessions/{sid}/approvals/{aid}` route. + // The reverse-RPC path: agent-core → bridge → DaemonApprovalBroker → WS + // `event.approval.requested`. The REST handler completes the round-trip + // by calling `IApprovalBroker.resolve(aid, body)`. + registerApprovalsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + // W8.2 / Chain 6 — register `/sessions/{sid}/questions/{qid}*` routes. + // Same reverse-RPC pattern as approval, with first-class `:dismiss` + // (SCHEMAS §6.3) and 5-kind discriminated-union answer normalization + // (SCHEMAS §6.4) done by the services adapter at REST-boundary time. + registerQuestionsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + // W9.1 / Chain 7 — register `/tools` + `/mcp/servers*` routes. + // Read-only `getTools` + `listMcpServers` plus `:restart` action — the 4th + // call site of the `:tail` action-suffix pattern, now extracted into + // `routes/action-suffix.ts`. + registerToolsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + // W9.2 / Chain 8 — register `/sessions/{sid}/tasks*` routes. + // list/get/cancel with 40406 + 40904 + the 5th `:tail` (action :cancel). + registerTasksRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + // W10 / Chains 9 + 10 — register `/sessions/{sid}/fs:*` routes. + // POST :list / :read / :list_many / :stat / :stat_many — daemon-OWN + // service, no agent-core bridge involved. Path safety is the central + // correctness concern; every input path flows through + // `resolveSafePath(cwd, input)` before any Node fs syscall. + registerFsRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + + // W12.2 / Chain 15 — register `/files*` routes (upload / download / + // delete). Registers `@fastify/multipart` lazily on the captured + // Fastify instance. Anti-corruption invariant: handlers resolve + // `IFileStore` via the DI accessor; no SDK imports. + registerFilesRoutes( + apiV1 as unknown as Parameters[0], + ix, + ); + }, { prefix: '/api/v1' }); + + // Register Swagger UI AFTER all routes are collected. + await app.register(swaggerUi, { + routePrefix: '/documentation', + uiConfig: { + docExpansion: 'list', + deepLinking: true, + }, + }); // Fastify lazily creates the raw `http.Server`. `WSGateway` (W5.1) needs // `app.server` to attach an `'upgrade'` listener — `app.ready()` populates @@ -584,7 +643,7 @@ export async function startDaemon(opts: DaemonStartOptions): Promise(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -120,7 +120,7 @@ async function openSubscriber( ws: WebSocket; received: Record[]; }> { - const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { const sock = new WebSocket(wsUrl); @@ -222,7 +222,7 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle // REST resolve. const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/approvals/${payload.approval_id}`, + url: `/api/v1/sessions/${sid}/approvals/${payload.approval_id}`, payload: { decision: 'approved', scope: 'session', @@ -311,7 +311,7 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/approvals/01JAAAAAAAAAAAAAAAAAAAAAAA`, + url: `/api/v1/sessions/${sid}/approvals/01JAAAAAAAAAAAAAAAAAAAAAAA`, payload: { decision: 'approved' }, }); const env = envelopeOf(res.json()); @@ -349,7 +349,7 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle // First resolve succeeds. const ok = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/approvals/${approvalId}`, + url: `/api/v1/sessions/${sid}/approvals/${approvalId}`, payload: { decision: 'approved' }, }); const env1 = envelopeOf<{ resolved: boolean }>(ok.json()); @@ -359,7 +359,7 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle // Second resolve hits the idempotency window. const dup = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/approvals/${approvalId}`, + url: `/api/v1/sessions/${sid}/approvals/${approvalId}`, payload: { decision: 'approved' }, }); const env2 = envelopeOf<{ resolved: boolean }>(dup.json()); @@ -397,7 +397,7 @@ describe('Approval reverse-RPC: WS broadcast → REST resolve → Promise settle const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/approvals/${approvalId}`, + url: `/api/v1/sessions/${sid}/approvals/${approvalId}`, payload: { decision: 'maybe' }, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/error-handler.test.ts b/packages/daemon/test/error-handler.test.ts index 2cecd75d7..601bfb587 100644 --- a/packages/daemon/test/error-handler.test.ts +++ b/packages/daemon/test/error-handler.test.ts @@ -6,7 +6,7 @@ * 2. `request_id` in envelope respects client-supplied `X-Request-Id` when valid. * 3. Malformed `X-Request-Id` → fresh ULID minted (regression test for the * pre-W4 verbatim-echo behavior; security review demanded ULID-only). - * 4. `/v1/healthz` smoke — success envelope shape stays byte-identical + * 4. `/api/v1/healthz` smoke — success envelope shape stays byte-identical * after the protocol re-export. * * Uses Fastify's built-in `.inject(...)` HTTP simulator — no socket binding, @@ -29,7 +29,7 @@ function buildApp() { genReqId: (req) => resolveRequestId(req.headers as Record), }); installErrorHandler(app); - app.get('/v1/healthz', async (req, reply) => reply.send(okEnvelope({ ok: true }, req.id))); + app.get('/api/v1/healthz', async (req, reply) => reply.send(okEnvelope({ ok: true }, req.id))); app.get('/boom', async () => { throw new Error('oops something broke'); }); @@ -72,7 +72,7 @@ describe('request_id resolution at the REST boundary', () => { it('mints a bare ULID when no header is supplied (no req_ prefix)', async () => { const app = buildApp(); try { - const res = await app.inject({ method: 'GET', url: '/v1/healthz' }); + const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' }); const body = res.json() as Record; expect(body['code']).toBe(0); expect(body['data']).toEqual({ ok: true }); @@ -90,7 +90,7 @@ describe('request_id resolution at the REST boundary', () => { const goodUlid = '01HQXY4Z2M3GZP6F8K9R5W7VBA'; // 26-char crockford const res = await app.inject({ method: 'GET', - url: '/v1/healthz', + url: '/api/v1/healthz', headers: { 'x-request-id': goodUlid }, }); const body = res.json() as Record; @@ -107,7 +107,7 @@ describe('request_id resolution at the REST boundary', () => { try { const res = await app.inject({ method: 'GET', - url: '/v1/healthz', + url: '/api/v1/healthz', headers: { 'x-request-id': 'req_garbage' }, }); const body = res.json() as Record; @@ -127,7 +127,7 @@ describe('request_id resolution at the REST boundary', () => { const looksRight = 'IIIIIIIIIIIIIIIIIIIIIIIIII'; const res = await app.inject({ method: 'GET', - url: '/v1/healthz', + url: '/api/v1/healthz', headers: { 'x-request-id': looksRight }, }); const id = (res.json() as Record)['request_id'] as string; @@ -139,11 +139,11 @@ describe('request_id resolution at the REST boundary', () => { }); }); -describe('/v1/healthz envelope shape stability across the protocol re-export', () => { +describe('/api/v1/healthz envelope shape stability across the protocol re-export', () => { it('responds with the documented success envelope', async () => { const app = buildApp(); try { - const res = await app.inject({ method: 'GET', url: '/v1/healthz' }); + const res = await app.inject({ method: 'GET', url: '/api/v1/healthz' }); expect(res.statusCode).toBe(200); const body = res.json() as Record; // Field order isn't a contract (JSON), but key set + types must hold. diff --git a/packages/daemon/test/files.e2e.test.ts b/packages/daemon/test/files.e2e.test.ts index 7dff2e32b..7dd1f672e 100644 --- a/packages/daemon/test/files.e2e.test.ts +++ b/packages/daemon/test/files.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `/v1/files` end-to-end (W12.2 / Chain 15, P1.15). + * `/api/v1/files` end-to-end (W12.2 / Chain 15, P1.15). * * AC coverage (ROADMAP §Chain 15): * 1. upload → file_id → GET stream → DELETE → re-GET → 40407 @@ -129,7 +129,7 @@ function buildMultipart(parts: { }; } -describe('POST /v1/files (W12.2 / Chain 15)', () => { +describe('POST /api/v1/files (W12.2 / Chain 15)', () => { it('AC #1: upload tiny file → file_id → GET stream matches → DELETE → re-GET 40407', async () => { const r = await bootDaemon(); const data = Buffer.from('hello daemon files'); @@ -143,7 +143,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { }); const upRes = await appOf(r).inject({ method: 'POST', - url: '/v1/files', + url: '/api/v1/files', payload: mp.body, headers: { 'content-type': mp.contentType }, }); @@ -169,7 +169,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { // GET should return the bytes with octet-stream-or-mime body. const getRes = await appOf(r).inject({ method: 'GET', - url: `/v1/files/${meta.id}`, + url: `/api/v1/files/${meta.id}`, }); expect(getRes.statusCode).toBe(200); expect(getRes.headers['content-type']).toBe('text/plain'); @@ -183,7 +183,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { // DELETE. const delRes = await appOf(r).inject({ method: 'DELETE', - url: `/v1/files/${meta.id}`, + url: `/api/v1/files/${meta.id}`, }); expect(delRes.statusCode).toBe(200); const delEnv = delRes.json() as Envelope<{ deleted: true }>; @@ -193,7 +193,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { // GET after delete → 40407. const get2Res = await appOf(r).inject({ method: 'GET', - url: `/v1/files/${meta.id}`, + url: `/api/v1/files/${meta.id}`, }); expect(get2Res.statusCode).toBe(404); expect(get2Res.headers['content-type']).toMatch(/application\/json/); @@ -215,7 +215,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { }); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/files', + url: '/api/v1/files', payload: mp.body, headers: { 'content-type': mp.contentType }, }); @@ -228,14 +228,14 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { const r = await bootDaemon(); const getRes = await appOf(r).inject({ method: 'GET', - url: '/v1/files/f_does_not_exist', + url: '/api/v1/files/f_does_not_exist', }); expect(getRes.statusCode).toBe(404); expect((getRes.json() as Envelope).code).toBe(40407); const delRes = await appOf(r).inject({ method: 'DELETE', - url: '/v1/files/f_does_not_exist', + url: '/api/v1/files/f_does_not_exist', }); expect(delRes.statusCode).toBe(404); expect((delRes.json() as Envelope).code).toBe(40407); @@ -255,7 +255,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { }); const upRes = await appOf(r).inject({ method: 'POST', - url: '/v1/files', + url: '/api/v1/files', payload: mp.body, headers: { 'content-type': mp.contentType }, }); @@ -269,7 +269,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { const getRes = await appOf(r).inject({ method: 'GET', - url: `/v1/files/${meta.id}`, + url: `/api/v1/files/${meta.id}`, }); expect(getRes.statusCode).toBe(200); expect(getRes.rawPayload).toEqual(data); @@ -289,7 +289,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { }); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/files', + url: '/api/v1/files', payload: mp.body, headers: { 'content-type': mp.contentType }, }); @@ -307,7 +307,7 @@ describe('POST /v1/files (W12.2 / Chain 15)', () => { ); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/files', + url: '/api/v1/files', payload: body, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, }); diff --git a/packages/daemon/test/fs-basic.e2e.test.ts b/packages/daemon/test/fs-basic.e2e.test.ts index eafae674e..c1c68f027 100644 --- a/packages/daemon/test/fs-basic.e2e.test.ts +++ b/packages/daemon/test/fs-basic.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `/v1/sessions/{sid}/fs:list` + `/v1/sessions/{sid}/fs:read` end-to-end + * `/api/v1/sessions/{sid}/fs:list` + `/api/v1/sessions/{sid}/fs:read` end-to-end * tests (W10.1 / Chain 9 / P1.9). * * AC coverage (ROADMAP §Chain 9): @@ -101,7 +101,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -111,7 +111,7 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { +describe('POST /api/v1/sessions/{sid}/fs:list (W10.1)', () => { it('lists direct children of cwd', async () => { writeFileSync(join(workspace, 'hello.txt'), 'hi'); mkdirSync(join(workspace, 'src')); @@ -121,7 +121,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list`, + url: `/api/v1/sessions/${sid}/fs:list`, payload: { path: '.' }, }); const env = envelopeOf<{ @@ -140,7 +140,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list`, + url: `/api/v1/sessions/${sid}/fs:list`, payload: { path: '/etc' }, }); const env = envelopeOf(res.json()); @@ -152,7 +152,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list`, + url: `/api/v1/sessions/${sid}/fs:list`, payload: { path: '../..' }, }); const env = envelopeOf(res.json()); @@ -169,7 +169,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list`, + url: `/api/v1/sessions/${sid}/fs:list`, payload: { path: '.' }, }); const env = envelopeOf<{ items: { name: string }[] }>(res.json()); @@ -185,7 +185,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list`, + url: `/api/v1/sessions/${sid}/fs:list`, payload: { path: '.', follow_gitignore: false }, }); const env = envelopeOf<{ items: { name: string }[] }>(res.json()); @@ -197,7 +197,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/does-not-exist/fs:list', + url: '/api/v1/sessions/does-not-exist/fs:list', payload: { path: '.' }, }); const env = envelopeOf(res.json()); @@ -209,7 +209,7 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:bogus`, + url: `/api/v1/sessions/${sid}/fs:bogus`, payload: {}, }); const env = envelopeOf(res.json()); @@ -217,14 +217,14 @@ describe('POST /v1/sessions/{sid}/fs:list (W10.1)', () => { }); }); -describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { +describe('POST /api/v1/sessions/{sid}/fs:read (W10.1)', () => { it('reads a normal utf-8 text file', async () => { writeFileSync(join(workspace, 'hello.txt'), 'hello world'); const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'hello.txt' }, }); const env = envelopeOf<{ @@ -247,7 +247,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: '/etc/passwd' }, }); const env = envelopeOf(res.json()); @@ -259,7 +259,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'no-such-file.txt' }, }); const env = envelopeOf(res.json()); @@ -272,7 +272,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'a-dir' }, }); const env = envelopeOf(res.json()); @@ -287,7 +287,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'huge.txt' }, }); const env = envelopeOf(res.json()); @@ -305,7 +305,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'bin', encoding: 'utf-8' }, }); const env = envelopeOf(res.json()); @@ -319,7 +319,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'bin' }, }); const env = envelopeOf<{ @@ -340,7 +340,7 @@ describe('POST /v1/sessions/{sid}/fs:read (W10.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:read`, + url: `/api/v1/sessions/${sid}/fs:read`, payload: { path: 'small.txt', length: 11 * 1024 * 1024 }, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/fs-batch.e2e.test.ts b/packages/daemon/test/fs-batch.e2e.test.ts index 4a0b4d56a..c449f4e17 100644 --- a/packages/daemon/test/fs-batch.e2e.test.ts +++ b/packages/daemon/test/fs-batch.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `/v1/sessions/{sid}/fs:list_many` + `:stat` + `:stat_many` end-to-end + * `/api/v1/sessions/{sid}/fs:list_many` + `:stat` + `:stat_many` end-to-end * tests (W10.2 / Chain 10 / P1.10). * * AC coverage (ROADMAP §Chain 10): @@ -101,7 +101,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -111,7 +111,7 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { +describe('POST /api/v1/sessions/{sid}/fs:list_many (W10.2)', () => { it('returns 100 path results, half existing half missing, with partial_errors', async () => { // 50 real directories + 50 missing paths. const real: string[] = []; @@ -129,7 +129,7 @@ describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list_many`, + url: `/api/v1/sessions/${sid}/fs:list_many`, payload: { paths }, }); @@ -158,7 +158,7 @@ describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list_many`, + url: `/api/v1/sessions/${sid}/fs:list_many`, payload: { paths: ['.', '../escape'] }, }); const env = envelopeOf(res.json()); @@ -169,7 +169,7 @@ describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/does-not-exist/fs:list_many', + url: '/api/v1/sessions/does-not-exist/fs:list_many', payload: { paths: ['.'] }, }); const env = envelopeOf(res.json()); @@ -182,7 +182,7 @@ describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { const paths = Array.from({ length: 101 }, (_, i) => `p${i}`); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:list_many`, + url: `/api/v1/sessions/${sid}/fs:list_many`, payload: { paths }, }); const env = envelopeOf(res.json()); @@ -190,14 +190,14 @@ describe('POST /v1/sessions/{sid}/fs:list_many (W10.2)', () => { }); }); -describe('POST /v1/sessions/{sid}/fs:stat (W10.2)', () => { +describe('POST /api/v1/sessions/{sid}/fs:stat (W10.2)', () => { it('returns an FsEntry for an existing file', async () => { writeFileSync(join(workspace, 'a.ts'), 'export {}'); const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat`, + url: `/api/v1/sessions/${sid}/fs:stat`, payload: { path: 'a.ts' }, }); const env = envelopeOf<{ @@ -219,7 +219,7 @@ describe('POST /v1/sessions/{sid}/fs:stat (W10.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat`, + url: `/api/v1/sessions/${sid}/fs:stat`, payload: { path: 'no-such.txt' }, }); const env = envelopeOf(res.json()); @@ -231,7 +231,7 @@ describe('POST /v1/sessions/{sid}/fs:stat (W10.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat`, + url: `/api/v1/sessions/${sid}/fs:stat`, payload: { path: '/etc/passwd' }, }); const env = envelopeOf(res.json()); @@ -239,14 +239,14 @@ describe('POST /v1/sessions/{sid}/fs:stat (W10.2)', () => { }); }); -describe('POST /v1/sessions/{sid}/fs:stat_many (W10.2)', () => { +describe('POST /api/v1/sessions/{sid}/fs:stat_many (W10.2)', () => { it('returns null for missing per-path entries (REST.md §3.9 line 524)', async () => { writeFileSync(join(workspace, 'present.txt'), 'p'); const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat_many`, + url: `/api/v1/sessions/${sid}/fs:stat_many`, payload: { paths: ['present.txt', 'missing.txt'] }, }); const env = envelopeOf<{ @@ -264,7 +264,7 @@ describe('POST /v1/sessions/{sid}/fs:stat_many (W10.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat_many`, + url: `/api/v1/sessions/${sid}/fs:stat_many`, payload: { paths: ['safe.txt', '/etc/passwd'] }, }); const env = envelopeOf(res.json()); @@ -288,7 +288,7 @@ describe('POST /v1/sessions/{sid}/fs:stat_many (W10.2)', () => { const start = performance.now(); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:stat_many`, + url: `/api/v1/sessions/${sid}/fs:stat_many`, payload: { paths }, }); const elapsed = performance.now() - start; diff --git a/packages/daemon/test/fs-download.e2e.test.ts b/packages/daemon/test/fs-download.e2e.test.ts index 85d221614..86485a901 100644 --- a/packages/daemon/test/fs-download.e2e.test.ts +++ b/packages/daemon/test/fs-download.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `GET /v1/sessions/{sid}/fs/{path}:download` end-to-end tests + * `GET /api/v1/sessions/{sid}/fs/{path}:download` end-to-end tests * (W11.3 / Chain 13 / P1.13). * * AC coverage (ROADMAP §Chain 13): @@ -106,7 +106,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -116,7 +116,7 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { +describe('GET /api/v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { it('streams a text file with the correct mime + length headers', async () => { writeFileSync(join(workspace, 'hello.txt'), 'hello world\n'); @@ -124,7 +124,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/hello.txt:download`, + url: `/api/v1/sessions/${sid}/fs/hello.txt:download`, }); expect(res.statusCode).toBe(200); expect(res.headers['content-length']).toBe('12'); @@ -144,7 +144,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/pixel.png:download`, + url: `/api/v1/sessions/${sid}/fs/pixel.png:download`, }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('image/png'); @@ -159,7 +159,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/src/lib/util.ts:download`, + url: `/api/v1/sessions/${sid}/fs/src/lib/util.ts:download`, }); expect(res.statusCode).toBe(200); expect(res.headers['content-disposition']).toContain('util.ts'); @@ -176,7 +176,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/big.bin:download`, + url: `/api/v1/sessions/${sid}/fs/big.bin:download`, }); expect(res.statusCode).toBe(200); expect(res.headers['content-length']).toBe(String(SIZE)); @@ -191,7 +191,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/a.bin:download`, + url: `/api/v1/sessions/${sid}/fs/a.bin:download`, headers: { Range: 'bytes=2-5' }, }); expect(res.statusCode).toBe(206); @@ -206,7 +206,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/a.bin:download`, + url: `/api/v1/sessions/${sid}/fs/a.bin:download`, headers: { Range: 'bytes=-3' }, }); expect(res.statusCode).toBe(206); @@ -220,7 +220,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/a.bin:download`, + url: `/api/v1/sessions/${sid}/fs/a.bin:download`, headers: { Range: 'bytes=3-' }, }); expect(res.statusCode).toBe(206); @@ -234,14 +234,14 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const first = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/hello.txt:download`, + url: `/api/v1/sessions/${sid}/fs/hello.txt:download`, }); expect(first.statusCode).toBe(200); const etag = first.headers['etag']; expect(etag).toBeDefined(); const second = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/hello.txt:download`, + url: `/api/v1/sessions/${sid}/fs/hello.txt:download`, headers: { 'If-None-Match': etag as string }, }); expect(second.statusCode).toBe(304); @@ -254,7 +254,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/does-not-exist.txt:download`, + url: `/api/v1/sessions/${sid}/fs/does-not-exist.txt:download`, }); expect(res.statusCode).toBe(200); expect((res.headers['content-type'] as string) ?? '').toContain('json'); @@ -268,7 +268,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/src:download`, + url: `/api/v1/sessions/${sid}/fs/src:download`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -283,7 +283,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { // is what we're testing, not the URL parser. const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/%2E%2E%2Foutside.txt:download`, + url: `/api/v1/sessions/${sid}/fs/%2E%2E%2Foutside.txt:download`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -294,7 +294,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions/sess_does_not_exist/fs/a.txt:download', + url: '/api/v1/sessions/sess_does_not_exist/fs/a.txt:download', }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -307,7 +307,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/a.txt:bogus`, + url: `/api/v1/sessions/${sid}/fs/a.txt:bogus`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -319,7 +319,7 @@ describe('GET /v1/sessions/{sid}/fs/{path}:download (W11.3)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/fs/:download`, + url: `/api/v1/sessions/${sid}/fs/:download`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/fs-git.e2e.test.ts b/packages/daemon/test/fs-git.e2e.test.ts index 9b46ef21f..b721b8e7e 100644 --- a/packages/daemon/test/fs-git.e2e.test.ts +++ b/packages/daemon/test/fs-git.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `/v1/sessions/{sid}/fs:git_status` end-to-end tests (W11.2 / Chain 12 / P1.12). + * `/api/v1/sessions/{sid}/fs:git_status` end-to-end tests (W11.2 / Chain 12 / P1.12). * * AC coverage (ROADMAP §Chain 12): * 1. e2e: git repo / non-git repo / dirty / clean @@ -102,7 +102,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -133,7 +133,7 @@ function initRepo(): void { git(['commit', '-m', 'seed', '--no-gpg-sign']); } -describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { +describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', () => { it('clean repo: empty entries, branch populated', async () => { initRepo(); @@ -141,7 +141,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: {}, }); const env = envelopeOf<{ @@ -168,7 +168,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: {}, }); const env = envelopeOf<{ @@ -190,7 +190,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: {}, }); const env = envelopeOf<{ entries: Record }>(res.json()); @@ -216,7 +216,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: { paths: ['a.txt'] }, }); const env = envelopeOf<{ entries: Record }>(res.json()); @@ -231,7 +231,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: {}, }); const env = envelopeOf(res.json()); @@ -245,7 +245,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:git_status`, + url: `/api/v1/sessions/${sid}/fs:git_status`, payload: { paths: ['../outside.txt'] }, }); const env = envelopeOf(res.json()); @@ -256,7 +256,7 @@ describe('POST /v1/sessions/{sid}/fs:git_status (W11.2)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/sess_does_not_exist/fs:git_status', + url: '/api/v1/sessions/sess_does_not_exist/fs:git_status', payload: {}, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/fs-search.e2e.test.ts b/packages/daemon/test/fs-search.e2e.test.ts index b4372e3b2..232081b6d 100644 --- a/packages/daemon/test/fs-search.e2e.test.ts +++ b/packages/daemon/test/fs-search.e2e.test.ts @@ -1,5 +1,5 @@ /** - * `/v1/sessions/{sid}/fs:search` + `/v1/sessions/{sid}/fs:grep` end-to-end + * `/api/v1/sessions/{sid}/fs:search` + `/api/v1/sessions/{sid}/fs:grep` end-to-end * tests (W11.1 / Chain 11 / P1.11). * * AC coverage (ROADMAP §Chain 11): @@ -111,7 +111,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -121,7 +121,7 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { +describe('POST /api/v1/sessions/{sid}/fs:search (W11.1)', () => { it('finds a file by fuzzy filename match', async () => { mkdirSync(join(workspace, 'src', 'components'), { recursive: true }); writeFileSync( @@ -134,7 +134,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:search`, + url: `/api/v1/sessions/${sid}/fs:search`, payload: { query: 'buton' }, }); const env = envelopeOf<{ @@ -155,7 +155,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:search`, + url: `/api/v1/sessions/${sid}/fs:search`, payload: { query: 'index' }, }); const env = envelopeOf<{ @@ -178,7 +178,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:search`, + url: `/api/v1/sessions/${sid}/fs:search`, payload: { query: 'match', limit: 200 }, }); const env = envelopeOf<{ items: unknown[]; truncated: boolean }>(res.json()); @@ -196,7 +196,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:search`, + url: `/api/v1/sessions/${sid}/fs:search`, payload: { query: 'keep', include_globs: ['*.ts'] }, }); const env = envelopeOf<{ @@ -212,7 +212,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:search`, + url: `/api/v1/sessions/${sid}/fs:search`, payload: { query: 'a' }, }); const env = envelopeOf<{ items: unknown[]; truncated: boolean }>(res.json()); @@ -224,7 +224,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/sess_does_not_exist/fs:search', + url: '/api/v1/sessions/sess_does_not_exist/fs:search', payload: { query: 'x' }, }); const env = envelopeOf(res.json()); @@ -232,7 +232,7 @@ describe('POST /v1/sessions/{sid}/fs:search (W11.1)', () => { }); }); -describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { +describe('POST /api/v1/sessions/{sid}/fs:grep (W11.1)', () => { it('finds a literal match across files with context', async () => { writeFileSync( join(workspace, 'a.txt'), @@ -243,7 +243,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:grep`, + url: `/api/v1/sessions/${sid}/fs:grep`, payload: { pattern: 'hello', context_lines: 1 }, }); const env = envelopeOf<{ @@ -286,7 +286,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:grep`, + url: `/api/v1/sessions/${sid}/fs:grep`, payload: { pattern: 'foo|bar', regex: true, context_lines: 0 }, }); const env = envelopeOf<{ @@ -304,7 +304,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:grep`, + url: `/api/v1/sessions/${sid}/fs:grep`, payload: { pattern: 'hello', case_sensitive: false, context_lines: 0 }, }); const env = envelopeOf<{ @@ -323,7 +323,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:grep`, + url: `/api/v1/sessions/${sid}/fs:grep`, payload: { pattern: 'needle', context_lines: 0 }, }); const env = envelopeOf<{ @@ -343,7 +343,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/fs:grep`, + url: `/api/v1/sessions/${sid}/fs:grep`, payload: { pattern: 'needle', max_total_matches: 5, @@ -364,7 +364,7 @@ describe('POST /v1/sessions/{sid}/fs:grep (W11.1)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/sess_does_not_exist/fs:grep', + url: '/api/v1/sessions/sess_does_not_exist/fs:grep', payload: { pattern: 'x' }, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/fs-watch.e2e.test.ts b/packages/daemon/test/fs-watch.e2e.test.ts index 68983a394..5265e281d 100644 --- a/packages/daemon/test/fs-watch.e2e.test.ts +++ b/packages/daemon/test/fs-watch.e2e.test.ts @@ -103,7 +103,7 @@ function appOf(r: RunningDaemon): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: workspace } }, }); const env = res.json() as { code: number; data: { id: string } | null }; @@ -114,7 +114,7 @@ async function createSession(r: RunningDaemon): Promise { } function wsUrl(http: string): string { - return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; } interface WsFrame { diff --git a/packages/daemon/test/lock.test.ts b/packages/daemon/test/lock.test.ts index da0ecb9cd..290719238 100644 --- a/packages/daemon/test/lock.test.ts +++ b/packages/daemon/test/lock.test.ts @@ -149,7 +149,7 @@ describe('acquireLock — concurrent-instance protection', () => { }); describe('acquireLock — defaults', () => { - it('DEFAULT_LOCK_PATH points under the homedir', () => { - expect(DEFAULT_LOCK_PATH).toMatch(/[/\\]\.kimi[/\\]daemon[/\\]lock$/); + it('DEFAULT_LOCK_PATH points under the kimi-code home', () => { + expect(DEFAULT_LOCK_PATH).toMatch(/[/\\]\.kimi-code[/\\]daemon[/\\]lock$/); }); }); diff --git a/packages/daemon/test/messages.e2e.test.ts b/packages/daemon/test/messages.e2e.test.ts index 5f3b971c9..ebc1a687e 100644 --- a/packages/daemon/test/messages.e2e.test.ts +++ b/packages/daemon/test/messages.e2e.test.ts @@ -7,10 +7,10 @@ * interference. * * Coverage matrix per REST.md §3.4: - * - GET /v1/sessions/{sid}/messages → Page + has_more + * - GET /api/v1/sessions/{sid}/messages → Page + has_more * - Empty session → empty page, has_more=false * - page_size honored (sub-cap) - * - GET /v1/sessions/{sid}/messages/{mid} → Message (40403 unknown id) + * - GET /api/v1/sessions/{sid}/messages/{mid} → Message (40403 unknown id) * * Plus the validation matrix: * - page_size=0 → 40001 @@ -104,7 +104,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -114,13 +114,13 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () => { +describe('GET /api/v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () => { it('returns an empty page for a freshly-created session', async () => { const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages`, + url: `/api/v1/sessions/${sid}/messages`, }); expect(res.statusCode).toBe(200); const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); @@ -134,7 +134,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions/sess_missing/messages', + url: '/api/v1/sessions/sess_missing/messages', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); @@ -146,7 +146,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages?page_size=0`, + url: `/api/v1/sessions/${sid}/messages?page_size=0`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); @@ -158,7 +158,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages?page_size=101`, + url: `/api/v1/sessions/${sid}/messages?page_size=101`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); @@ -169,7 +169,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages?before_id=a&after_id=b`, + url: `/api/v1/sessions/${sid}/messages?before_id=a&after_id=b`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); @@ -180,7 +180,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages?role=cat`, + url: `/api/v1/sessions/${sid}/messages?role=cat`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); @@ -191,7 +191,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages?page_size=10&role=assistant`, + url: `/api/v1/sessions/${sid}/messages?page_size=10&role=assistant`, }); const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); expect(env.code).toBe(0); @@ -200,7 +200,7 @@ describe('GET /v1/sessions/{session_id}/messages — list (W7.1 / Chain 3)', () }); }); -describe('GET /v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Chain 3)', () => { +describe('GET /api/v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Chain 3)', () => { it('returns 40403 (message.not_found) when the id has no matching history entry', async () => { const r = await bootDaemon(); const sid = await createSession(r); @@ -209,7 +209,7 @@ describe('GET /v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Ch const fakeId = `msg_${sid}_000000`; const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages/${fakeId}`, + url: `/api/v1/sessions/${sid}/messages/${fakeId}`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40403); @@ -222,7 +222,7 @@ describe('GET /v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Ch const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/messages/garbage`, + url: `/api/v1/sessions/${sid}/messages/garbage`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40403); @@ -232,7 +232,7 @@ describe('GET /v1/sessions/{session_id}/messages/{message_id} — get (W7.1 / Ch const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions/sess_missing/messages/msg_anything', + url: '/api/v1/sessions/sess_missing/messages/msg_anything', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); diff --git a/packages/daemon/test/meta.e2e.test.ts b/packages/daemon/test/meta.e2e.test.ts index 18453e8d2..e230929c7 100644 --- a/packages/daemon/test/meta.e2e.test.ts +++ b/packages/daemon/test/meta.e2e.test.ts @@ -1,8 +1,8 @@ /** - * `/v1/meta` end-to-end smoke (W6.1 / Chain 1 / P1.1). + * `/api/v1/meta` end-to-end smoke (W6.1 / Chain 1 / P1.1). * * Boots the real daemon (hermetic — port 0, tmp lock + bridge home), hits - * `GET /v1/meta` via Fastify's `inject` simulator on the constructed app, and + * `GET /api/v1/meta` via Fastify's `inject` simulator on the constructed app, and * asserts: * 1. Envelope shape (`code: 0`, `msg: success`, `request_id`, `data`). * 2. `data` matches `metaResponseSchema` — daemon_version + capabilities @@ -12,7 +12,7 @@ * 4. `started_at` is the daemon's boot time — within a generous window of * `Date.now()` at test start. * - * Plus request_id propagation (already covered for `/v1/healthz` in + * Plus request_id propagation (already covered for `/api/v1/healthz` in * `error-handler.test.ts` but re-asserted here because the prompt requires * Chain 1's first business endpoint to demonstrate the W4.3 request_id pipe): * @@ -87,10 +87,10 @@ function appOf(r: RunningDaemon): { }); } -describe('GET /v1/meta — envelope + metaResponseSchema', () => { +describe('GET /api/v1/meta — envelope + metaResponseSchema', () => { it('responds 200 with code 0 + schema-conforming data', async () => { const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/meta' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/meta' }); expect(res.statusCode).toBe(200); const body = res.json() as Record; expect(body['code']).toBe(0); @@ -122,8 +122,8 @@ describe('GET /v1/meta — envelope + metaResponseSchema', () => { it('server_id is stable across multiple calls (process-scoped)', async () => { const r = await bootDaemon(); const app = appOf(r); - const a = await app.inject({ method: 'GET', url: '/v1/meta' }); - const b = await app.inject({ method: 'GET', url: '/v1/meta' }); + const a = await app.inject({ method: 'GET', url: '/api/v1/meta' }); + const b = await app.inject({ method: 'GET', url: '/api/v1/meta' }); const aData = (a.json() as { data: { server_id: string } }).data; const bData = (b.json() as { data: { server_id: string } }).data; expect(aData.server_id).toBe(bData.server_id); @@ -150,8 +150,8 @@ describe('GET /v1/meta — envelope + metaResponseSchema', () => { bridgeOptions: { homeDir: homeB }, }); try { - const a = await appOf(r1).inject({ method: 'GET', url: '/v1/meta' }); - const b = await appOf(r2).inject({ method: 'GET', url: '/v1/meta' }); + const a = await appOf(r1).inject({ method: 'GET', url: '/api/v1/meta' }); + const b = await appOf(r2).inject({ method: 'GET', url: '/api/v1/meta' }); const aData = (a.json() as { data: { server_id: string } }).data; const bData = (b.json() as { data: { server_id: string } }).data; expect(aData.server_id).not.toBe(bData.server_id); @@ -164,13 +164,13 @@ describe('GET /v1/meta — envelope + metaResponseSchema', () => { }); }); -describe('GET /v1/meta — request_id propagation (W4.3 contract)', () => { +describe('GET /api/v1/meta — request_id propagation (W4.3 contract)', () => { it('echoes a client-supplied valid ULID verbatim', async () => { const r = await bootDaemon(); const goodUlid = '01HQXY4Z2M3GZP6F8K9R5W7VBA'; const res = await appOf(r).inject({ method: 'GET', - url: '/v1/meta', + url: '/api/v1/meta', headers: { 'x-request-id': goodUlid }, }); const body = res.json() as Record; @@ -179,7 +179,7 @@ describe('GET /v1/meta — request_id propagation (W4.3 contract)', () => { it('mints a bare ULID when no header is supplied (no req_ prefix)', async () => { const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/meta' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/meta' }); const body = res.json() as Record; const id = body['request_id'] as string; expect(id).not.toMatch(/^req_/); @@ -190,7 +190,7 @@ describe('GET /v1/meta — request_id propagation (W4.3 contract)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/meta', + url: '/api/v1/meta', headers: { 'x-request-id': 'req_garbage' }, }); const body = res.json() as Record; diff --git a/packages/daemon/test/prompt.e2e.test.ts b/packages/daemon/test/prompt.e2e.test.ts index b1bbc6ac9..5a20a7d63 100644 --- a/packages/daemon/test/prompt.e2e.test.ts +++ b/packages/daemon/test/prompt.e2e.test.ts @@ -3,7 +3,7 @@ * * **Bootstrap strategy**: spawn the real daemon (port 0, tmp lock + bridge * home) and exercise: - * 1. POST /v1/sessions/{sid}/prompts validation (40001 on bad body, 40401 + * 1. POST /api/v1/sessions/{sid}/prompts validation (40001 on bad body, 40401 * on bad sid). * 2. Lifecycle event synthesis: register a fake active prompt directly on * the IPromptService (so we don't have to drive agent-core through the @@ -98,7 +98,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -123,7 +123,7 @@ async function openSubscriber( ws: WebSocket; received: Record[]; }> { - const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { const sock = new WebSocket(wsUrl); @@ -166,13 +166,13 @@ async function waitFor( ); } -describe('POST /v1/sessions/{sid}/prompts — submit validation (W7.2 / Chain 4)', () => { +describe('POST /api/v1/sessions/{sid}/prompts — submit validation (W7.2 / Chain 4)', () => { it('rejects an empty content array with 40001', async () => { const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts`, + url: `/api/v1/sessions/${sid}/prompts`, payload: { content: [] }, }); const env = envelopeOf(res.json()); @@ -185,7 +185,7 @@ describe('POST /v1/sessions/{sid}/prompts — submit validation (W7.2 / Chain 4) const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/sess_missing/prompts', + url: '/api/v1/sessions/sess_missing/prompts', payload: { content: [{ type: 'text', text: 'hello' }] }, }); const env = envelopeOf(res.json()); @@ -197,7 +197,7 @@ describe('POST /v1/sessions/{sid}/prompts — submit validation (W7.2 / Chain 4) const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts`, + url: `/api/v1/sessions/${sid}/prompts`, payload: { content: [{ text: 'no type' }] }, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/question.e2e.test.ts b/packages/daemon/test/question.e2e.test.ts index e8550755f..e14472bc2 100644 --- a/packages/daemon/test/question.e2e.test.ts +++ b/packages/daemon/test/question.e2e.test.ts @@ -3,7 +3,7 @@ * * Covers the reverse-RPC path: agent-core → BridgeClientAPI.requestQuestion → * IQuestionBroker.request → WS `event.question.requested` → REST - * `POST /v1/sessions/{sid}/questions/{qid}` (or `:dismiss`) → Promise + * `POST /api/v1/sessions/{sid}/questions/{qid}` (or `:dismiss`) → Promise * resolves with `Record` (or `null` for dismiss). * * Mirrors `approval.e2e.test.ts` strategy — bypass `bridge.rpc.prompt(...)` @@ -94,7 +94,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -111,7 +111,7 @@ async function openSubscriber( ws: WebSocket; received: Record[]; }> { - const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { const sock = new WebSocket(wsUrl); @@ -216,7 +216,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle // POST with mixed kinds INCLUDING one skipped. const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${payload.question_id}`, + url: `/api/v1/sessions/${sid}/questions/${payload.question_id}`, payload: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' }, @@ -314,7 +314,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${questionId}`, + url: `/api/v1/sessions/${sid}/questions/${questionId}`, payload: { answers }, }); const env = envelopeOf<{ resolved: boolean }>(res.json()); @@ -354,7 +354,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${payload.question_id}:dismiss`, + url: `/api/v1/sessions/${sid}/questions/${payload.question_id}:dismiss`, payload: {}, }); const env = envelopeOf<{ dismissed: boolean; dismissed_at: string }>(res.json()); @@ -381,7 +381,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/01JAAAAAAAAAAAAAAAAAAAAAAA`, + url: `/api/v1/sessions/${sid}/questions/01JAAAAAAAAAAAAAAAAAAAAAAA`, payload: { answers: { q_0: { kind: 'skipped' } } }, }); const env = envelopeOf(res.json()); @@ -393,7 +393,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/01JBBBBBBBBBBBBBBBBBBBBBBB:dismiss`, + url: `/api/v1/sessions/${sid}/questions/01JBBBBBBBBBBBBBBBBBBBBBBB:dismiss`, payload: {}, }); const env = envelopeOf(res.json()); @@ -426,7 +426,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const ok = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${questionId}`, + url: `/api/v1/sessions/${sid}/questions/${questionId}`, payload: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, }); expect(envelopeOf<{ resolved: boolean }>(ok.json()).code).toBe(0); @@ -434,7 +434,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const dup = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${questionId}`, + url: `/api/v1/sessions/${sid}/questions/${questionId}`, payload: { answers: { q_0: { kind: 'single', option_id: 'opt_0_0' } } }, }); const dupEnv = envelopeOf<{ resolved: boolean }>(dup.json()); @@ -468,7 +468,7 @@ describe('Question reverse-RPC: WS broadcast → REST resolve → Promise settle const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/questions/${questionId}`, + url: `/api/v1/sessions/${sid}/questions/${questionId}`, payload: { answers: { q_0: { kind: 'rangefinder', value: 42 } } }, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/sessions.e2e.test.ts b/packages/daemon/test/sessions.e2e.test.ts index d7c030e33..c5890770d 100644 --- a/packages/daemon/test/sessions.e2e.test.ts +++ b/packages/daemon/test/sessions.e2e.test.ts @@ -9,11 +9,11 @@ * `core-impl.ts:170-172`), but no network / external state is involved. * * Coverage matrix per REST.md §3.3: - * - POST /v1/sessions → envelope code 0 + Session payload - * - GET /v1/sessions → Page + has_more - * - GET /v1/sessions/{id} → Session (40401 on unknown id) - * - PATCH /v1/sessions/{id} → Session (40401 on unknown id) - * - DELETE /v1/sessions/{id} → { deleted: true } (40401 on unknown) + * - POST /api/v1/sessions → envelope code 0 + Session payload + * - GET /api/v1/sessions → Page + has_more + * - GET /api/v1/sessions/{id} → Session (40401 on unknown id) + * - PATCH /api/v1/sessions/{id} → Session (40401 on unknown id) + * - DELETE /api/v1/sessions/{id} → { deleted: true } (40401 on unknown) * * Plus the validation matrix: * - POST with missing `metadata.cwd` → 40001 + `details` containing path. @@ -82,13 +82,13 @@ function envelopeOf(body: unknown): { code: number; msg: string; data: T | nu return body as { code: number; msg: string; data: T | null; request_id: string; details?: unknown }; } -describe('POST /v1/sessions — create', () => { +describe('POST /api/v1/sessions — create', () => { it('returns a Session payload with snake_case + ISO Z timestamps', async () => { const r = await bootDaemon(); const cwd = join(tmpDir, 'workspace-create'); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd }, title: 'created via test' }, }); expect(res.statusCode).toBe(200); @@ -108,7 +108,7 @@ describe('POST /v1/sessions — create', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { title: 'no cwd' }, }); expect(res.statusCode).toBe(200); @@ -123,15 +123,15 @@ describe('POST /v1/sessions — create', () => { }); }); -describe('GET /v1/sessions — list', () => { +describe('GET /api/v1/sessions — list', () => { it('returns Page with has_more=false when fewer than page_size entries exist', async () => { const r = await bootDaemon(); const cwd1 = join(tmpDir, 'workspace-list-1'); const cwd2 = join(tmpDir, 'workspace-list-2'); - await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: cwd1 } } }); - await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: cwd2 } } }); + await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: cwd1 } } }); + await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: cwd2 } } }); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' }); expect(res.statusCode).toBe(200); const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); expect(env.code).toBe(0); @@ -146,11 +146,11 @@ describe('GET /v1/sessions — list', () => { it('honors page_size and surfaces has_more', async () => { const r = await bootDaemon(); - await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-a') } } }); - await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-b') } } }); - await appOf(r).inject({ method: 'POST', url: '/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-c') } } }); + await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-a') } } }); + await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-b') } } }); + await appOf(r).inject({ method: 'POST', url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'ws-c') } } }); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions?page_size=2' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?page_size=2' }); const env = envelopeOf<{ items: unknown[]; has_more: boolean }>(res.json()); expect(env.code).toBe(0); expect(env.data!.items).toHaveLength(2); @@ -159,7 +159,7 @@ describe('GET /v1/sessions — list', () => { it('rejects page_size=0 (out of range)', async () => { const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/sessions?page_size=0' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?page_size=0' }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); }); @@ -168,27 +168,27 @@ describe('GET /v1/sessions — list', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions?before_id=a&after_id=b', + url: '/api/v1/sessions?before_id=a&after_id=b', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); }); }); -describe('GET /v1/sessions/{session_id} — fetch single', () => { +describe('GET /api/v1/sessions/{session_id} — fetch single', () => { it('returns the matching Session', async () => { const r = await bootDaemon(); const cwd = join(tmpDir, 'workspace-get'); const createRes = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd } }, }); const created = envelopeOf<{ id: string }>(createRes.json()).data!; const getRes = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${created.id}`, + url: `/api/v1/sessions/${created.id}`, }); const env = envelopeOf(getRes.json()); expect(env.code).toBe(0); @@ -201,7 +201,7 @@ describe('GET /v1/sessions/{session_id} — fetch single', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions/sess_does_not_exist', + url: '/api/v1/sessions/sess_does_not_exist', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); @@ -210,21 +210,21 @@ describe('GET /v1/sessions/{session_id} — fetch single', () => { }); }); -describe('PATCH /v1/sessions/{session_id} — update', () => { +describe('PATCH /api/v1/sessions/{session_id} — update', () => { it('updates the title and returns the post-update Session', async () => { const r = await bootDaemon(); const cwd = join(tmpDir, 'workspace-patch'); const created = envelopeOf<{ id: string }>( (await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd } }, })).json(), ).data!; const res = await appOf(r).inject({ method: 'PATCH', - url: `/v1/sessions/${created.id}`, + url: `/api/v1/sessions/${created.id}`, payload: { title: 'Renamed' }, }); const env = envelopeOf(res.json()); @@ -239,7 +239,7 @@ describe('PATCH /v1/sessions/{session_id} — update', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'PATCH', - url: '/v1/sessions/sess_missing', + url: '/api/v1/sessions/sess_missing', payload: { title: 'x' }, }); const env = envelopeOf(res.json()); @@ -247,21 +247,21 @@ describe('PATCH /v1/sessions/{session_id} — update', () => { }); }); -describe('DELETE /v1/sessions/{session_id} — delete', () => { +describe('DELETE /api/v1/sessions/{session_id} — delete', () => { it('returns { deleted: true } envelope', async () => { const r = await bootDaemon(); const cwd = join(tmpDir, 'workspace-delete'); const created = envelopeOf<{ id: string }>( (await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd } }, })).json(), ).data!; const res = await appOf(r).inject({ method: 'DELETE', - url: `/v1/sessions/${created.id}`, + url: `/api/v1/sessions/${created.id}`, }); const env = envelopeOf<{ deleted: boolean }>(res.json()); expect(env.code).toBe(0); @@ -272,7 +272,7 @@ describe('DELETE /v1/sessions/{session_id} — delete', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'DELETE', - url: '/v1/sessions/sess_missing', + url: '/api/v1/sessions/sess_missing', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); diff --git a/packages/daemon/test/swagger.e2e.test.ts b/packages/daemon/test/swagger.e2e.test.ts new file mode 100644 index 000000000..22ef37e90 --- /dev/null +++ b/packages/daemon/test/swagger.e2e.test.ts @@ -0,0 +1,88 @@ +/** + * OpenAPI / Swagger UI smoke test. + * + * Asserts that `@fastify/swagger` and `@fastify/swagger-ui` are wired + * correctly and that the generated OpenAPI document covers the daemon's + * REST surface. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-swagger-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-swagger-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown; payload: string }>; + }; + }); +} + +describe('Swagger / OpenAPI', () => { + it('/documentation/json returns a valid OpenAPI document', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/documentation/json' }); + expect(res.statusCode).toBe(200); + + const doc = res.json() as Record; + expect(doc.openapi).toMatch(/^3\.\d+\.\d+$/); + expect(typeof doc.info).toBe('object'); + expect((doc.info as Record)['title']).toBe('Kimi Code Daemon API'); + expect(typeof (doc.info as Record)['version']).toBe('string'); + + const paths = doc.paths as Record; + expect(paths['/api/v1/healthz']).toBeDefined(); + expect(paths['/api/v1/meta']).toBeDefined(); + expect(paths['/api/v1/sessions']).toBeDefined(); + expect(paths['/api/v1/tools']).toBeDefined(); + expect(paths['/api/v1/files']).toBeDefined(); + }); + + it('/documentation returns the Swagger UI HTML', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/documentation' }); + expect(res.statusCode).toBe(200); + expect(res.payload).toContain('swagger-ui'); + }); +}); diff --git a/packages/daemon/test/tasks.e2e.test.ts b/packages/daemon/test/tasks.e2e.test.ts index 7bdaee0f8..9844be7f7 100644 --- a/packages/daemon/test/tasks.e2e.test.ts +++ b/packages/daemon/test/tasks.e2e.test.ts @@ -2,9 +2,9 @@ * Background Tasks end-to-end tests (W9.2 / Chain 8 / P1.8). * * Covers REST.md §3.7: - * - GET /v1/sessions/{sid}/tasks → envelope + items[] - * - GET /v1/sessions/{sid}/tasks/{tid} → BackgroundTask, 40406 unknown - * - POST /v1/sessions/{sid}/tasks/{tid}:cancel → {cancelled:true}, + * - GET /api/v1/sessions/{sid}/tasks → envelope + items[] + * - GET /api/v1/sessions/{sid}/tasks/{tid} → BackgroundTask, 40406 unknown + * - POST /api/v1/sessions/{sid}/tasks/{tid}:cancel → {cancelled:true}, * 40406 unknown id, * 40904 already finished * - Negative: session_id unknown → 40401 @@ -104,7 +104,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -145,12 +145,12 @@ function overrideTaskService( ix._instances.set(ITaskService, replacement); } -describe('GET /v1/sessions/{sid}/tasks', () => { +describe('GET /api/v1/sessions/{sid}/tasks', () => { it('returns 40401 for an unknown session_id', async () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/sessions/does-not-exist/tasks', + url: '/api/v1/sessions/does-not-exist/tasks', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); @@ -161,7 +161,7 @@ describe('GET /v1/sessions/{sid}/tasks', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/tasks`, + url: `/api/v1/sessions/${sid}/tasks`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -175,20 +175,20 @@ describe('GET /v1/sessions/{sid}/tasks', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/tasks?status=pending`, + url: `/api/v1/sessions/${sid}/tasks?status=pending`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); }); }); -describe('GET /v1/sessions/{sid}/tasks/{tid}', () => { +describe('GET /api/v1/sessions/{sid}/tasks/{tid}', () => { it('returns 40406 for an unknown task_id (real session, empty tasks)', async () => { const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/${sid}/tasks/does-not-exist`, + url: `/api/v1/sessions/${sid}/tasks/does-not-exist`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40406); @@ -198,20 +198,20 @@ describe('GET /v1/sessions/{sid}/tasks/{tid}', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/sessions/unknown/tasks/anything`, + url: `/api/v1/sessions/unknown/tasks/anything`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); }); }); -describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { +describe('POST /api/v1/sessions/{sid}/tasks/{tid}:cancel', () => { it('returns 40406 for an unknown task_id', async () => { const r = await bootDaemon(); const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/tasks/nope:cancel`, + url: `/api/v1/sessions/${sid}/tasks/nope:cancel`, payload: {}, }); const env = envelopeOf(res.json()); @@ -223,7 +223,7 @@ describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/tasks/abc123`, + url: `/api/v1/sessions/${sid}/tasks/abc123`, payload: {}, }); const env = envelopeOf(res.json()); @@ -236,7 +236,7 @@ describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/tasks/abc:bogus`, + url: `/api/v1/sessions/${sid}/tasks/abc:bogus`, payload: {}, }); const env = envelopeOf(res.json()); @@ -253,7 +253,7 @@ describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { }); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/tasks/t_finished:cancel`, + url: `/api/v1/sessions/${sid}/tasks/t_finished:cancel`, payload: {}, }); const env = envelopeOf<{ cancelled: false }>(res.json()); @@ -273,7 +273,7 @@ describe('POST /v1/sessions/{sid}/tasks/{tid}:cancel', () => { }); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/tasks/t_running:cancel`, + url: `/api/v1/sessions/${sid}/tasks/t_running:cancel`, payload: {}, }); const env = envelopeOf<{ cancelled: true }>(res.json()); diff --git a/packages/daemon/test/tools.e2e.test.ts b/packages/daemon/test/tools.e2e.test.ts index 61a1a51de..650ce8ead 100644 --- a/packages/daemon/test/tools.e2e.test.ts +++ b/packages/daemon/test/tools.e2e.test.ts @@ -2,11 +2,11 @@ * Tools + MCP end-to-end tests (W9.1 / Chain 7 / P1.7). * * Coverage: - * - GET /v1/tools → envelope shape + tools[] - * - GET /v1/mcp/servers → envelope shape + servers[] - * - POST /v1/mcp/servers/{id}:restart → {restarting:true} on a real + * - GET /api/v1/tools → envelope shape + tools[] + * - GET /api/v1/mcp/servers → envelope shape + servers[] + * - POST /api/v1/mcp/servers/{id}:restart → {restarting:true} on a real * server / 40408 on unknown - * - POST /v1/mcp/servers/foo:bogus → 40001 unsupported action + * - POST /api/v1/mcp/servers/foo:bogus → 40001 unsupported action * * **Bootstrap strategy**: spawn the real daemon and create one session so the * agent-core `getTools` / `listMcpServers` can dispatch (those calls live on @@ -90,7 +90,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -100,10 +100,10 @@ async function createSession(r: RunningDaemon): Promise { return env.data.id; } -describe('GET /v1/tools', () => { +describe('GET /api/v1/tools', () => { it('returns an envelope with {tools: ToolDescriptor[]} (empty list pre-session)', async () => { const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/tools' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/tools' }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); expect(env.code).toBe(0); @@ -115,7 +115,7 @@ describe('GET /v1/tools', () => { it('returns a populated list after a session exists (response data round-trips through schema)', async () => { const r = await bootDaemon(); await createSession(r); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/tools' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/tools' }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); expect(env.code).toBe(0); @@ -131,7 +131,7 @@ describe('GET /v1/tools', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'GET', - url: `/v1/tools?session_id=${sid}`, + url: `/api/v1/tools?session_id=${sid}`, }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); @@ -143,18 +143,18 @@ describe('GET /v1/tools', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'GET', - url: '/v1/tools?session_id=', + url: '/api/v1/tools?session_id=', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40001); }); }); -describe('GET /v1/mcp/servers', () => { +describe('GET /api/v1/mcp/servers', () => { it('returns an envelope with {servers: McpServer[]} (typically empty in sandboxed home)', async () => { const r = await bootDaemon(); await createSession(r); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/mcp/servers' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/mcp/servers' }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); expect(env.code).toBe(0); @@ -164,7 +164,7 @@ describe('GET /v1/mcp/servers', () => { it('returns 200 with empty list even before any session is created', async () => { const r = await bootDaemon(); - const res = await appOf(r).inject({ method: 'GET', url: '/v1/mcp/servers' }); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/mcp/servers' }); expect(res.statusCode).toBe(200); const env = envelopeOf(res.json()); expect(env.code).toBe(0); @@ -173,13 +173,13 @@ describe('GET /v1/mcp/servers', () => { }); }); -describe('POST /v1/mcp/servers/{id}:restart', () => { +describe('POST /api/v1/mcp/servers/{id}:restart', () => { it('returns 40408 mcp.server_not_found for an unknown server id', async () => { const r = await bootDaemon(); await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/mcp/servers/does-not-exist:restart', + url: '/api/v1/mcp/servers/does-not-exist:restart', payload: {}, }); const env = envelopeOf(res.json()); @@ -191,7 +191,7 @@ describe('POST /v1/mcp/servers/{id}:restart', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/mcp/servers/x:restart', + url: '/api/v1/mcp/servers/x:restart', payload: {}, }); const env = envelopeOf(res.json()); @@ -202,7 +202,7 @@ describe('POST /v1/mcp/servers/{id}:restart', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/mcp/servers/foo:bogus', + url: '/api/v1/mcp/servers/foo:bogus', payload: {}, }); const env = envelopeOf(res.json()); @@ -214,7 +214,7 @@ describe('POST /v1/mcp/servers/{id}:restart', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/mcp/servers/foo', + url: '/api/v1/mcp/servers/foo', payload: {}, }); const env = envelopeOf(res.json()); diff --git a/packages/daemon/test/ws-abort.e2e.test.ts b/packages/daemon/test/ws-abort.e2e.test.ts index 22cff5676..9df483024 100644 --- a/packages/daemon/test/ws-abort.e2e.test.ts +++ b/packages/daemon/test/ws-abort.e2e.test.ts @@ -9,7 +9,7 @@ * 2. WS `abort` idempotency: second abort returns * `code: 0, payload.aborted: false` (per WS.md §3.4 convention — * NOT REST's 40903, intentional). - * 3. REST `POST /v1/sessions/{sid}/prompts/{pid}:abort`: + * 3. REST `POST /api/v1/sessions/{sid}/prompts/{pid}:abort`: * - Returns `{aborted: true}` on first call. * - Returns `code: 40903 + data: {aborted: false}` on second * (idempotent already-completed) per REST.md §3.5. @@ -97,7 +97,7 @@ function envelopeOf(body: unknown): { async function createSession(r: RunningDaemon): Promise { const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions', + url: '/api/v1/sessions', payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, }); const env = envelopeOf<{ id: string }>(res.json()); @@ -125,7 +125,7 @@ interface Subscriber { } async function openSubscriber(r: RunningDaemon, sid: string): Promise { - const wsUrl = r.address.replace('http://', 'ws://') + '/v1/ws'; + const wsUrl = r.address.replace('http://', 'ws://') + '/api/v1/ws'; const received: Record[] = []; const ws = await new Promise((resolve, reject) => { const sock = new WebSocket(wsUrl); @@ -263,7 +263,7 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { injectActivePrompt(r, sid, promptId, 1); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + url: `/api/v1/sessions/${sid}/prompts/${promptId}:abort`, }); const env = envelopeOf<{ aborted: boolean }>(res.json()); expect(env.code).toBe(0); @@ -277,11 +277,11 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { injectActivePrompt(r, sid, promptId, 2); await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + url: `/api/v1/sessions/${sid}/prompts/${promptId}:abort`, }); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + url: `/api/v1/sessions/${sid}/prompts/${promptId}:abort`, }); const env = envelopeOf<{ aborted: boolean }>(res.json()); expect(env.code).toBe(40903); @@ -292,7 +292,7 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { const r = await bootDaemon(); const res = await appOf(r).inject({ method: 'POST', - url: '/v1/sessions/sess_missing/prompts/prompt_X:abort', + url: '/api/v1/sessions/sess_missing/prompts/prompt_X:abort', }); const env = envelopeOf(res.json()); expect(env.code).toBe(40401); @@ -303,7 +303,7 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { const sid = await createSession(r); const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/prompt_missing:abort`, + url: `/api/v1/sessions/${sid}/prompts/prompt_missing:abort`, }); const env = envelopeOf(res.json()); expect(env.code).toBe(40402); @@ -318,7 +318,7 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { // First abort via REST → success. const rest = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + url: `/api/v1/sessions/${sid}/prompts/${promptId}:abort`, }); expect(envelopeOf<{ aborted: boolean }>(rest.json()).data?.aborted).toBe(true); @@ -360,7 +360,7 @@ describe('REST abort + REST/WS symmetry (W7.3 / Chain 4b)', () => { // Second abort via REST — must surface 40903 already_completed. const res = await appOf(r).inject({ method: 'POST', - url: `/v1/sessions/${sid}/prompts/${promptId}:abort`, + url: `/api/v1/sessions/${sid}/prompts/${promptId}:abort`, }); const env = envelopeOf<{ aborted: boolean }>(res.json()); expect(env.code).toBe(40903); diff --git a/packages/daemon/test/ws-broadcast.e2e.test.ts b/packages/daemon/test/ws-broadcast.e2e.test.ts index 3fdc87fad..64ff356b8 100644 --- a/packages/daemon/test/ws-broadcast.e2e.test.ts +++ b/packages/daemon/test/ws-broadcast.e2e.test.ts @@ -69,7 +69,7 @@ async function spawn(): Promise { } function wsUrl(http: string): string { - return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; } interface WsFrame { diff --git a/packages/daemon/test/ws-handshake.e2e.test.ts b/packages/daemon/test/ws-handshake.e2e.test.ts index 7bde91263..11dc68154 100644 --- a/packages/daemon/test/ws-handshake.e2e.test.ts +++ b/packages/daemon/test/ws-handshake.e2e.test.ts @@ -68,7 +68,7 @@ async function spawn(): Promise { } function wsUrl(http: string): string { - return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; } interface WsFrame { @@ -230,9 +230,9 @@ describe('WS gateway handshake + heartbeat (W5.1)', () => { expect(code).toBe(1001); }); - it('non-/v1/ws upgrade requests are rejected', async () => { + it('non-/api/v1/ws upgrade requests are rejected', async () => { const r = await spawn(); - const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/v1/other'; + const badUrl = r.address.replace(/^http:\/\//, 'ws://') + '/api/v1/other'; await expect(openConn(badUrl)).rejects.toBeInstanceOf(Error); }); }); diff --git a/packages/daemon/test/ws-resync.e2e.test.ts b/packages/daemon/test/ws-resync.e2e.test.ts index c1f34e17a..8a0d561ab 100644 --- a/packages/daemon/test/ws-resync.e2e.test.ts +++ b/packages/daemon/test/ws-resync.e2e.test.ts @@ -79,7 +79,7 @@ async function spawn(): Promise { } function wsUrl(http: string): string { - return http.replace(/^http:\/\//, 'ws://') + '/v1/ws'; + return http.replace(/^http:\/\//, 'ws://') + '/api/v1/ws'; } interface WsFrame { diff --git a/packages/services/src/impls/mcp-service-impl.ts b/packages/services/src/impls/mcp-service-impl.ts index f59372fea..47dd21bd7 100644 --- a/packages/services/src/impls/mcp-service-impl.ts +++ b/packages/services/src/impls/mcp-service-impl.ts @@ -7,7 +7,7 @@ * `tool-adapter.toProtocolMcpServer`. * * **agent-core API note**: `listMcpServers` is exposed on the SessionAPI - * (per-session). Per REST.md §3.8 the wire endpoint `/v1/mcp/servers` is + * (per-session). Per REST.md §3.8 the wire endpoint `/api/v1/mcp/servers` is * GLOBAL (not session-scoped). We pass the agent-core implicit session id * `'__global__'` via the bridge — but agent-core's `listMcpServers` actually * reads from the in-process MCP registrar which is process-global today, so @@ -20,7 +20,7 @@ * the global REST surface we accept ANY known session id; the daemon route * can pass a probe (e.g. first session from `listSessions`) or — when no * sessions exist — return an empty list. We implement the latter to keep the - * daemon's `/v1/mcp/servers` 200-OK before any session is created. + * daemon's `/api/v1/mcp/servers` 200-OK before any session is created. * * **Reconnect**: `bridge.rpc.reconnectMcpServer({name, sessionId})` likewise * needs a session anchor. We forward the route-supplied `sessionId` (the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e07ca7b5f..fa91e1342 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,12 @@ importers: '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 '@moonshot-ai/agent-core': specifier: workspace:^ version: link:../agent-core @@ -371,6 +377,9 @@ importers: zod: specifier: 'catalog:' version: 4.3.6 + zod-to-json-schema: + specifier: ^3.25.2 + version: 3.25.2(zod@4.3.6) devDependencies: '@types/ws': specifier: ^8.18.0 @@ -1310,6 +1319,9 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -1337,6 +1349,18 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@google/genai@1.49.0': resolution: {integrity: sha512-hO69Zl0H3x+L0KL4stl1pLYgnqnwHoLqtKy6MRlNnW8TAxjqMdOUVafomKd4z1BePkzoxJWbYILny9a2Zk43VQ==} engines: {node: '>=20.0.0'} @@ -1393,6 +1417,10 @@ packages: '@loaderkit/resolve@1.0.4': resolution: {integrity: sha512-rJzYKVcV4dxJv+vW6jlvagF8zvGxHJ2+HTr1e2qOejfmGhAApgJHl8Aog4mMszxceTRiKTTbnpgmTO1bEZHV/A==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -3665,6 +3693,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -4023,6 +4055,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -4359,6 +4395,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -4506,6 +4547,9 @@ packages: zod: optional: true + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -4584,6 +4628,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -6556,6 +6604,8 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.18.0 @@ -6591,6 +6641,41 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.3 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.3 + transitivePeerDependencies: + - supports-color + '@google/genai@1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -6654,6 +6739,8 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@lukeed/ms@2.0.2': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.2 @@ -8884,6 +8971,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -9226,6 +9319,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.0 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.2 @@ -9678,6 +9779,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + mimic-function@5.0.1: {} minimatch@10.2.3: @@ -9799,6 +9902,8 @@ snapshots: ws: 8.20.0 zod: 4.3.6 + openapi-types@12.1.3: {} + outdent@0.5.0: {} own-keys@1.0.1: @@ -9884,6 +9989,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-to-regexp@8.4.2: {} From 27e5c320e3fb405e14d66a2a36ef82529a6588fb Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 5 Jun 2026 15:49:28 +0800 Subject: [PATCH 003/255] feat(auth): add readiness probe and OAuth device-code flow endpoints - add GET /v1/auth readiness probe with authSummarySchema (P2.1)\n- add /v1/oauth/* device-code flow start/poll/cancel/logout routes (P2.7)\n- add IAuthSummaryService and IOAuthService implementations\n- gate prompt submission behind auth readiness (4011x error codes)\n- add e2e tests for auth and OAuth endpoints\n- add protocol schemas and rest-auth unit tests --- packages/daemon/src/routes/auth.ts | 53 +++ packages/daemon/src/routes/oauth.ts | 167 +++++++++ packages/daemon/src/routes/prompts.ts | 51 +++ packages/daemon/src/start.ts | 55 +++ packages/daemon/test/auth.e2e.test.ts | 349 ++++++++++++++++++ packages/daemon/test/oauth.e2e.test.ts | 342 +++++++++++++++++ packages/services/package.json | 1 + .../src/impls/auth-summary-service-impl.ts | 170 +++++++++ .../services/src/impls/oauth-service-impl.ts | 323 ++++++++++++++++ .../services/src/impls/prompt-service-impl.ts | 7 + packages/services/src/index.ts | 8 + .../src/interfaces/auth-summary-service.ts | 104 ++++++ packages/services/src/interfaces/index.ts | 8 + .../services/src/interfaces/oauth-service.ts | 67 ++++ packages/services/test/oauth-service.test.ts | 336 +++++++++++++++++ packages/services/test/prompt-service.test.ts | 48 ++- pnpm-lock.yaml | 3 + 17 files changed, 2078 insertions(+), 14 deletions(-) create mode 100644 packages/daemon/src/routes/auth.ts create mode 100644 packages/daemon/src/routes/oauth.ts create mode 100644 packages/daemon/test/auth.e2e.test.ts create mode 100644 packages/daemon/test/oauth.e2e.test.ts create mode 100644 packages/services/src/impls/auth-summary-service-impl.ts create mode 100644 packages/services/src/impls/oauth-service-impl.ts create mode 100644 packages/services/src/interfaces/auth-summary-service.ts create mode 100644 packages/services/src/interfaces/oauth-service.ts create mode 100644 packages/services/test/oauth-service.test.ts diff --git a/packages/daemon/src/routes/auth.ts b/packages/daemon/src/routes/auth.ts new file mode 100644 index 000000000..285f0a2f2 --- /dev/null +++ b/packages/daemon/src/routes/auth.ts @@ -0,0 +1,53 @@ +/** + * `GET /v1/auth` — readiness probe (P2.1 D2 / REST.md §3). + * + * Single权威 readiness signal that web/IDE clients hit on first paint to + * decide between onboarding vs. chat UI. Returns 200 + envelope regardless + * of provider state — failure modes for downstream entries (prompt submit, + * model PATCH) carry `40110/40111/40112/40113` codes; this probe never + * fails on auth. + * + * **No DI for input** — the route just resolves `IAuthSummaryService` from + * the accessor and forwards the snapshot. Same structural shape as + * `meta.ts`'s `RouteHost`. + * + * **Anti-corruption**: no SDK package imports; `IAuthSummaryService` is the + * services-package façade. + */ + +import { authSummarySchema } from '@moonshot-ai/protocol'; +import { IAuthSummaryService } from '@moonshot-ai/services'; +import type { IInstantiationService } from '@moonshot-ai/agent-core'; + +import { okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; + +interface RouteHost { + get( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +export function registerAuthRoute(app: RouteHost, ix: IInstantiationService): void { + app.get( + '/auth', + { + schema: buildRouteSchema({ + description: 'Get daemon auth readiness snapshot', + tags: ['auth'], + response: { 200: authSummarySchema }, + }), + }, + async (req, reply) => { + const summary = await ix.invokeFunction((a) => + a.get(IAuthSummaryService).get(), + ); + reply.send(okEnvelope(summary, req.id)); + }, + ); +} diff --git a/packages/daemon/src/routes/oauth.ts b/packages/daemon/src/routes/oauth.ts new file mode 100644 index 000000000..88ec34d0c --- /dev/null +++ b/packages/daemon/src/routes/oauth.ts @@ -0,0 +1,167 @@ +/** + * `/v1/oauth/*` REST routes (P2.7). + * + * POST /v1/oauth/login start a device-code flow → OAuthFlowStart + * GET /v1/oauth/login poll current flow state → OAuthFlowSnapshot | null + * DELETE /v1/oauth/login cancel pending flow → { cancelled, status } + * POST /v1/oauth/logout logout → { logged_out, provider } + * + * **Polling contract**: the frontend opens `verification_uri_complete` in a + * browser tab, then polls `GET /v1/oauth/login` at the `interval` seconds + * returned in the start response. When `status` flips to `'authenticated'`, + * stop polling and hit `GET /v1/auth` to see `ready: true`. + * + * **No bare flow_id in URL**: PLAN D6.4 says one in-flight per provider. The + * frontend has the flow_id from the start response — it uses it client-side + * to detect "the flow I started got superseded" (matching the snapshot's + * flow_id against its own captured value). + */ + +import { + oauthFlowSnapshotSchema, + oauthFlowStartSchema, + oauthLoginCancelResponseSchema, + oauthLoginQuerySchema, + oauthLoginStartRequestSchema, + oauthLogoutRequestSchema, + oauthLogoutResponseSchema, +} from '@moonshot-ai/protocol'; +import { IOAuthService } from '@moonshot-ai/services'; +import type { + OAuthLoginStartRequest, + OAuthLoginQuery, + OAuthLogoutRequest, +} from '@moonshot-ai/protocol'; +import type { IInstantiationService } from '@moonshot-ai/agent-core'; +import { z } from 'zod'; + +import { okEnvelope } from '../envelope.js'; +import { buildRouteSchema } from '../middleware/schema.js'; +import { validateBody, validateQuery } from '../middleware/validate.js'; + +/** + * Structural Fastify subset — same shape as `meta.ts` / `auth.ts` so the + * generic-mismatch with the daemon's pino-typed FastifyInstance doesn't + * bleed into this file. + */ +interface RouteHost { + get( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + delete( + path: string, + options: { preHandler?: unknown[]; schema?: Record }, + handler: ( + req: { id: string; query: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; +} + +/** + * `GET /v1/oauth/login` returns either a snapshot or `null` (no flow yet). + * Wrap in a nullable z.object so the generated OpenAPI knows about both. + */ +const oauthFlowSnapshotOrNullSchema = z.union([ + oauthFlowSnapshotSchema, + z.null(), +]); + +export function registerOAuthRoutes(app: RouteHost, ix: IInstantiationService): void { + // POST /oauth/login — start device flow ---------------------------------- + app.post( + '/oauth/login', + { + preHandler: [validateBody(oauthLoginStartRequestSchema)], + schema: buildRouteSchema({ + description: 'Start an OAuth device-code flow', + tags: ['auth'], + body: oauthLoginStartRequestSchema, + response: { 200: oauthFlowStartSchema }, + }), + }, + async (req, reply) => { + const body = req.body as OAuthLoginStartRequest; + const result = await ix.invokeFunction((a) => + a.get(IOAuthService).startLogin(body.provider), + ); + reply.send(okEnvelope(result, req.id)); + }, + ); + + // GET /oauth/login — poll current flow state ----------------------------- + app.get( + '/oauth/login', + { + preHandler: [validateQuery(oauthLoginQuerySchema)], + schema: buildRouteSchema({ + description: 'Poll the current OAuth device-code flow', + tags: ['auth'], + querystring: oauthLoginQuerySchema, + response: { 200: oauthFlowSnapshotOrNullSchema }, + }), + }, + async (req, reply) => { + const query = req.query as OAuthLoginQuery; + const snapshot = ix.invokeFunction((a) => + a.get(IOAuthService).getFlow(query.provider), + ); + reply.send(okEnvelope(snapshot ?? null, req.id)); + }, + ); + + // DELETE /oauth/login — cancel pending flow ------------------------------ + app.delete( + '/oauth/login', + { + preHandler: [validateQuery(oauthLoginQuerySchema)], + schema: buildRouteSchema({ + description: 'Cancel the current OAuth device-code flow', + tags: ['auth'], + querystring: oauthLoginQuerySchema, + response: { 200: oauthLoginCancelResponseSchema }, + }), + }, + async (req, reply) => { + const query = req.query as OAuthLoginQuery; + const result = await ix.invokeFunction((a) => + a.get(IOAuthService).cancelLogin(query.provider), + ); + reply.send(okEnvelope(result, req.id)); + }, + ); + + // POST /oauth/logout ----------------------------------------------------- + app.post( + '/oauth/logout', + { + preHandler: [validateBody(oauthLogoutRequestSchema)], + schema: buildRouteSchema({ + description: 'Logout the managed OAuth provider', + tags: ['auth'], + body: oauthLogoutRequestSchema, + response: { 200: oauthLogoutResponseSchema }, + }), + }, + async (req, reply) => { + const body = req.body as OAuthLogoutRequest; + const result = await ix.invokeFunction((a) => + a.get(IOAuthService).logout(body.provider), + ); + reply.send(okEnvelope(result, req.id)); + }, + ); +} diff --git a/packages/daemon/src/routes/prompts.ts b/packages/daemon/src/routes/prompts.ts index e7756e6bf..d4197ed45 100644 --- a/packages/daemon/src/routes/prompts.ts +++ b/packages/daemon/src/routes/prompts.ts @@ -33,6 +33,10 @@ import { } from '@moonshot-ai/protocol'; import { IPromptService, + AuthModelNotResolvedError, + AuthProvisioningRequiredError, + AuthTokenMissingError, + AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, @@ -204,5 +208,52 @@ function sendMappedError( reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); return; } + // P2.1 D1 — readiness gate failures. The envelope shape mirrors + // PLAN.md §3.1.4: `code` is the auth sub-code, `data: null`, `details` + // carries `{provider_id?, model_id?}` so clients can route onboarding + // without parsing `msg`. + if (err instanceof AuthProvisioningRequiredError) { + reply.send({ + code: ErrorCode.AUTH_PROVISIONING_REQUIRED, + msg: err.message, + data: null, + request_id: requestId, + details: null, + }); + return; + } + if (err instanceof AuthTokenMissingError) { + reply.send({ + code: ErrorCode.AUTH_TOKEN_MISSING, + msg: err.message, + data: null, + request_id: requestId, + details: { provider_id: err.providerId }, + }); + return; + } + if (err instanceof AuthTokenUnauthorizedError) { + reply.send({ + code: ErrorCode.AUTH_TOKEN_UNAUTHORIZED, + msg: err.message, + data: null, + request_id: requestId, + details: { provider_id: err.providerId }, + }); + return; + } + if (err instanceof AuthModelNotResolvedError) { + const details: Record = {}; + if (err.modelId !== undefined) details['model_id'] = err.modelId; + if (err.providerId !== undefined) details['provider_id'] = err.providerId; + reply.send({ + code: ErrorCode.AUTH_MODEL_NOT_RESOLVED, + msg: err.message, + data: null, + request_id: requestId, + details: Object.keys(details).length === 0 ? null : details, + }); + return; + } throw err; } diff --git a/packages/daemon/src/start.ts b/packages/daemon/src/start.ts index 3f69fce7a..e2c17ea67 100644 --- a/packages/daemon/src/start.ts +++ b/packages/daemon/src/start.ts @@ -2,14 +2,19 @@ import { InstantiationService, ServiceCollection, SyncDescriptor, + resolveConfigPath, + resolveKimiHome, } from '@moonshot-ai/agent-core'; import { + AuthSummaryServiceImpl, HarnessBridge, IApprovalBroker, + IAuthSummaryService, IEventBus, IHarnessBridge, IMcpService, IMessageService, + IOAuthService, IPromptService, IQuestionBroker, ISessionService, @@ -17,6 +22,7 @@ import { IToolService, McpServiceImpl, MessageServiceImpl, + OAuthServiceImpl, PromptServiceImpl, SessionNotFoundError, SessionServiceImpl, @@ -43,6 +49,8 @@ import { registerMessagesRoutes } from './routes/messages.js'; import { registerMetaRoute } from './routes/meta.js'; import { registerPromptsRoutes } from './routes/prompts.js'; import { registerApprovalsRoutes } from './routes/approvals.js'; +import { registerAuthRoute } from './routes/auth.js'; +import { registerOAuthRoutes } from './routes/oauth.js'; import { registerQuestionsRoutes } from './routes/questions.js'; import { registerSessionsRoutes } from './routes/sessions.js'; import { registerTasksRoutes } from './routes/tasks.js'; @@ -183,6 +191,7 @@ export async function startDaemon(opts: DaemonStartOptions): Promise[0], ix); + + // P2.7 / Chain P2.7.1 — `/oauth/*`. Device-code flow start / poll / + // cancel + logout. Grouped under the `auth` swagger tag since they're + // all login-related; the URL prefix `/oauth` keeps them out of + // `/auth`'s pure-readout namespace. + registerOAuthRoutes(apiV1 as unknown as Parameters[0], ix); + // W6.2 / Chain 2 — register `/sessions/*` routes. The route module // captures `ix` by reference; per-request `accessor.get(ISessionService)` // dispatches against whatever's in the container at that moment. We @@ -441,6 +461,41 @@ export async function startDaemon(opts: DaemonStartOptions): Promise/config.toml` BEFORE calling + * `startDaemon` so KimiCore loads it on construction. The `homeDir` we pass + * via `bridgeOptions.homeDir` is also what `AuthSummaryServiceImpl` uses to + * locate the credential dir — keeping the file paths in lockstep with prod. + * + * **Anti-corruption**: tests only use the public REST surface + `RunningDaemon` + * accessor. No reaching into `IPromptService._injectActiveForTest` like the + * lifecycle test — we want the real ensureReady → bridge.rpc.prompt path. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-auth-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-auth-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +async function bootDaemon(): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +/** + * Seed `/config.toml` BEFORE daemon boot. Path layout matches + * `resolveConfigPath({homeDir})` exactly so KimiCore + AuthSummaryService + * load the same file. + */ +function seedConfig(toml: string): void { + writeFileSync(join(bridgeHome, 'config.toml'), toml, 'utf-8'); +} + +async function createSession(r: RunningDaemon): Promise { + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd: join(tmpDir, 'workspace') } }, + }); + const env = envelopeOf<{ id: string }>(res.json()); + if (env.code !== 0 || env.data === null) { + throw new Error(`create session failed: ${JSON.stringify(env)}`); + } + return env.data.id; +} + +/* -------------------------------------------------------------------- */ +/* GET /v1/auth — readiness snapshot */ +/* -------------------------------------------------------------------- */ + +describe('GET /api/v1/auth — readiness probe (P2.1 D2)', () => { + it('returns ready=false + zero providers on empty config', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const summary = authSummarySchema.parse(env.data); + expect(summary).toEqual({ + ready: false, + providers_count: 0, + default_model: null, + managed_provider: null, + }); + }); + + it('returns ready=false when provider exists but default_model missing', async () => { + seedConfig( + [ + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' }); + const env = envelopeOf(res.json()); + const summary = authSummarySchema.parse(env.data); + expect(summary.ready).toBe(false); + expect(summary.providers_count).toBe(1); + expect(summary.default_model).toBeNull(); + }); + + it('returns ready=true when provider + api_key + default_model are all set', async () => { + seedConfig( + [ + 'default_model = "x"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' }); + const env = envelopeOf(res.json()); + const summary = authSummarySchema.parse(env.data); + expect(summary).toEqual({ + ready: true, + providers_count: 1, + default_model: 'x', + managed_provider: null, + }); + }); + + it('surfaces managed_provider.unauthenticated when config has managed:kimi-code but no cached token', async () => { + seedConfig( + [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://example/v1"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "oauth/kimi-code"', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const res = await appOf(r).inject({ method: 'GET', url: '/api/v1/auth' }); + const env = envelopeOf(res.json()); + const summary = authSummarySchema.parse(env.data); + expect(summary.managed_provider).toEqual({ + name: 'managed:kimi-code', + status: 'unauthenticated', + }); + // ready is still false — no default_model, even though provider exists + expect(summary.ready).toBe(false); + }); +}); + +/* -------------------------------------------------------------------- */ +/* POST /sessions/{sid}/prompts — readiness gate */ +/* -------------------------------------------------------------------- */ + +describe('POST /api/v1/sessions/{sid}/prompts — readiness gate (P2.1 D1)', () => { + it('returns 40110 with details=null on empty config', async () => { + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40110); + expect(env.data).toBeNull(); + expect(env.details).toBeNull(); + }); + + it('returns 40111 with details.provider_id when manual provider has no api_key', async () => { + seedConfig( + [ + 'default_model = "x"', + '', + '[providers.x]', + 'type = "kimi"', + '# no api_key', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40111); + expect(env.data).toBeNull(); + expect(env.details).toEqual({ provider_id: 'x' }); + }); + + it('returns 40113 with details.model_id when default_model alias does not resolve', async () => { + seedConfig( + [ + 'default_model = "missing-alias"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40113); + expect(env.data).toBeNull(); + expect(env.details).toEqual({ model_id: 'missing-alias' }); + }); + + it('returns 40113 when default_model is unset (no model_id detail)', async () => { + seedConfig( + [ + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40113); + // No model_id in details when default is simply unset — clients should + // route to "select a model" UX rather than "this alias is broken". + expect(env.details).toBeNull(); + }); + + it('passes the readiness gate when provider + key + default_model are all set', async () => { + seedConfig( + [ + 'default_model = "x"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const r = await bootDaemon(); + const sid = await createSession(r); + const res = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${sid}/prompts`, + payload: { content: [{ type: 'text', text: 'hello' }] }, + }); + const env = envelopeOf(res.json()); + // The gate passes; bridge.rpc.prompt then runs against the test fixture + // which has no real model wired up. We assert the readiness codes are + // NOT what we see — anything beyond P2.1's scope is "out of band". + expect([40110, 40111, 40112, 40113]).not.toContain(env.code); + }); +}); diff --git a/packages/daemon/test/oauth.e2e.test.ts b/packages/daemon/test/oauth.e2e.test.ts new file mode 100644 index 000000000..144367f5a --- /dev/null +++ b/packages/daemon/test/oauth.e2e.test.ts @@ -0,0 +1,342 @@ +/** + * `/v1/oauth/*` REST endpoints e2e tests (P2.7). + * + * **Strategy**: replace the real `IOAuthService` in the DI container with a + * scripted stub AFTER `startDaemon` returns, so the routes go through their + * full Fastify validation + envelope wrapping but never touch a real OAuth + * host. We keep the `OAuthServiceImpl` itself out of scope here — the + * services-package unit test (`oauth-service.test.ts`) covers its internal + * state machine end-to-end. + * + * Coverage: + * - POST /oauth/login returns 200 + envelope { code:0, data: OAuthFlowStart } + * - GET /oauth/login returns 200 + envelope { code:0, data: null } before start + * - GET /oauth/login returns the snapshot after start + * - DELETE /oauth/login returns { cancelled, status } + * - POST /oauth/logout returns { logged_out: true, provider } + * - body / query schema validation → 40001 + * - device_code never appears in any response body + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { pino } from 'pino'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + oauthFlowSnapshotSchema, + oauthFlowStartSchema, +} from '@moonshot-ai/protocol'; +import { + IOAuthService, +} from '@moonshot-ai/services'; +import type { + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthLoginCancelResponse, + OAuthLogoutResponse, +} from '@moonshot-ai/protocol'; + +import { IRestGateway, startDaemon, type RunningDaemon } from '../src'; + +let tmpDir: string; +let lockPath: string; +let bridgeHome: string; +let daemon: RunningDaemon | undefined; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'kimi-daemon-oauth-test-')); + lockPath = join(tmpDir, 'lock'); + bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-daemon-oauth-home-')); +}); + +afterEach(async () => { + try { + await daemon?.close(); + } catch { + // ignore + } + daemon = undefined; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(bridgeHome, { recursive: true, force: true }); +}); + +interface StubOAuth { + startLogin: (provider?: string) => Promise; + getFlow: (provider?: string) => OAuthFlowSnapshot | undefined; + cancelLogin: (provider?: string) => Promise; + logout: (provider?: string) => Promise; + calls: { + start: Array<{ provider: string | undefined }>; + get: Array<{ provider: string | undefined }>; + cancel: Array<{ provider: string | undefined }>; + logout: Array<{ provider: string | undefined }>; + }; +} + +/** Build a stub service with scripted responses. */ +function makeStub(scripted: { + start?: OAuthFlowStart; + snapshot?: OAuthFlowSnapshot | undefined; + cancel?: OAuthLoginCancelResponse; + logout?: OAuthLogoutResponse; +}): StubOAuth { + const calls = { + start: [] as Array<{ provider: string | undefined }>, + get: [] as Array<{ provider: string | undefined }>, + cancel: [] as Array<{ provider: string | undefined }>, + logout: [] as Array<{ provider: string | undefined }>, + }; + const defaultStart: OAuthFlowStart = { + flow_id: 'oauth_01ABCDEFGH', + provider: 'managed:kimi-code', + verification_uri: 'https://example.com/device', + verification_uri_complete: 'https://example.com/device?user_code=KIMI-1234', + user_code: 'KIMI-1234', + expires_in: 900, + interval: 5, + status: 'pending', + expires_at: '2026-06-05T08:00:00.000Z', + }; + return { + calls, + startLogin: async (provider) => { + calls.start.push({ provider }); + return scripted.start ?? defaultStart; + }, + getFlow: (provider) => { + calls.get.push({ provider }); + return scripted.snapshot; + }, + cancelLogin: async (provider) => { + calls.cancel.push({ provider }); + return scripted.cancel ?? { cancelled: false, status: 'cancelled' }; + }, + logout: async (provider) => { + calls.logout.push({ provider }); + return scripted.logout ?? { logged_out: true, provider: 'managed:kimi-code' }; + }, + }; +} + +async function bootDaemon(stub: StubOAuth): Promise { + daemon = await startDaemon({ + host: '127.0.0.1', + port: 0, + lockPath, + logger: pino({ level: 'silent' }), + bridgeOptions: { homeDir: bridgeHome }, + }); + // Override the IOAuthService in the container post-boot. The container's + // `ServiceCollection` is public; we re-set the slot and also clear the + // `_instances` cache so per-request `accessor.get(IOAuthService)` returns + // the stub instead of the cached real impl. + const ix = daemon.services as unknown as { + services: { set: (id: unknown, v: unknown) => void }; + _instances: Map; + }; + ix.services.set(IOAuthService, stub); + ix._instances.set(IOAuthService, stub); + return daemon; +} + +function appOf(r: RunningDaemon): { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; +} { + return r.services.invokeFunction((a) => { + const gw = a.get(IRestGateway); + return gw.app as unknown as { + inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>; + }; + }); +} + +function envelopeOf(body: unknown): { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} { + return body as { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; + }; +} + +describe('POST /api/v1/oauth/login (P2.7)', () => { + it('returns 200 + envelope { code:0, data: OAuthFlowStart }', async () => { + const stub = makeStub({}); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/oauth/login', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const data = oauthFlowStartSchema.parse(env.data); + expect(data.flow_id).toBe('oauth_01ABCDEFGH'); + expect(data.verification_uri_complete).toBe( + 'https://example.com/device?user_code=KIMI-1234', + ); + expect(stub.calls.start).toEqual([{ provider: undefined }]); + }); + + it('passes through the optional provider field', async () => { + const stub = makeStub({}); + const r = await bootDaemon(stub); + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/oauth/login', + payload: { provider: 'managed:other' }, + }); + expect(stub.calls.start[0]?.provider).toBe('managed:other'); + }); + + it('rejects an invalid provider field with 40001', async () => { + const stub = makeStub({}); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/oauth/login', + payload: { provider: 123 }, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('GET /api/v1/oauth/login (P2.7)', () => { + it('returns 200 + envelope { code:0, data: null } when no flow is registered', async () => { + const stub = makeStub({ snapshot: undefined }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/oauth/login', + }); + expect(res.statusCode).toBe(200); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(env.data).toBeNull(); + }); + + it('returns the snapshot when present', async () => { + const snap: OAuthFlowSnapshot = { + flow_id: 'oauth_01ABCDEFGH', + provider: 'managed:kimi-code', + status: 'pending', + verification_uri: 'https://example.com/device', + verification_uri_complete: 'https://example.com/device?user_code=KIMI-1234', + user_code: 'KIMI-1234', + expires_in: 900, + expires_at: '2026-06-05T08:00:00.000Z', + interval: 5, + }; + const stub = makeStub({ snapshot: snap }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/oauth/login', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + const parsed = oauthFlowSnapshotSchema.parse(env.data); + expect(parsed.status).toBe('pending'); + // Wire must not leak device_code. + expect(JSON.stringify(env)).not.toContain('device_code'); + }); + + it('reflects terminal-state snapshots', async () => { + const snap: OAuthFlowSnapshot = { + flow_id: 'oauth_01ABCDEFGH', + provider: 'managed:kimi-code', + status: 'authenticated', + verification_uri: 'https://example.com/device', + verification_uri_complete: 'https://example.com/device?user_code=KIMI-1234', + user_code: 'KIMI-1234', + expires_in: 900, + expires_at: '2026-06-05T08:00:00.000Z', + interval: 5, + resolved_at: '2026-06-05T07:50:00.000Z', + }; + const stub = makeStub({ snapshot: snap }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/oauth/login', + }); + const env = envelopeOf(res.json()); + const parsed = oauthFlowSnapshotSchema.parse(env.data); + expect(parsed.status).toBe('authenticated'); + expect(parsed.resolved_at).toBe('2026-06-05T07:50:00.000Z'); + }); +}); + +describe('DELETE /api/v1/oauth/login (P2.7)', () => { + it('returns { cancelled:true, status:cancelled } on a pending flow', async () => { + const stub = makeStub({ + cancel: { cancelled: true, status: 'cancelled' }, + }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'DELETE', + url: '/api/v1/oauth/login', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(env.data).toEqual({ cancelled: true, status: 'cancelled' }); + }); + + it('idempotently reports the current status on terminal flows', async () => { + const stub = makeStub({ + cancel: { cancelled: false, status: 'authenticated' }, + }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'DELETE', + url: '/api/v1/oauth/login', + }); + const env = envelopeOf(res.json()); + expect(env.data).toEqual({ cancelled: false, status: 'authenticated' }); + }); +}); + +describe('POST /api/v1/oauth/logout (P2.7)', () => { + it('returns { logged_out:true, provider }', async () => { + const stub = makeStub({}); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/oauth/logout', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(0); + expect(env.data).toEqual({ + logged_out: true, + provider: 'managed:kimi-code', + }); + expect(stub.calls.logout).toHaveLength(1); + }); + + it('passes the provider field through', async () => { + const stub = makeStub({ + logout: { logged_out: true, provider: 'managed:other' }, + }); + const r = await bootDaemon(stub); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/oauth/logout', + payload: { provider: 'managed:other' }, + }); + const env = envelopeOf(res.json()); + expect(env.data?.provider).toBe('managed:other'); + }); +}); diff --git a/packages/services/package.json b/packages/services/package.json index 226239beb..a5e23d5c3 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/protocol": "workspace:^", "ulid": "^3.0.1" diff --git a/packages/services/src/impls/auth-summary-service-impl.ts b/packages/services/src/impls/auth-summary-service-impl.ts new file mode 100644 index 000000000..2ddfffd03 --- /dev/null +++ b/packages/services/src/impls/auth-summary-service-impl.ts @@ -0,0 +1,170 @@ +/** + * `AuthSummaryServiceImpl` — P2.1 D2 readiness probe + write-side gate. + * + * Reads the live config via `IHarnessBridge.rpc.getKimiConfig({})` and the + * managed-OAuth credential state via `KimiAuthFacade.status(...)`. Both are + * cheap (in-process RPC + a token-file existence probe), so we run them on + * every call instead of caching — keeps the staleness window at zero. + * + * **Why import KimiAuthFacade here**: services package already depends on + * `@moonshot-ai/kimi-code-sdk` (the workspace alias for `node-sdk`), which + * re-exports the facade. Daemon's anti-corruption invariant ("daemon source + * has zero direct SDK imports") still holds — the daemon resolves + * `IAuthSummaryService` from the DI accessor. + * + * **Status mapping** (P2.1 minimum viable): + * - managed provider configured + cached token → 'authenticated' + * - managed provider configured + no cached token → 'unauthenticated' + * - managed provider not in config → managed_provider = null + * + * `'expired' / 'revoked'` are intentionally NOT distinguished here — they + * require runtime introspection that lands with the reactive-refresh path in + * P2.7+P2.9. Until then, "no cached token" subsumes both states. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import type { KimiConfig } from '@moonshot-ai/agent-core'; +import { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk'; +import type { AuthSummary } from '@moonshot-ai/protocol'; + +import { IHarnessBridge } from '../bridge/harness-bridge'; +import { + AuthModelNotResolvedError, + AuthProvisioningRequiredError, + AuthTokenMissingError, + IAuthSummaryService, +} from '../interfaces/auth-summary-service'; + +/** Wire name of the OAuth-managed provider (`@moonshot-ai/kimi-code-oauth`'s `KIMI_CODE_PROVIDER_NAME`). */ +const MANAGED_PROVIDER_NAME = 'managed:kimi-code'; + +export interface AuthSummaryServiceOptions { + /** `~/.kimi-code` (or test tmpdir) — the credential file root for KimiAuthFacade. */ + readonly homeDir: string; + /** Full path to `config.toml`. Must match what HarnessBridge / KimiCore see. */ + readonly configPath: string; +} + +export class AuthSummaryServiceImpl + extends Disposable + implements IAuthSummaryService +{ + private readonly _authFacade: KimiAuthFacade; + + constructor( + // VSCode-style: static options prefix, @-injected services after. + options: AuthSummaryServiceOptions, + @IHarnessBridge private readonly bridge: IHarnessBridge, + ) { + super(); + this._authFacade = new KimiAuthFacade({ + homeDir: options.homeDir, + configPath: options.configPath, + }); + } + + async get(): Promise { + const config = await this._readConfig(); + const providers = config.providers ?? {}; + const providers_count = Object.keys(providers).length; + const default_model = nonEmpty(config.defaultModel); + + let managed_provider: AuthSummary['managed_provider'] = null; + if (providers[MANAGED_PROVIDER_NAME] !== undefined) { + const hasToken = await this._hasCachedToken(MANAGED_PROVIDER_NAME); + managed_provider = { + name: MANAGED_PROVIDER_NAME, + status: hasToken ? 'authenticated' : 'unauthenticated', + }; + } + + const ready = + providers_count >= 1 && + default_model !== null && + (managed_provider === null || managed_provider.status !== 'revoked'); + + return { ready, providers_count, default_model, managed_provider }; + } + + async ensureReady(modelOverride?: string): Promise { + const config = await this._readConfig(); + const providers = config.providers ?? {}; + if (Object.keys(providers).length === 0) { + throw new AuthProvisioningRequiredError(); + } + + const modelId = modelOverride ?? config.defaultModel; + if (modelId === undefined || modelId === '') { + throw new AuthModelNotResolvedError(undefined); + } + + const alias = config.models?.[modelId]; + if (alias === undefined) { + throw new AuthModelNotResolvedError(modelId); + } + + const providerName = alias.provider ?? config.defaultProvider; + if (providerName === undefined || providerName === '') { + throw new AuthModelNotResolvedError(modelId); + } + + const providerConfig = providers[providerName]; + if (providerConfig === undefined) { + throw new AuthModelNotResolvedError(modelId, providerName); + } + + // Credential presence: api_key (config or env), OR a cached OAuth token. + // We deliberately don't probe live OAuth refresh here — that path is + // reactive (P2.9). Static gate only. + const hasInlineKey = nonEmpty(providerConfig.apiKey) !== null; + if (hasInlineKey) return; + + if (providerConfig.oauth !== undefined) { + const hasToken = await this._hasCachedToken(providerName); + if (hasToken) return; + throw new AuthTokenMissingError(providerName); + } + + // No inline key, no oauth ref. Could still be an env-supplied key — for + // P2.1 minimum viable we conservatively gate; env-key callers can set + // apiKey="${VAR}" in config to bypass. The acceptance test fixture for + // 40111 uses "manual provider with no api_key" which lands here. + throw new AuthTokenMissingError(providerName); + } + + override dispose(): void { + if (this._isDisposed) return; + super.dispose(); + } + + /* ----------------------------- internals ---------------------------- */ + + private async _readConfig(): Promise { + // `reload: true` forces KimiCore to re-read `config.toml` from disk + // before returning. Critical for the auth probe path: writes from + // `OAuthServiceImpl` (toolkit's provisioning) and `IProviderService` + // future RW endpoints land on disk via `writeConfigFile`, but + // KimiCore's `this.config` only refreshes when something explicitly + // asks for `reload`. Without this flag, `GET /v1/auth` would stay + // `ready:false` for the entire daemon lifetime after first login. + return this.bridge.rpc.getKimiConfig({ reload: true }); + } + + private async _hasCachedToken(providerName: string): Promise { + try { + const token = await this._authFacade.getCachedAccessToken(providerName); + return typeof token === 'string' && token.trim().length > 0; + } catch { + // FileTokenStorage throws if the credential dir or file is unreadable; + // treat any failure as "no token" so callers don't block on transient + // filesystem errors. + return false; + } + } +} + +function nonEmpty(value: string | undefined): string | null { + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +} diff --git a/packages/services/src/impls/oauth-service-impl.ts b/packages/services/src/impls/oauth-service-impl.ts new file mode 100644 index 000000000..b2a6136a7 --- /dev/null +++ b/packages/services/src/impls/oauth-service-impl.ts @@ -0,0 +1,323 @@ +/** + * `OAuthServiceImpl` — P2.7 device-code flow over REST. + * + * **Architecture**: + * + * POST /v1/oauth/login + * │ + * ▼ + * startLogin() ──┐ + * │ KimiAuthFacade.login() runs in BACKGROUND + * ▼ │ + * ┌─ onDeviceCode(auth) ◄────────────────────┘ (fires once) + * │ │ + * │ └─ resolves a deferred capturing the verification URLs + * │ + * ▼ + * REST handler returns OAuthFlowStart immediately + * + * meanwhile, the background facade.login() polls... + * + * ┌─ resolves with KimiAuthLoginResult → flow status = 'authenticated' + * │ + config.toml provisioned + * │ + token saved to credentials + * │ + * └─ rejects with one of: + * DeviceCodeTimeoutError → 'expired' + * OAuthError("denied") → 'denied' + * OAuthError("aborted") → 'cancelled' + * other → 'denied' (generic failure) + * + * GET /v1/oauth/login → getFlow() → snapshot of in-memory state + * + * **One in-flight per provider** (PLAN D6.4): startLogin replaces an + * existing pending flow by aborting its AbortController + flipping its + * status to 'cancelled' BEFORE minting a new flow_id. + * + * **GC**: a 5-min timer fires after each terminal transition; the entry is + * dropped on timer fire. Pending flows have no GC — they live until the + * upstream 15-min device_code TTL expires + facade.login resolves with + * `DeviceCodeTimeoutError`. + */ + +import { Disposable } from '@moonshot-ai/agent-core'; +import { + DeviceCodeTimeoutError, + KIMI_CODE_PROVIDER_NAME, + OAuthError, + type DeviceAuthorization, +} from '@moonshot-ai/kimi-code-oauth'; +import { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk'; +import type { + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthFlowStatus, + OAuthLoginCancelResponse, + OAuthLogoutResponse, +} from '@moonshot-ai/protocol'; +import { ulid } from 'ulid'; + +import { IOAuthService } from '../interfaces/oauth-service'; + +/** Same path-resolver options as `AuthSummaryServiceImpl`. */ +export interface OAuthServiceOptions { + readonly homeDir: string; + readonly configPath: string; + /** + * Optional pre-built facade for tests. When omitted, the impl constructs + * its own from `homeDir + configPath`. Tests pass an instance whose + * `login / logout / getCachedAccessToken` methods are mocked so they + * don't need a real OAuth host on the network. + */ + readonly authFacade?: KimiAuthFacade; +} + +interface FlowState { + readonly flowId: string; + readonly provider: string; + readonly deviceAuth: DeviceAuthorization; + /** Resolved seconds-until-expiry (may differ from `deviceAuth.expiresIn` if that was null). */ + readonly expiresInSec: number; + readonly startedAt: number; + readonly expiresAt: number; + status: OAuthFlowStatus; + readonly controller: AbortController; + resolvedAt?: number; + errorMessage?: string; + gcTimer?: NodeJS.Timeout; +} + +/** Terminal flows live this long after resolution before GC. */ +const TERMINAL_RETENTION_MS = 5 * 60 * 1000; + +export class OAuthServiceImpl extends Disposable implements IOAuthService { + private readonly _authFacade: KimiAuthFacade; + private readonly _flows = new Map(); + + constructor(options: OAuthServiceOptions) { + super(); + this._authFacade = + options.authFacade ?? + new KimiAuthFacade({ + homeDir: options.homeDir, + configPath: options.configPath, + }); + } + + async startLogin(providerName?: string): Promise { + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; + + // PLAN D6.4 — supersede any existing pending flow. + const existing = this._flows.get(name); + if (existing !== undefined && existing.status === 'pending') { + existing.controller.abort(); + this._setTerminal(existing, 'cancelled'); + } + + const flowId = `oauth_${ulid()}`; + const controller = new AbortController(); + + // Capture the device authorization via a deferred. `KimiAuthFacade.login` + // calls `onDeviceCode` exactly once, then starts polling. We resolve the + // deferred from inside the callback so this method can return as soon as + // the URLs are known — well before the polling completes. + let resolveAuth: (d: DeviceAuthorization) => void; + let rejectAuth: (e: unknown) => void; + const authPromise = new Promise((resolve, reject) => { + resolveAuth = resolve; + rejectAuth = reject; + }); + + // Background login — DO NOT await. We hand the controller's signal in so + // `cancelLogin()` and the supersede path can interrupt mid-poll. + const loginPromise = this._authFacade.login(name, { + signal: controller.signal, + onDeviceCode: (auth) => { + resolveAuth(auth); + }, + }); + + // Surface a synchronous failure (device-auth request itself fails before + // `onDeviceCode` fires) by racing the login promise. + loginPromise.catch((err) => { + rejectAuth(err); + }); + + let deviceAuth: DeviceAuthorization; + try { + deviceAuth = await authPromise; + } catch (err) { + // The OAuth host or the network broke before we got a device code. + // No flow state was registered yet; just surface the error to the + // REST handler → 50001. + const msg = err instanceof Error ? err.message : String(err); + throw new OAuthError(`failed to start device flow: ${msg}`); + } + + const startedAt = Date.now(); + // `expiresIn` is server-reported and may be null (RFC 8628 §3.2 allows + // omission). Fall back to the local 15-min budget enforced by + // `OAuthManager.login`, so the `expires_at` we surface to clients is + // never further out than the deadline that's actually being enforced. + const expiresInSec = deviceAuth.expiresIn ?? 15 * 60; + const state: FlowState = { + flowId, + provider: name, + deviceAuth, + expiresInSec, + startedAt, + expiresAt: startedAt + expiresInSec * 1000, + status: 'pending', + controller, + }; + this._flows.set(name, state); + + // Wire the background promise's terminal transition. We branch on error + // class + message — see the file header for the mapping. + loginPromise.then( + () => this._handleSuccess(state), + (err) => this._handleFailure(state, err), + ); + + return { + flow_id: flowId, + provider: name, + verification_uri: deviceAuth.verificationUri, + verification_uri_complete: deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri, + user_code: deviceAuth.userCode, + expires_in: expiresInSec, + interval: deviceAuth.interval, + status: 'pending', + expires_at: new Date(state.expiresAt).toISOString(), + }; + } + + getFlow(providerName?: string): OAuthFlowSnapshot | undefined { + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; + const state = this._flows.get(name); + if (state === undefined) return undefined; + return this._toSnapshot(state); + } + + async cancelLogin(providerName?: string): Promise { + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; + const state = this._flows.get(name); + if (state === undefined) { + // No flow at all → treat as "already cancelled" (idempotent). + return { cancelled: false, status: 'cancelled' }; + } + if (state.status !== 'pending') { + return { cancelled: false, status: state.status }; + } + state.controller.abort(); + this._setTerminal(state, 'cancelled'); + return { cancelled: true, status: 'cancelled' }; + } + + async logout(providerName?: string): Promise { + const name = providerName ?? KIMI_CODE_PROVIDER_NAME; + // Also cancel any in-flight flow so the next `GET /v1/auth` sees a clean + // slate. + const pending = this._flows.get(name); + if (pending !== undefined && pending.status === 'pending') { + pending.controller.abort(); + this._setTerminal(pending, 'cancelled'); + } + const result = await this._authFacade.logout(name); + return { logged_out: true, provider: result.providerName }; + } + + override dispose(): void { + if (this._isDisposed) return; + for (const state of this._flows.values()) { + if (state.gcTimer !== undefined) clearTimeout(state.gcTimer); + if (state.status === 'pending') { + try { + state.controller.abort(); + } catch { + // ignore + } + } + } + this._flows.clear(); + super.dispose(); + } + + /* ----------------------------- internals ---------------------------- */ + + private _handleSuccess(state: FlowState): void { + if (state.status !== 'pending') return; // already cancelled / superseded + this._setTerminal(state, 'authenticated'); + } + + private _handleFailure(state: FlowState, err: unknown): void { + if (state.status !== 'pending') return; // already cancelled / superseded + + const status = classifyFailure(err); + const message = err instanceof Error ? err.message : String(err); + state.errorMessage = message; + this._setTerminal(state, status); + } + + private _setTerminal(state: FlowState, status: OAuthFlowStatus): void { + if (state.status === status) return; + state.status = status; + state.resolvedAt = Date.now(); + // Schedule GC. If a new flow supersedes this entry first, the new flow + // replaces the map entry and this timer just no-ops on the stale state. + if (state.gcTimer !== undefined) clearTimeout(state.gcTimer); + state.gcTimer = setTimeout(() => { + const current = this._flows.get(state.provider); + // Only GC if this state IS the current map entry. A newer flow may + // have already overwritten the slot. + if (current === state) this._flows.delete(state.provider); + }, TERMINAL_RETENTION_MS); + // Don't keep the process alive solely for GC. + state.gcTimer.unref?.(); + } + + private _toSnapshot(state: FlowState): OAuthFlowSnapshot { + const snap: OAuthFlowSnapshot = { + flow_id: state.flowId, + provider: state.provider, + status: state.status, + verification_uri: state.deviceAuth.verificationUri, + verification_uri_complete: + state.deviceAuth.verificationUriComplete ?? state.deviceAuth.verificationUri, + user_code: state.deviceAuth.userCode, + expires_in: state.expiresInSec, + expires_at: new Date(state.expiresAt).toISOString(), + interval: state.deviceAuth.interval, + }; + if (state.resolvedAt !== undefined) { + (snap as { resolved_at?: string }).resolved_at = new Date( + state.resolvedAt, + ).toISOString(); + } + if (state.errorMessage !== undefined) { + (snap as { error_message?: string }).error_message = state.errorMessage; + } + return snap; + } +} + +/** + * Map the error thrown by the background login promise to a terminal status. + * + * - `DeviceCodeTimeoutError` → 'expired' (the 15-min budget ran out) + * - `OAuthError` whose message starts with 'Login aborted' → 'cancelled' + * (our own AbortController fired or the toolkit's signal path) + * - `OAuthError` mentioning 'denied' → 'denied' (user refused) + * - Anything else → 'denied' (we collapse "denied" and "generic failure"; + * the `error_message` field carries the diagnostic detail for the UI) + */ +function classifyFailure(err: unknown): OAuthFlowStatus { + if (err instanceof DeviceCodeTimeoutError) return 'expired'; + if (err instanceof OAuthError) { + const msg = err.message.toLowerCase(); + if (msg.includes('aborted')) return 'cancelled'; + if (msg.includes('denied')) return 'denied'; + return 'denied'; + } + return 'denied'; +} diff --git a/packages/services/src/impls/prompt-service-impl.ts b/packages/services/src/impls/prompt-service-impl.ts index 2b54c8da9..0a0f771fb 100644 --- a/packages/services/src/impls/prompt-service-impl.ts +++ b/packages/services/src/impls/prompt-service-impl.ts @@ -70,6 +70,7 @@ import type { import { ulid } from 'ulid'; import { IHarnessBridge } from '../bridge/harness-bridge'; +import { IAuthSummaryService } from '../interfaces/auth-summary-service'; import { IEventBus } from '../interfaces/event-bus'; import { IPromptService, @@ -156,6 +157,7 @@ export class PromptServiceImpl constructor( @IHarnessBridge private readonly bridge: IHarnessBridge, @IEventBus private readonly eventBus: IEventBus, + @IAuthSummaryService private readonly auth: IAuthSummaryService, ) { super(); } @@ -165,6 +167,11 @@ export class PromptServiceImpl async submit(sid: string, body: PromptSubmission): Promise { await this._requireSession(sid); + // P2.1 D1 — readiness gate. Throws AuthProvisioningRequired / + // AuthTokenMissing / AuthModelNotResolved before we mint a prompt_id and + // hand off to agent-core. Daemon route layer maps to 40110/40111/40113. + await this.auth.ensureReady(); + const existing = this._active.get(sid); if (existing !== undefined && !existing.completed && !existing.aborted) { throw new SessionBusyError(sid, existing.promptId); diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index 494b391b6..1113ba053 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -35,6 +35,14 @@ export type { SyntheticPromptAbortedEvent, SyntheticPromptCompletedEvent, } from './impls/prompt-service-impl'; +export { + AuthSummaryServiceImpl, + type AuthSummaryServiceOptions, +} from './impls/auth-summary-service-impl'; +export { + OAuthServiceImpl, + type OAuthServiceOptions, +} from './impls/oauth-service-impl'; export { ToolServiceImpl } from './impls/tool-service-impl'; export { McpServiceImpl } from './impls/mcp-service-impl'; export { TaskServiceImpl } from './impls/task-service-impl'; diff --git a/packages/services/src/interfaces/auth-summary-service.ts b/packages/services/src/interfaces/auth-summary-service.ts new file mode 100644 index 000000000..8463e52ac --- /dev/null +++ b/packages/services/src/interfaces/auth-summary-service.ts @@ -0,0 +1,104 @@ +/** + * `IAuthSummaryService` — daemon-facing readiness probe (P2.1 D2). + * + * Single权威 readiness signal source: + * - `get()` produces the `AuthSummary` payload for `GET /v1/auth`. + * - `ensureReady(modelOverride?)` is the synchronous gate invoked by entry + * points that can't proceed without provider credentials — currently + * `PromptServiceImpl.submit`. It throws one of the four sentinel error + * classes below; daemon route layers map them to envelope codes + * `40110 / 40111 / 40112 / 40113`. + * + * Why centralized: the same "is there a usable provider + model + token?" + * computation is needed by both the read probe and every write-side entry that + * could surface 50001 "internal" today (PLAN背景 §1). Co-locating it keeps the + * logic in one place + makes it cheap to add new gated entries (PATCH session + * model, etc.). + * + * Status mapping note (P2.1 scope): we only return `'authenticated'` (token + * cached) or `'unauthenticated'` (no token). The `'expired' / 'revoked'` + * states require runtime OAuth introspection that lands in P2.7 + P2.9 — + * P2.1's gate intentionally does NOT try to differentiate them. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { AuthSummary } from '@moonshot-ai/protocol'; + +export interface IAuthSummaryService { + /** + * Compute the current readiness snapshot. Cheap (one config read + one + * cached-token lookup); safe to call on every `GET /v1/auth`. + */ + get(): Promise; + + /** + * Throw a sentinel auth error if the daemon can NOT currently serve a + * prompt with `modelOverride` (or `config.defaultModel` if omitted). + * Returns void on success. + */ + ensureReady(modelOverride?: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IAuthSummaryService = createDecorator( + 'IAuthSummaryService', +); + +/** + * `40110 auth.provisioning_required` — daemon has zero provider configs. + */ +export class AuthProvisioningRequiredError extends Error { + constructor() { + super('no provider configured; complete onboarding via /login or POST /v1/providers'); + this.name = 'AuthProvisioningRequiredError'; + } +} + +/** + * `40111 auth.token_missing` — provider exists in config but its credential + * (api_key or cached OAuth token) is missing. + */ +export class AuthTokenMissingError extends Error { + readonly providerId: string; + constructor(providerId: string) { + super(`provider ${providerId} has no credential configured`); + this.name = 'AuthTokenMissingError'; + this.providerId = providerId; + } +} + +/** + * `40112 auth.token_unauthorized` — OAuth refresh returned 401; user has + * revoked the grant. Not produced by P2.1's static gate (would require a + * round-trip to the OAuth host); reserved for the reactive-refresh path in + * P2.9. + */ +export class AuthTokenUnauthorizedError extends Error { + readonly providerId: string; + constructor(providerId: string) { + super(`provider ${providerId} oauth grant revoked; re-login required`); + this.name = 'AuthTokenUnauthorizedError'; + this.providerId = providerId; + } +} + +/** + * `40113 auth.model_not_resolved` — the (default or requested) model alias + * does not resolve to a configured provider. Two sub-cases: + * - no default model set at all (`modelId === undefined`) + * - alias missing or points at a non-existent provider + */ +export class AuthModelNotResolvedError extends Error { + readonly modelId: string | undefined; + readonly providerId: string | undefined; + constructor(modelId: string | undefined, providerId?: string) { + super( + modelId === undefined + ? 'no default model configured' + : `model ${modelId} does not resolve to a configured provider`, + ); + this.name = 'AuthModelNotResolvedError'; + this.modelId = modelId; + this.providerId = providerId; + } +} diff --git a/packages/services/src/interfaces/index.ts b/packages/services/src/interfaces/index.ts index c3435c85c..0d3c9e8ef 100644 --- a/packages/services/src/interfaces/index.ts +++ b/packages/services/src/interfaces/index.ts @@ -13,6 +13,14 @@ export { IApprovalBroker } from './approval-broker'; export type { ApprovalRequest, ApprovalResponse } from './approval-broker'; export { IQuestionBroker } from './question-broker'; export type { QuestionRequest, QuestionResult } from './question-broker'; +export { + IAuthSummaryService, + AuthProvisioningRequiredError, + AuthTokenMissingError, + AuthTokenUnauthorizedError, + AuthModelNotResolvedError, +} from './auth-summary-service'; +export { IOAuthService } from './oauth-service'; export { ISessionService, SessionNotFoundError } from './session-service'; export type { SessionListQuery } from './session-service'; export { IMessageService, MessageNotFoundError } from './message-service'; diff --git a/packages/services/src/interfaces/oauth-service.ts b/packages/services/src/interfaces/oauth-service.ts new file mode 100644 index 000000000..b9eb37e7f --- /dev/null +++ b/packages/services/src/interfaces/oauth-service.ts @@ -0,0 +1,67 @@ +/** + * `IOAuthService` — daemon-facing device-code login orchestration (P2.7). + * + * Bridges the OAuth toolkit's `login({onDeviceCode})` callback shape to a + * REST resource: the frontend POSTs to start, gets a `verification_uri` + * synchronously, then polls a GET endpoint for status transitions while the + * daemon polls the OAuth host in the background. + * + * **One in-flight flow per provider** (PLAN D6.4). A second start cancels + * the existing pending flow first (transitions it to `'cancelled'`) then + * mints a fresh `flow_id`. Completed flows live in-memory for 5 min so the + * frontend's last poll lands on the terminal status; after that, they GC + * and `getFlow()` returns `undefined`. + * + * **No client coupling** (PLAN D6.5). Daemon does NOT detect frontend exit + * / WS disconnect. Cleanup paths: + * 1. 15-min upstream timeout (DeviceCodeTimeoutError → 'expired') + * 2. Explicit `cancelLogin()` (→ 'cancelled') + * 3. Same-provider new flow superseding (→ 'cancelled') + * + * **Token + config** land via the toolkit's provisioning path: on success, + * the `managed:kimi-code` provider + models entry are written to + * `config.toml`, and the cached token is saved to credentials. Frontend + * follow-up: hit `GET /v1/auth` to confirm `ready: true`. + */ + +import { createDecorator } from '@moonshot-ai/agent-core'; +import type { + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthLoginCancelResponse, + OAuthLogoutResponse, +} from '@moonshot-ai/protocol'; + +export interface IOAuthService { + /** + * Kick off a device-code flow for `providerName` (default + * `'managed:kimi-code'`). Requests the device authorization synchronously + * (1-2 round-trips to the OAuth host), starts background polling, and + * returns the verification URLs + flow_id. + * + * Cancels any existing pending flow for the same provider before starting. + */ + startLogin(providerName?: string): Promise; + + /** + * Snapshot the current flow state for `providerName`. Returns `undefined` + * when no flow has been started (or was GC'd after 5 min in terminal state). + */ + getFlow(providerName?: string): OAuthFlowSnapshot | undefined; + + /** + * Cancel a pending flow. Idempotent: cancelling a terminal flow returns + * `{cancelled: false, status: }` instead of throwing. + */ + cancelLogin(providerName?: string): Promise; + + /** + * Logout — delete the stored token + strip the managed provider's + * `apply` config entries (provider + models). After this, `GET /v1/auth` + * flips to `ready: false`. + */ + logout(providerName?: string): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const IOAuthService = createDecorator('IOAuthService'); diff --git a/packages/services/test/oauth-service.test.ts b/packages/services/test/oauth-service.test.ts new file mode 100644 index 000000000..08dcb590e --- /dev/null +++ b/packages/services/test/oauth-service.test.ts @@ -0,0 +1,336 @@ +/** + * `OAuthServiceImpl` (P2.7) unit tests. + * + * Hermetic: a mock `KimiAuthFacade` is injected so we don't need a real + * OAuth host on the network. The mock's `login()` exposes a deferred device + * authorization + completion promise so tests can drive each transition + * independently: + * + * facadeMock.deviceCodeReady(deviceAuth) → fires onDeviceCode → REST returns + * facadeMock.resolveLogin(result) → flow → 'authenticated' + * facadeMock.rejectLogin(err) → flow → 'denied' / 'expired' / 'cancelled' + * + * Coverage: + * - startLogin returns flow_id + verification URLs + status='pending' + * - getFlow returns the in-memory snapshot + * - resolveLogin → status='authenticated' + * - rejectLogin(DeviceCodeTimeoutError) → status='expired' + * - rejectLogin(OAuthError 'aborted') → status='cancelled' + * - rejectLogin(OAuthError 'denied') → status='denied' + * - rejectLogin(generic) → status='denied' with error_message preserved + * - cancelLogin on pending → status='cancelled', AbortController fired + * - cancelLogin on terminal → cancelled=false, status unchanged + * - startLogin while another is pending → previous flips to 'cancelled', + * new flow gets fresh flow_id + * - logout → delegates to facade.logout + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + DeviceCodeTimeoutError, + OAuthError, + type DeviceAuthorization, +} from '@moonshot-ai/kimi-code-oauth'; +import type { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk'; + +import { OAuthServiceImpl } from '../src/impls/oauth-service-impl'; + +interface LoginCall { + providerName: string | undefined; + onDeviceCode: ((auth: DeviceAuthorization) => void | Promise) | undefined; + signal: AbortSignal | undefined; + resolve: (value: { providerName: string; ok: true }) => void; + reject: (reason: unknown) => void; + promise: Promise; +} + +interface MockFacade { + facade: KimiAuthFacade; + loginCalls: LoginCall[]; + logoutCalls: Array<{ providerName: string | undefined }>; +} + +function makeMockFacade(): MockFacade { + const loginCalls: LoginCall[] = []; + const logoutCalls: Array<{ providerName: string | undefined }> = []; + + const facade = { + login: vi.fn((providerName: string | undefined, options: { + onDeviceCode?: (auth: DeviceAuthorization) => void | Promise; + signal?: AbortSignal; + }) => { + let resolveFn!: (v: { providerName: string; ok: true }) => void; + let rejectFn!: (r: unknown) => void; + const promise = new Promise<{ providerName: string; ok: true }>((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + loginCalls.push({ + providerName, + onDeviceCode: options.onDeviceCode, + signal: options.signal, + resolve: resolveFn, + reject: rejectFn, + promise, + }); + return promise; + }), + logout: vi.fn(async (providerName: string | undefined) => { + logoutCalls.push({ providerName }); + return { providerName: providerName ?? 'managed:kimi-code', ok: true as const }; + }), + } as unknown as KimiAuthFacade; + + return { facade, loginCalls, logoutCalls }; +} + +function fakeDeviceAuth(overrides: Partial = {}): DeviceAuthorization { + return { + deviceCode: 'dev-code-secret', + userCode: 'KIMI-1234', + verificationUri: 'https://example.com/device', + verificationUriComplete: 'https://example.com/device?user_code=KIMI-1234', + expiresIn: 900, + interval: 5, + ...overrides, + }; +} + +async function flushMicrotasks(): Promise { + // Two ticks is enough to settle the .then / .catch chain inside + // OAuthServiceImpl.startLogin. + await Promise.resolve(); + await Promise.resolve(); +} + +function makeImpl(): { impl: OAuthServiceImpl; mock: MockFacade } { + const mock = makeMockFacade(); + const impl = new OAuthServiceImpl({ + homeDir: '/tmp/oauth-test', + configPath: '/tmp/oauth-test/config.toml', + authFacade: mock.facade, + }); + return { impl, mock }; +} + +describe('OAuthServiceImpl.startLogin', () => { + it('returns flow_id + verification URLs once the facade fires onDeviceCode', async () => { + const { impl, mock } = makeImpl(); + + const startPromise = impl.startLogin(); + await flushMicrotasks(); + expect(mock.loginCalls).toHaveLength(1); + + // Fire the device-code callback from the facade side. + const auth = fakeDeviceAuth(); + await mock.loginCalls[0]!.onDeviceCode?.(auth); + + const start = await startPromise; + expect(start.status).toBe('pending'); + expect(start.flow_id).toMatch(/^oauth_/); + expect(start.verification_uri).toBe(auth.verificationUri); + expect(start.verification_uri_complete).toBe(auth.verificationUriComplete); + expect(start.user_code).toBe(auth.userCode); + expect(start.expires_in).toBe(900); + expect(start.interval).toBe(5); + expect(start.provider).toBe('managed:kimi-code'); + }); + + it('falls back to 15-min expires_in when the OAuth host omits the field', async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.( + fakeDeviceAuth({ expiresIn: null }), + ); + const start = await startPromise; + expect(start.expires_in).toBe(15 * 60); + }); +}); + +describe('OAuthServiceImpl.getFlow', () => { + it('returns undefined before any flow is started', () => { + const { impl } = makeImpl(); + expect(impl.getFlow()).toBeUndefined(); + }); + + it('returns the pending snapshot after start', async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + const start = await startPromise; + + const snap = impl.getFlow(); + expect(snap).toBeDefined(); + expect(snap!.flow_id).toBe(start.flow_id); + expect(snap!.status).toBe('pending'); + expect(snap!.resolved_at).toBeUndefined(); + expect(snap!.error_message).toBeUndefined(); + }); + + it("does NOT leak device_code via the snapshot", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + const snap = impl.getFlow(); + expect(JSON.stringify(snap)).not.toContain('dev-code-secret'); + }); +}); + +describe('OAuthServiceImpl — terminal transitions', () => { + it("'authenticated' on facade.login resolve", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); + await flushMicrotasks(); + + expect(impl.getFlow()!.status).toBe('authenticated'); + expect(impl.getFlow()!.resolved_at).toBeDefined(); + }); + + it("'expired' on DeviceCodeTimeoutError", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + mock.loginCalls[0]!.reject(new DeviceCodeTimeoutError('timed out')); + await flushMicrotasks(); + + expect(impl.getFlow()!.status).toBe('expired'); + expect(impl.getFlow()!.error_message).toBe('timed out'); + }); + + it("'denied' on OAuthError carrying 'denied'", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + mock.loginCalls[0]!.reject(new OAuthError('Authorization denied')); + await flushMicrotasks(); + + expect(impl.getFlow()!.status).toBe('denied'); + }); + + it("'cancelled' on OAuthError carrying 'aborted'", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + mock.loginCalls[0]!.reject(new OAuthError('Login aborted by caller')); + await flushMicrotasks(); + + expect(impl.getFlow()!.status).toBe('cancelled'); + }); + + it("'denied' for generic failures, preserving error_message", async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + mock.loginCalls[0]!.reject(new Error('ECONNREFUSED')); + await flushMicrotasks(); + + const snap = impl.getFlow()!; + expect(snap.status).toBe('denied'); + expect(snap.error_message).toBe('ECONNREFUSED'); + }); +}); + +describe('OAuthServiceImpl.cancelLogin', () => { + it('cancels a pending flow and fires the AbortController', async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + + const aborted = new Promise((resolve) => { + mock.loginCalls[0]!.signal!.addEventListener('abort', () => resolve(true)); + }); + + const result = await impl.cancelLogin(); + expect(result).toEqual({ cancelled: true, status: 'cancelled' }); + expect(await aborted).toBe(true); + expect(impl.getFlow()!.status).toBe('cancelled'); + }); + + it('idempotently reports the current status on terminal flows', async () => { + const { impl, mock } = makeImpl(); + const startPromise = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await startPromise; + mock.loginCalls[0]!.resolve({ providerName: 'managed:kimi-code', ok: true }); + await flushMicrotasks(); + + const result = await impl.cancelLogin(); + expect(result).toEqual({ cancelled: false, status: 'authenticated' }); + }); + + it('returns cancelled=false when no flow has ever been started', async () => { + const { impl } = makeImpl(); + const result = await impl.cancelLogin(); + expect(result).toEqual({ cancelled: false, status: 'cancelled' }); + }); +}); + +describe('OAuthServiceImpl — supersede (PLAN D6.4)', () => { + it("flips the previous pending flow to 'cancelled' and mints a new flow_id", async () => { + const { impl, mock } = makeImpl(); + + const first = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + const firstStart = await first; + + const second = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[1]!.onDeviceCode?.( + fakeDeviceAuth({ deviceCode: 'second-secret', userCode: 'KIMI-9999' }), + ); + const secondStart = await second; + + expect(secondStart.flow_id).not.toBe(firstStart.flow_id); + expect(impl.getFlow()!.flow_id).toBe(secondStart.flow_id); + expect(impl.getFlow()!.status).toBe('pending'); + expect(mock.loginCalls[0]!.signal!.aborted).toBe(true); + }); +}); + +describe('OAuthServiceImpl.logout', () => { + it('delegates to facade.logout and returns logged_out=true', async () => { + const { impl, mock } = makeImpl(); + const result = await impl.logout(); + expect(result).toEqual({ logged_out: true, provider: 'managed:kimi-code' }); + expect(mock.logoutCalls).toHaveLength(1); + }); + + it('also cancels any pending flow', async () => { + const { impl, mock } = makeImpl(); + const start = impl.startLogin(); + await flushMicrotasks(); + await mock.loginCalls[0]!.onDeviceCode?.(fakeDeviceAuth()); + await start; + + await impl.logout(); + // After logout, the in-memory flow is in 'cancelled' terminal state + expect(impl.getFlow()!.status).toBe('cancelled'); + expect(mock.loginCalls[0]!.signal!.aborted).toBe(true); + }); +}); diff --git a/packages/services/test/prompt-service.test.ts b/packages/services/test/prompt-service.test.ts index 54eacc967..1d1b96b7f 100644 --- a/packages/services/test/prompt-service.test.ts +++ b/packages/services/test/prompt-service.test.ts @@ -31,6 +31,7 @@ import type { } from '@moonshot-ai/agent-core'; import { + type IAuthSummaryService, type IEventBus, type IHarnessBridge, type HarnessRPC, @@ -90,11 +91,30 @@ function makeBus(): { bus: IEventBus; events: Event[] } { return { bus, events }; } +/** + * Stub `IAuthSummaryService` for hermetic prompt-service tests. Default + * `ensureReady()` resolves; tests that need to exercise the readiness gate + * can pass `{ ensureReadyError }` and assert the error surfaces. + */ +function makeAuth(opts: { ensureReadyError?: Error } = {}): IAuthSummaryService { + return { + get: vi.fn().mockResolvedValue({ + ready: true, + providers_count: 1, + default_model: 'kimi-k2', + managed_provider: null, + }), + ensureReady: vi.fn().mockImplementation(async () => { + if (opts.ensureReadyError) throw opts.ensureReadyError; + }), + }; +} + describe('PromptServiceImpl.submit (W7.2)', () => { it('returns ULID-shaped prompt_id + user_message_id derived from it', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); const result = await impl.submit(SID, { content: [{ type: 'text', text: 'hello' }], }); @@ -105,7 +125,7 @@ describe('PromptServiceImpl.submit (W7.2)', () => { it('translates text + image content to kosong ContentParts', async () => { const { bridge, record } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [ { type: 'text', text: 'hello' }, @@ -129,7 +149,7 @@ describe('PromptServiceImpl.submit (W7.2)', () => { it('throws SessionBusyError when a non-terminal prompt is already active', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [{ type: 'text', text: 'one' }] }); await expect( impl.submit(SID, { content: [{ type: 'text', text: 'two' }] }), @@ -139,7 +159,7 @@ describe('PromptServiceImpl.submit (W7.2)', () => { it('throws SessionNotFoundError on unknown session id', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await expect( impl.submit('sess_missing', { content: [{ type: 'text', text: 'hi' }] }), ).rejects.toBeInstanceOf(SessionNotFoundError); @@ -162,7 +182,7 @@ describe('PromptServiceImpl.submit (W7.2)', () => { dispose: vi.fn(), }; const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await expect( impl.submit(SID, { content: [{ type: 'text', text: 'x' }] }), ).rejects.toThrowError(/boom/); @@ -175,7 +195,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('captures turnId on the first turn.started after submit', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); impl.observeEvent({ type: 'turn.started', @@ -190,7 +210,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('ignores subsequent turn.started events (treated as nested turns)', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); impl.observeEvent({ type: 'turn.started', @@ -212,7 +232,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('synthesizes prompt.completed on top-level turn.ended (reason=completed)', async () => { const { bridge } = makeBridge(); const { bus, events } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); const submit = await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); impl.observeEvent({ type: 'turn.started', @@ -247,7 +267,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('synthesizes prompt.aborted on top-level turn.ended (reason=cancelled)', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); impl.observeEvent({ type: 'turn.started', @@ -270,7 +290,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('ignores nested turn.ended (different turnId) so prompt stays active', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }] }); impl.observeEvent({ type: 'turn.started', @@ -293,7 +313,7 @@ describe('PromptServiceImpl.observeEvent (lifecycle synthesis)', () => { it('is a no-op for events on a session with no active prompt', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); const derived = impl.observeEvent({ type: 'turn.ended', turnId: 1, @@ -309,7 +329,7 @@ describe('PromptServiceImpl.abort (W7.3)', () => { it('throws PromptNotFoundError when no active prompt for the session', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); await expect(impl.abort(SID, 'prompt_xyz')).rejects.toBeInstanceOf( PromptNotFoundError, ); @@ -318,7 +338,7 @@ describe('PromptServiceImpl.abort (W7.3)', () => { it('returns {aborted: true} and publishes prompt.aborted', async () => { const { bridge, record } = makeBridge(); const { bus, events } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); const submit = await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }], }); @@ -346,7 +366,7 @@ describe('PromptServiceImpl.abort (W7.3)', () => { it('throws PromptAlreadyCompletedError on the second abort', async () => { const { bridge } = makeBridge(); const { bus } = makeBus(); - const impl = new PromptServiceImpl(bridge, bus); + const impl = new PromptServiceImpl(bridge, bus, makeAuth()); const submit = await impl.submit(SID, { content: [{ type: 'text', text: 'hi' }], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa91e1342..368df49ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,9 @@ importers: '@moonshot-ai/agent-core': specifier: workspace:^ version: link:../agent-core + '@moonshot-ai/kimi-code-oauth': + specifier: workspace:^ + version: link:../oauth '@moonshot-ai/kimi-code-sdk': specifier: workspace:^ version: link:../node-sdk From 86e435e446b32d5bc2dff7245f1c88714ad052de Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 5 Jun 2026 16:44:25 +0800 Subject: [PATCH 004/255] fix(services): wire default OAuth token resolver into HarnessBridge - default-construct KimiAuthFacade in HarnessBridge when caller omits resolveOAuthTokenProvider\n- stop synthesized always-throw AUTH_LOGIN_REQUIRED closure after device-code login\n- remove tsx watch from dev:daemon to avoid restart loops\n- ignore Docker artifacts in .gitignore\n- add regression test for default resolver wiring --- .gitignore | 4 ++ apps/kimi-code/package.json | 4 +- .../services/src/bridge/harness-bridge.ts | 47 ++++++++++++++++++- packages/services/test/bridge.test.ts | 21 +++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3fdbd2d60..d02592734 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ coverage/ .kimi-stash-dir plugins/cdn/ superpowers + +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 4491ac5dd..a8374d2c7 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -56,7 +56,7 @@ "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", - "dev:daemon": "tsx watch --tsconfig ./tsconfig.dev.json --clear-screen=false --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon", + "dev:daemon": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", "dev:prod": "node dist/main.mjs", @@ -94,4 +94,4 @@ "engines": { "node": ">=22.19.0" } -} +} \ No newline at end of file diff --git a/packages/services/src/bridge/harness-bridge.ts b/packages/services/src/bridge/harness-bridge.ts index d1c50bf3a..2d810dd34 100644 --- a/packages/services/src/bridge/harness-bridge.ts +++ b/packages/services/src/bridge/harness-bridge.ts @@ -32,11 +32,15 @@ import { createRPC, Disposable, KimiCore, + resolveConfigPath, + resolveKimiHome, type CoreAPI, type CoreRPC, type KimiCoreOptions, + type OAuthTokenProviderResolver, type SDKAPI, } from '@moonshot-ai/agent-core'; +import { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk'; import { BridgeClientAPI } from './bridge-client-api'; import { IApprovalBroker } from '../interfaces/approval-broker'; @@ -123,9 +127,28 @@ export class HarnessBridge extends Disposable implements IHarnessBridge { // function KimiCore receives, `sdkRpc` is the one the bridge satisfies. const [coreRpc, sdkRpc] = createRPC(); + // Default-wire the OAuth token resolver. Without this, KimiCore's + // `ProviderManager.resolveAuth` sees `resolveOAuthTokenProvider === + // undefined` and synthesizes a closure that ALWAYS throws + // `AUTH_LOGIN_REQUIRED` — even after a successful device-code login that + // persisted a fresh token to disk. The daemon's `/auth` readiness probe + // is a different code path (file existence on the credentials store) so + // it stays green; the failure only surfaces inside the prompt turn, as + // an `auth.login_required` error after `turn.step.started`. We bridge + // the gap by default-constructing a `KimiAuthFacade` against the same + // home + config paths KimiCore will use, and handing its + // `resolveOAuthTokenProvider` into the core. Callers (e.g. node-sdk + // tests) can still override via `options.resolveOAuthTokenProvider`. + const resolveOAuthTokenProvider: OAuthTokenProviderResolver = + options.resolveOAuthTokenProvider ?? + HarnessBridge._defaultOAuthTokenResolver(options); + // 2. Construct the core. KimiCore's ctor wires itself into `coreRpc` and // exposes `this.sdk: Promise` for the reverse direction. - this._core = new KimiCore(coreRpc, options); + this._core = new KimiCore(coreRpc, { + ...options, + resolveOAuthTokenProvider, + }); // 3. Satisfy the SDK side with a BridgeClientAPI that routes to brokers. // sdkRpc returns Promise> — these are the methods @@ -192,4 +215,26 @@ export class HarnessBridge extends Disposable implements IHarnessBridge { }, }); } + + /** + * Build the default `resolveOAuthTokenProvider` from the same home + config + * paths KimiCore resolves internally. Mirrors `SDKRpcClient`'s default in + * `packages/node-sdk/src/sdk-rpc-client.ts` so the daemon and the SDK + * runtimes share OAuth credentials when both run against the same + * `~/.kimi-code`. + * + * Exposed as `static` so tests can assert the wiring without exercising the + * full agent-core turn loop. + */ + static _defaultOAuthTokenResolver( + options: HarnessBridgeOptions, + ): OAuthTokenProviderResolver { + const homeDir = resolveKimiHome(options.homeDir); + const configPath = resolveConfigPath({ + homeDir: options.homeDir, + configPath: options.configPath, + }); + const facade = new KimiAuthFacade({ homeDir, configPath }); + return facade.resolveOAuthTokenProvider; + } } diff --git a/packages/services/test/bridge.test.ts b/packages/services/test/bridge.test.ts index 282985e63..7f210a719 100644 --- a/packages/services/test/bridge.test.ts +++ b/packages/services/test/bridge.test.ts @@ -199,6 +199,27 @@ describe('HarnessBridge direct construction (W3.2)', () => { await expect(bridge.rpc.getCoreInfo({})).rejects.toThrow(/disposed/); }); + + // Regression: prior to the BLOCKER fix the bridge never forwarded a + // `resolveOAuthTokenProvider` into KimiCore. ProviderManager.resolveAuth + // then synthesized a closure that ALWAYS threw `auth.login_required` + // even after a successful device-code login. The daemon's `/auth` + // readiness probe (file-existence check) still said `ready:true`, so the + // failure only surfaced inside the prompt turn. Lock down that the + // bridge default-wires a resolver from the same home/config paths + // KimiCore consumes. + it('default-wires a resolveOAuthTokenProvider when caller omits one', () => { + const resolver = HarnessBridge._defaultOAuthTokenResolver({ homeDir: tmpHome }); + expect(typeof resolver).toBe('function'); + // Calling the resolver with the managed-kimi-code provider name must + // return an object exposing `getAccessToken`. We don't invoke it — + // there's no token on disk in this hermetic test — but the shape is + // sufficient to prove the bridge wired a real BearerTokenProvider + // factory (not the always-throw sentinel). + const tokenProvider = resolver('managed:kimi-code'); + expect(tokenProvider).toBeDefined(); + expect(typeof tokenProvider?.getAccessToken).toBe('function'); + }); }); describe('defaultServicesModule() composition (W3.2)', () => { From 9e023b0e1a2e7f2ceb7d90dea51adc2bf74bd6e0 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 8 Jun 2026 22:05:50 +0800 Subject: [PATCH 005/255] feat(web): add Kimi web client (apps/kimi-web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser peer to the Kimi Code TUI that talks to the local daemon over REST + WS (/api/v1, raw agent-core event stream). Highlights: - Workspace + session model: a workspace is a real folder; sessions are scoped to it. Dual sidebar — a workspace rail (collapsible icons ⇄ named rows) + a resizable, project-scoped session column. One-click new session, folder browser (fs:browse) to add workspaces, cross-session attention markers. - Conversation: streaming thinking/text/tool calls, follow-to-bottom with a "new messages" pill, markdown with highlight.js syntax highlighting + diff coloring, per-tool header summaries, image attachments, @mention files, slash commands, editable message queue, max-width reading column with left/center alignment. - Controls (StatusLine): model picker, context meter, thinking-level selector, plan-mode toggle, permission selector; /status panel; every prompt carries model + thinking + permission_mode + plan_mode per the daemon contract. - Auth: OAuth device-code login/logout, managed-provider account menu. - Tabs: ~/chat, ~/diff (git changes), ~/files (tree + preview), ~/tasks. - i18n (vue-i18n): en/zh, browser detection + localStorage, EN/中文 switcher. - Vue 3 + Vite + TS; 294 unit tests; typecheck + build green. --- apps/kimi-web/dev/stub-daemon.mjs | 1824 +++++++++++++++++ apps/kimi-web/index.html | 14 + apps/kimi-web/package.json | 32 + apps/kimi-web/src/App.vue | 452 ++++ apps/kimi-web/src/api/config.ts | 60 + .../__tests__/agentEventProjector.test.ts | 551 +++++ .../daemon/__tests__/client.workspace.test.ts | 97 + .../daemon/__tests__/mappers.model.test.ts | 93 + .../src/api/daemon/agentEventProjector.ts | 826 ++++++++ apps/kimi-web/src/api/daemon/client.ts | 847 ++++++++ apps/kimi-web/src/api/daemon/eventReducer.ts | 370 ++++ apps/kimi-web/src/api/daemon/http.ts | 118 ++ apps/kimi-web/src/api/daemon/mappers.ts | 606 ++++++ apps/kimi-web/src/api/daemon/wire.ts | 650 ++++++ apps/kimi-web/src/api/daemon/ws.ts | 286 +++ apps/kimi-web/src/api/errors.ts | 30 + apps/kimi-web/src/api/index.ts | 13 + apps/kimi-web/src/api/types.ts | 419 ++++ .../src/components/AddWorkspaceDialog.vue | 491 +++++ apps/kimi-web/src/components/ApprovalCard.vue | 369 ++++ apps/kimi-web/src/components/ChatPane.vue | 151 ++ apps/kimi-web/src/components/Composer.vue | 809 ++++++++ .../src/components/ConversationPane.vue | 443 ++++ apps/kimi-web/src/components/DiffView.vue | 231 +++ apps/kimi-web/src/components/FilePreview.vue | 401 ++++ apps/kimi-web/src/components/FileTree.vue | 408 ++++ .../src/components/LanguageSwitcher.vue | 61 + apps/kimi-web/src/components/LoginDialog.vue | 540 +++++ apps/kimi-web/src/components/Markdown.vue | 422 ++++ apps/kimi-web/src/components/MentionMenu.vue | 160 ++ apps/kimi-web/src/components/ModelPicker.vue | 346 ++++ .../src/components/NewSessionDialog.vue | 328 +++ .../src/components/ProviderManager.vue | 510 +++++ apps/kimi-web/src/components/QuestionCard.vue | 375 ++++ apps/kimi-web/src/components/ResizeHandle.vue | 73 + apps/kimi-web/src/components/SessionRow.vue | 284 +++ apps/kimi-web/src/components/Sidebar.vue | 362 ++++ apps/kimi-web/src/components/SlashMenu.vue | 84 + apps/kimi-web/src/components/StatusLine.vue | 452 ++++ apps/kimi-web/src/components/StatusPanel.vue | 199 ++ apps/kimi-web/src/components/TabBar.vue | 134 ++ apps/kimi-web/src/components/TasksPane.vue | 84 + .../kimi-web/src/components/ThinkingBlock.vue | 98 + apps/kimi-web/src/components/ToolCall.vue | 126 ++ .../kimi-web/src/components/WarningToasts.vue | 96 + .../kimi-web/src/components/WorkspaceRail.vue | 475 +++++ .../__tests__/AddWorkspaceDialog.test.ts | 93 + .../src/components/__tests__/App.test.ts | 56 + .../__tests__/ApprovalCard.kinds.test.ts | 159 ++ .../components/__tests__/ApprovalCard.test.ts | 51 + .../src/components/__tests__/ChatPane.test.ts | 35 + .../src/components/__tests__/Composer.test.ts | 245 +++ .../__tests__/ConversationPane.test.ts | 152 ++ .../src/components/__tests__/DiffView.test.ts | 73 + .../components/__tests__/FilePreview.test.ts | 158 ++ .../src/components/__tests__/FileTree.test.ts | 243 +++ .../components/__tests__/LoginDialog.test.ts | 132 ++ .../src/components/__tests__/Markdown.test.ts | 66 + .../components/__tests__/MentionMenu.test.ts | 53 + .../components/__tests__/ModelPicker.test.ts | 123 ++ .../__tests__/NewSessionDialog.test.ts | 107 + .../components/__tests__/ResizeHandle.test.ts | 52 + .../src/components/__tests__/Sidebar.test.ts | 223 ++ .../components/__tests__/StatusLine.test.ts | 199 ++ .../components/__tests__/StatusPanel.test.ts | 77 + .../src/components/__tests__/TabBar.test.ts | 72 + .../components/__tests__/TasksPane.test.ts | 41 + .../src/components/__tests__/ToolCall.test.ts | 63 + .../__tests__/WorkspaceRail.test.ts | 170 ++ .../__tests__/messagesToTurns.test.ts | 154 ++ .../useKimiWebClient.planMode.test.ts | 151 ++ .../useKimiWebClient.workspace.test.ts | 275 +++ .../__tests__/useResizable.test.ts | 77 + .../src/composables/messagesToTurns.ts | 291 +++ .../src/composables/useKimiWebClient.ts | 1806 ++++++++++++++++ apps/kimi-web/src/composables/useResizable.ts | 127 ++ apps/kimi-web/src/env.d.ts | 8 + apps/kimi-web/src/i18n/index.ts | 31 + apps/kimi-web/src/i18n/locales/en/app.ts | 4 + apps/kimi-web/src/i18n/locales/en/approval.ts | 24 + apps/kimi-web/src/i18n/locales/en/commands.ts | 19 + apps/kimi-web/src/i18n/locales/en/common.ts | 1 + apps/kimi-web/src/i18n/locales/en/composer.ts | 15 + .../src/i18n/locales/en/conversation.ts | 4 + apps/kimi-web/src/i18n/locales/en/diff.ts | 8 + .../src/i18n/locales/en/filePreview.ts | 11 + apps/kimi-web/src/i18n/locales/en/fileTree.ts | 5 + apps/kimi-web/src/i18n/locales/en/layout.ts | 6 + apps/kimi-web/src/i18n/locales/en/login.ts | 23 + apps/kimi-web/src/i18n/locales/en/mention.ts | 4 + apps/kimi-web/src/i18n/locales/en/model.ts | 12 + .../src/i18n/locales/en/newSession.ts | 12 + .../kimi-web/src/i18n/locales/en/providers.ts | 35 + apps/kimi-web/src/i18n/locales/en/question.ts | 9 + apps/kimi-web/src/i18n/locales/en/sidebar.ts | 20 + apps/kimi-web/src/i18n/locales/en/status.ts | 40 + apps/kimi-web/src/i18n/locales/en/tasks.ts | 8 + apps/kimi-web/src/i18n/locales/en/thinking.ts | 3 + apps/kimi-web/src/i18n/locales/en/tools.ts | 17 + apps/kimi-web/src/i18n/locales/en/warnings.ts | 5 + .../kimi-web/src/i18n/locales/en/workspace.ts | 40 + apps/kimi-web/src/i18n/locales/index.ts | 102 + apps/kimi-web/src/i18n/locales/zh/app.ts | 4 + apps/kimi-web/src/i18n/locales/zh/approval.ts | 24 + apps/kimi-web/src/i18n/locales/zh/commands.ts | 19 + apps/kimi-web/src/i18n/locales/zh/common.ts | 1 + apps/kimi-web/src/i18n/locales/zh/composer.ts | 15 + .../src/i18n/locales/zh/conversation.ts | 4 + apps/kimi-web/src/i18n/locales/zh/diff.ts | 8 + .../src/i18n/locales/zh/filePreview.ts | 11 + apps/kimi-web/src/i18n/locales/zh/fileTree.ts | 5 + apps/kimi-web/src/i18n/locales/zh/layout.ts | 6 + apps/kimi-web/src/i18n/locales/zh/login.ts | 23 + apps/kimi-web/src/i18n/locales/zh/mention.ts | 4 + apps/kimi-web/src/i18n/locales/zh/model.ts | 12 + .../src/i18n/locales/zh/newSession.ts | 12 + .../kimi-web/src/i18n/locales/zh/providers.ts | 35 + apps/kimi-web/src/i18n/locales/zh/question.ts | 9 + apps/kimi-web/src/i18n/locales/zh/sidebar.ts | 20 + apps/kimi-web/src/i18n/locales/zh/status.ts | 40 + apps/kimi-web/src/i18n/locales/zh/tasks.ts | 8 + apps/kimi-web/src/i18n/locales/zh/thinking.ts | 3 + apps/kimi-web/src/i18n/locales/zh/tools.ts | 17 + apps/kimi-web/src/i18n/locales/zh/warnings.ts | 5 + .../kimi-web/src/i18n/locales/zh/workspace.ts | 40 + .../src/lib/__tests__/slashCommands.test.ts | 85 + apps/kimi-web/src/lib/slashCommands.ts | 61 + apps/kimi-web/src/lib/toolMeta.ts | 217 ++ apps/kimi-web/src/main.ts | 6 + apps/kimi-web/src/style.css | 38 + apps/kimi-web/src/types.ts | 154 ++ apps/kimi-web/tsconfig.json | 23 + apps/kimi-web/vite.config.ts | 31 + package.json | 1 + pnpm-lock.yaml | 961 ++++++++- 135 files changed, 23683 insertions(+), 9 deletions(-) create mode 100644 apps/kimi-web/dev/stub-daemon.mjs create mode 100644 apps/kimi-web/index.html create mode 100644 apps/kimi-web/package.json create mode 100644 apps/kimi-web/src/App.vue create mode 100644 apps/kimi-web/src/api/config.ts create mode 100644 apps/kimi-web/src/api/daemon/__tests__/agentEventProjector.test.ts create mode 100644 apps/kimi-web/src/api/daemon/__tests__/client.workspace.test.ts create mode 100644 apps/kimi-web/src/api/daemon/__tests__/mappers.model.test.ts create mode 100644 apps/kimi-web/src/api/daemon/agentEventProjector.ts create mode 100644 apps/kimi-web/src/api/daemon/client.ts create mode 100644 apps/kimi-web/src/api/daemon/eventReducer.ts create mode 100644 apps/kimi-web/src/api/daemon/http.ts create mode 100644 apps/kimi-web/src/api/daemon/mappers.ts create mode 100644 apps/kimi-web/src/api/daemon/wire.ts create mode 100644 apps/kimi-web/src/api/daemon/ws.ts create mode 100644 apps/kimi-web/src/api/errors.ts create mode 100644 apps/kimi-web/src/api/index.ts create mode 100644 apps/kimi-web/src/api/types.ts create mode 100644 apps/kimi-web/src/components/AddWorkspaceDialog.vue create mode 100644 apps/kimi-web/src/components/ApprovalCard.vue create mode 100644 apps/kimi-web/src/components/ChatPane.vue create mode 100644 apps/kimi-web/src/components/Composer.vue create mode 100644 apps/kimi-web/src/components/ConversationPane.vue create mode 100644 apps/kimi-web/src/components/DiffView.vue create mode 100644 apps/kimi-web/src/components/FilePreview.vue create mode 100644 apps/kimi-web/src/components/FileTree.vue create mode 100644 apps/kimi-web/src/components/LanguageSwitcher.vue create mode 100644 apps/kimi-web/src/components/LoginDialog.vue create mode 100644 apps/kimi-web/src/components/Markdown.vue create mode 100644 apps/kimi-web/src/components/MentionMenu.vue create mode 100644 apps/kimi-web/src/components/ModelPicker.vue create mode 100644 apps/kimi-web/src/components/NewSessionDialog.vue create mode 100644 apps/kimi-web/src/components/ProviderManager.vue create mode 100644 apps/kimi-web/src/components/QuestionCard.vue create mode 100644 apps/kimi-web/src/components/ResizeHandle.vue create mode 100644 apps/kimi-web/src/components/SessionRow.vue create mode 100644 apps/kimi-web/src/components/Sidebar.vue create mode 100644 apps/kimi-web/src/components/SlashMenu.vue create mode 100644 apps/kimi-web/src/components/StatusLine.vue create mode 100644 apps/kimi-web/src/components/StatusPanel.vue create mode 100644 apps/kimi-web/src/components/TabBar.vue create mode 100644 apps/kimi-web/src/components/TasksPane.vue create mode 100644 apps/kimi-web/src/components/ThinkingBlock.vue create mode 100644 apps/kimi-web/src/components/ToolCall.vue create mode 100644 apps/kimi-web/src/components/WarningToasts.vue create mode 100644 apps/kimi-web/src/components/WorkspaceRail.vue create mode 100644 apps/kimi-web/src/components/__tests__/AddWorkspaceDialog.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/App.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ApprovalCard.kinds.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ApprovalCard.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ChatPane.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/Composer.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ConversationPane.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/DiffView.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/FilePreview.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/FileTree.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/LoginDialog.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/Markdown.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/MentionMenu.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ModelPicker.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/NewSessionDialog.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ResizeHandle.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/Sidebar.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/StatusLine.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/StatusPanel.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/TabBar.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/TasksPane.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/ToolCall.test.ts create mode 100644 apps/kimi-web/src/components/__tests__/WorkspaceRail.test.ts create mode 100644 apps/kimi-web/src/composables/__tests__/messagesToTurns.test.ts create mode 100644 apps/kimi-web/src/composables/__tests__/useKimiWebClient.planMode.test.ts create mode 100644 apps/kimi-web/src/composables/__tests__/useKimiWebClient.workspace.test.ts create mode 100644 apps/kimi-web/src/composables/__tests__/useResizable.test.ts create mode 100644 apps/kimi-web/src/composables/messagesToTurns.ts create mode 100644 apps/kimi-web/src/composables/useKimiWebClient.ts create mode 100644 apps/kimi-web/src/composables/useResizable.ts create mode 100644 apps/kimi-web/src/env.d.ts create mode 100644 apps/kimi-web/src/i18n/index.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/app.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/approval.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/commands.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/common.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/composer.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/conversation.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/diff.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/filePreview.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/fileTree.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/layout.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/login.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/mention.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/model.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/newSession.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/providers.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/question.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/sidebar.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/status.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/tasks.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/thinking.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/tools.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/warnings.ts create mode 100644 apps/kimi-web/src/i18n/locales/en/workspace.ts create mode 100644 apps/kimi-web/src/i18n/locales/index.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/app.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/approval.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/commands.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/common.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/composer.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/conversation.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/diff.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/filePreview.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/fileTree.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/layout.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/login.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/mention.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/model.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/newSession.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/providers.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/question.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/sidebar.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/status.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/tasks.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/thinking.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/tools.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/warnings.ts create mode 100644 apps/kimi-web/src/i18n/locales/zh/workspace.ts create mode 100644 apps/kimi-web/src/lib/__tests__/slashCommands.test.ts create mode 100644 apps/kimi-web/src/lib/slashCommands.ts create mode 100644 apps/kimi-web/src/lib/toolMeta.ts create mode 100644 apps/kimi-web/src/main.ts create mode 100644 apps/kimi-web/src/style.css create mode 100644 apps/kimi-web/src/types.ts create mode 100644 apps/kimi-web/tsconfig.json create mode 100644 apps/kimi-web/vite.config.ts diff --git a/apps/kimi-web/dev/stub-daemon.mjs b/apps/kimi-web/dev/stub-daemon.mjs new file mode 100644 index 000000000..5bfafc262 --- /dev/null +++ b/apps/kimi-web/dev/stub-daemon.mjs @@ -0,0 +1,1824 @@ +// Local stub daemon for Kimi Web development. +// +// This is NOT the real backend. It is a throwaway dev server that speaks the +// daemon REST + WS wire protocol (envelope, snake_case, event frames) closely +// enough for the Web UI to be fully clickable before the real daemon exists. +// When the real daemon ships, point VITE_KIMI_DAEMON_HTTP_URL at it instead and +// stop running this. +// +// node dev/stub-daemon.mjs # listens on 127.0.0.1:7878 +// PORT=9000 node dev/stub-daemon.mjs +// +import http from 'node:http'; +import { WebSocketServer } from 'ws'; + +const PORT = Number(process.env.PORT) || 7878; +const STARTED_AT = new Date().toISOString(); + +const now = () => new Date().toISOString(); +const expires60 = () => new Date(Date.now() + 60_000).toISOString(); + +// Simple ULID-ish: time-prefix + random. Good enough for a stub. +function ulid(prefix = '') { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +const ok = (data) => + JSON.stringify({ code: 0, msg: 'success', data, request_id: ulid('req_') }); +const fail = (code, msg, data = null) => + JSON.stringify({ code, msg, data, request_id: ulid('req_') }); + +// ---- PRESUMED: in-memory models + providers ---- +// PRESUMED — not in current daemon docs; endpoints isolated here, swap when backend defines them. + +const seedProviders = [ + { + id: 'prov_moonshot', + type: 'moonshot', + base_url: undefined, + default_model: 'moonshot-v1-128k', + has_api_key: true, + status: 'connected', + models: ['moonshot-v1-128k', 'moonshot-v1-32k'], + }, + { + id: 'prov_anthropic', + type: 'anthropic', + base_url: undefined, + default_model: undefined, + has_api_key: false, + status: 'unconfigured', + models: [], + }, + { + id: 'prov_openai', + type: 'openai', + base_url: undefined, + default_model: undefined, + has_api_key: false, + status: 'unconfigured', + models: [], + }, +]; + +const seedModels = [ + { provider: 'prov_moonshot', model: 'moonshot-v1-128k', display_name: 'Moonshot 128K', max_context_size: 131072, capabilities: [] }, + { provider: 'prov_moonshot', model: 'moonshot-v1-32k', display_name: 'Moonshot 32K', max_context_size: 32768, capabilities: [] }, + { provider: 'prov_moonshot', model: 'moonshot-v1-8k', display_name: 'Moonshot 8K', max_context_size: 8192, capabilities: [] }, + { provider: 'prov_anthropic', model: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6', max_context_size: 200000, capabilities: ['thinking'] }, + { provider: 'prov_anthropic', model: 'claude-opus-4-5', display_name: 'Claude Opus 4.5', max_context_size: 200000, capabilities: ['thinking'] }, + { provider: 'prov_openai', model: 'gpt-4o', display_name: 'GPT-4o', max_context_size: 128000, capabilities: [] }, +]; + +// Mutable arrays so POST/DELETE update them live +const providers = [...seedProviders]; +const models = [...seedModels]; + +// ---- Real OAuth singleton state ---- +let loggedIn = false; +let currentFlow = null; // { flow_id, provider, status, user_code, expires_in, interval, ... } + +// ---- in-memory state ---- + +function mkUsage(ctx = 38000, turns = 2) { + return { + input_tokens: 1200 + ctx * 2, + output_tokens: 600, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: +(ctx * 0.000002).toFixed(4), + context_tokens: ctx, + context_limit: 200000, + turn_count: turns, + }; +} + +function mkSession(id, title, status = 'idle', ctx = 38000, turns = 2) { + return { + id, + title, + created_at: now(), + updated_at: now(), + status, + metadata: { cwd: '/Users/moonshot/code/kimi-code-web' }, + agent_config: { model: 'moonshot-v1-128k', tools: ['read', 'bash', 'edit', 'write'] }, + usage: mkUsage(ctx, turns), + permission_rules: [], + message_count: 0, + last_seq: 0, + }; +} + +// ---- seed sessions ---- + +const ses1 = mkSession('ses_1', '重构 API client 超时配置', 'idle', 52000, 4); +const ses2 = mkSession('ses_2', '修复 TUI 渲染抖动', 'idle', 29000, 2); +const ses3 = mkSession('ses_3', '登录态错误归一化', 'idle', 18000, 1); +const ses4 = mkSession('ses_4', '新功能:文件搜索高亮', 'idle', 8000, 0); + +const sessions = [ses1, ses2, ses3, ses4]; + +// ---- seed workspaces + folder browser (demo) ---- +// A wd__ id, matching the real daemon's workspace id shape. +function wdId(root) { + const slug = (root.split('/').filter(Boolean).pop() || 'root') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'root'; + let h = 0; + for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0; + const hash = (h.toString(36) + '000000000000').slice(0, 12); + return `wd_${slug}_${hash}`; +} + +function mkWorkspace(root, name) { + return { + id: wdId(root), + root, + name: name || root.split('/').filter(Boolean).pop() || root, + is_git_repo: true, + branch: 'main', + created_at: now(), + last_opened_at: now(), + session_count: sessions.filter((s) => s.metadata?.cwd === root).length, + }; +} + +// Derive one workspace from the seeded session cwd, plus a couple of demo ones. +const workspaces = [ + mkWorkspace('/Users/moonshot/code/kimi-code-web', 'kimi-code-web'), + mkWorkspace('/Users/moonshot/code/kimi-cli', 'kimi-cli'), + mkWorkspace('/Users/moonshot/code/paseo', 'paseo'), +]; + +const FS_HOME = '/Users/moonshot'; +const FS_RECENT = [ + '/Users/moonshot/code/kimi-code-web', + '/Users/moonshot/code/kimi-cli', +]; + +// A tiny in-memory folder tree for the demo folder browser. Maps an absolute +// dir → its immediate subdirs (name + whether it's a git repo + branch). +const FS_TREE = { + '/': [{ name: 'Users', git: false }], + '/Users': [{ name: 'moonshot', git: false }], + '/Users/moonshot': [ + { name: 'code', git: false }, + { name: 'Documents', git: false }, + { name: 'Downloads', git: false }, + ], + '/Users/moonshot/code': [ + { name: 'kimi-code-web', git: true, branch: 'main' }, + { name: 'kimi-cli', git: true, branch: 'dev' }, + { name: 'paseo', git: true, branch: 'main' }, + { name: 'scratch', git: false }, + ], + '/Users/moonshot/code/kimi-code-web': [ + { name: 'apps', git: false }, + { name: 'packages', git: false }, + { name: 'docs', git: false }, + ], + '/Users/moonshot/code/kimi-cli': [{ name: 'src', git: false }], + '/Users/moonshot/code/paseo': [{ name: 'src', git: false }], +}; + +function browseDir(dirPath) { + const kids = FS_TREE[dirPath] || []; + const parent = dirPath === '/' ? null : (dirPath.split('/').slice(0, -1).join('/') || '/'); + return { + path: dirPath, + parent, + entries: kids.map((k) => ({ + name: k.name, + path: dirPath === '/' ? `/${k.name}` : `${dirPath}/${k.name}`, + is_dir: true, + is_git_repo: !!k.git, + ...(k.branch ? { branch: k.branch } : {}), + })), + }; +} + +// ---- seed messages ---- + +const t = (text) => ({ type: 'text', text }); +const thinking = (text) => ({ + type: 'thinking', + thinking: text, + signature: 'sig_stub_' + Math.random().toString(36).slice(2, 8), +}); +const toolUse = (id, name, input) => ({ type: 'tool_use', tool_call_id: id, tool_name: name, input }); +const toolResult = (id, output, is_error = false) => ({ + type: 'tool_result', + tool_call_id: id, + output, + is_error, +}); + +function mkMsg(id, session_id, role, content, prompt_id) { + return { id, session_id, role, content, created_at: now(), prompt_id }; +} + +const messages = { + ses_1: [ + mkMsg('msg_s1_1', 'ses_1', 'user', [t('把 api client 的超时改成可配置,然后跑测试')], 'pr_s1_1'), + mkMsg('msg_s1_2', 'ses_1', 'assistant', [ + thinking( + '用户想让我修改 API client,把硬编码的超时时间改成可通过参数配置的方式。\n' + + '我需要先读取现有代码,了解超时目前是如何硬编码的,然后:\n' + + '1. 修改 createClient 函数签名,加入 timeoutMs 可选参数\n' + + '2. 更新相关测试\n让我先读一下文件。' + ), + t('好的,我先看一下 `api/client.ts` 的现有实现:'), + toolUse('tc_s1_a', 'read', { path: 'packages/api/src/client.ts' }), + ], 'pr_s1_1'), + mkMsg('msg_s1_3', 'ses_1', 'assistant', [ + toolResult('tc_s1_a', + 'export function createClient(baseUrl: string) {\n' + + ' const timeout = 30_000; // hardcoded\n' + + ' return { baseUrl, timeout };\n' + + '}\n', false), + ], 'pr_s1_1'), + mkMsg('msg_s1_4', 'ses_1', 'assistant', [ + t('找到了,超时是硬编码的 `30_000`。现在修改成可配置:'), + toolUse('tc_s1_b', 'edit', { + path: 'packages/api/src/client.ts', + old_string: 'export function createClient(baseUrl: string) {\n const timeout = 30_000; // hardcoded', + new_string: 'export function createClient(baseUrl: string, timeoutMs = 30_000) {\n const timeout = timeoutMs;', + }), + ], 'pr_s1_1'), + mkMsg('msg_s1_5', 'ses_1', 'assistant', [ + toolResult('tc_s1_b', 'File edited successfully.', false), + t('修改完成,现在跑测试确认没有回归:'), + toolUse('tc_s1_c', 'bash', { command: 'pnpm --filter @kimi-code/api test --run' }), + ], 'pr_s1_1'), + mkMsg('msg_s1_6', 'ses_1', 'assistant', [ + toolResult('tc_s1_c', + 'PASS packages/api/src/__tests__/client.test.ts\n' + + 'Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n', false), + ], 'pr_s1_1'), + mkMsg('msg_s1_7', 'ses_1', 'assistant', [ + t( + '## 完成\n\n' + + '**变更摘要**\n\n' + + '- 给 `createClient` 加了 `timeoutMs` 可选参数,默认保持 `30_000` 不变\n' + + '- 现有调用方无需修改(向后兼容)\n\n' + + '所有 3 个测试通过,没有回归。' + ), + ], 'pr_s1_1'), + ], + + ses_2: [ + mkMsg('msg_s2_1', 'ses_2', 'user', [t('TUI 偶尔抖动,看下渲染循环是否有问题')], 'pr_s2_1'), + mkMsg('msg_s2_2', 'ses_2', 'assistant', [ + thinking( + '用户报告 TUI 渲染抖动。可能原因:\n' + + '1. 渲染节流不够导致频繁重绘\n' + + '2. 状态更新与渲染循环冲突\n' + + '先读渲染主循环。' + ), + t('先看一下 TUI 渲染循环的实现:'), + toolUse('tc_s2_a', 'read', { path: 'apps/kimi-tui/src/renderer/loop.ts' }), + ], 'pr_s2_1'), + mkMsg('msg_s2_3', 'ses_2', 'assistant', [ + toolResult('tc_s2_a', + 'export class RenderLoop {\n' + + ' private dirty = false;\n' + + ' schedule() { this.dirty = true; this.flush(); }\n' + + ' flush() { if (!this.dirty) return; this.dirty = false; this.render(); }\n' + + '}\n', false), + ], 'pr_s2_1'), + mkMsg('msg_s2_4', 'ses_2', 'assistant', [ + t( + '问题找到了。`schedule()` 在标记 `dirty` 之后立即 `flush()`,' + + '所以同一帧内多次 `schedule()` 调用会触发多次 `render()`,造成抖动。\n\n' + + '修复方案:用 `setImmediate` 把 flush 推迟到当前 tick 末尾。' + ), + toolUse('tc_s2_b', 'edit', { + path: 'apps/kimi-tui/src/renderer/loop.ts', + old_string: ' schedule() { this.dirty = true; this.flush(); }', + new_string: ' schedule() { if (this.dirty) return; this.dirty = true; setImmediate(() => this.flush()); }', + }), + ], 'pr_s2_1'), + mkMsg('msg_s2_5', 'ses_2', 'assistant', [ + toolResult('tc_s2_b', 'File edited successfully.', false), + t('修复完成。现在 `schedule()` 在同一 tick 内只会排队一次 flush,消除了重复渲染。'), + ], 'pr_s2_1'), + ], + + ses_3: [ + mkMsg('msg_s3_1', 'ses_3', 'user', [t('登录态报错太乱了,帮我归一化一下错误处理')], 'pr_s3_1'), + mkMsg('msg_s3_2', 'ses_3', 'assistant', [ + t('好的,我先搜索一下目前登录相关的错误处理分散在哪些文件:'), + toolUse('tc_s3_a', 'bash', { + command: 'rg "AuthError|loginError|auth_error" --type ts -l apps/kimi-cli/src/', + }), + ], 'pr_s3_1'), + mkMsg('msg_s3_3', 'ses_3', 'assistant', [ + toolResult('tc_s3_a', + 'apps/kimi-cli/src/auth/login.ts\n' + + 'apps/kimi-cli/src/auth/refresh.ts\n' + + 'apps/kimi-cli/src/commands/auth.ts\n', false), + t( + '错误处理散落在 3 个文件里。建议统一到 `auth/errors.ts` 里定义一个 `AuthError` 类。\n\n' + + '你希望我直接动手实施这个方案,还是先看看具体代码再决定?' + ), + ], 'pr_s3_1'), + ], + + ses_4: [], +}; + +// Update message_count on sessions +for (const s of sessions) { + s.message_count = (messages[s.id] || []).length; +} + +// ---- seed tasks ---- + +const tasks = { + ses_1: [ + { + id: 'task_1', session_id: 'ses_1', kind: 'subagent', description: 'pnpm build --filter @kimi-code/api', + status: 'running', created_at: now(), started_at: now(), + output_preview: '$ pnpm build --filter @kimi-code/api\nvite v5.2.1 building for production...', + output_bytes: 128, + }, + { + id: 'task_2', session_id: 'ses_1', kind: 'bash', description: 'eslint packages/api/src', + status: 'running', created_at: now(), started_at: now(), + output_preview: 'Running ESLint on packages/api/src...', + output_bytes: 48, + }, + { + id: 'task_3', session_id: 'ses_1', kind: 'tool', description: 'Generate docs', + status: 'completed', created_at: now(), started_at: now(), completed_at: now(), + output_preview: 'Docs generated: docs/api/client.md\n0 errors, 0 warnings', + output_bytes: 512, + }, + ], + ses_2: [], + ses_3: [], + ses_4: [], +}; + +// ---- sequence counters ---- + +const seqBySession = { ses_1: 8, ses_2: 5, ses_3: 2, ses_4: 0 }; + +// ---- pending continuations keyed by session_id ---- +const pendingContinuation = {}; +const pendingApproval = {}; +const pendingQuestion = {}; + +// ---- WS broadcast ---- + +const sockets = new Set(); + +function broadcast(type, sessionId, payload) { + const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1); + const session = sessions.find((s) => s.id === sessionId); + if (session) session.last_seq = seq; + const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload }); + for (const ws of sockets) if (ws.readyState === 1) ws.send(frame); + return seq; +} + +// ---- raw mode flag ---- +// Set STUB_RAW_EVENTS=1 to emit raw agent-core events instead of projected event.* frames. +const RAW_EVENTS_MODE = process.env.STUB_RAW_EVENTS === '1'; + +// ---- scripted reply flows ---- + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function scheduleSteps(steps) { + let p = Promise.resolve(); + for (const [ms, fn] of steps) { + p = p.then(() => delay(ms)).then(() => fn()); + } + return p; +} + +async function streamMarkdown(sessionId, msgId, contentIndex, text, chunkSize = 40) { + const chunks = []; + for (let i = 0; i < text.length; i += chunkSize) { + chunks.push(text.slice(i, i + chunkSize)); + } + for (const chunk of chunks) { + await delay(80 + Math.random() * 60); + broadcast('event.assistant.delta', sessionId, { + message_id: msgId, + content_index: contentIndex, + delta: { text: chunk }, + }); + } +} + +async function simulateToolUse(sessionId, parentMsgId, toolCallId, toolName, input, outputText) { + broadcast('event.assistant.tool_use_started', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + tool_name: toolName, + content_index: 1, + }); + await delay(200); + + const inputStr = JSON.stringify(input); + const chunkSize = 20; + for (let i = 0; i < inputStr.length; i += chunkSize) { + await delay(40); + broadcast('event.assistant.tool_use_delta', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + input_delta: inputStr.slice(i, i + chunkSize), + }); + } + + broadcast('event.assistant.tool_use_completed', sessionId, { + message_id: parentMsgId, + tool_call_id: toolCallId, + input, + }); + + await delay(100); + + broadcast('event.tool.started', sessionId, { + tool_call_id: toolCallId, + tool_name: toolName, + input, + parent_message_id: parentMsgId, + }); + + const lines = outputText.split('\n'); + for (const line of lines) { + await delay(50 + Math.random() * 80); + broadcast('event.tool.output', sessionId, { + tool_call_id: toolCallId, + chunk: line + '\n', + stream: 'stdout', + }); + } + + await delay(100); + broadcast('event.tool.completed', sessionId, { + tool_call_id: toolCallId, + output: outputText, + is_error: false, + duration_ms: 210 + Math.floor(Math.random() * 300), + }); +} + +async function simulateDefaultReply(sessionId, userText) { + const promptId = ulid('pr_'); + const session = sessions.find((s) => s.id === sessionId); + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'idle', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(80); + + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + broadcast('event.message.created', sessionId, { message: userMsg }); + + await delay(150); + + const aMsgId = ulid('msg_'); + const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId); + (messages[sessionId]).push(aMsg); + broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } }); + + await delay(300); + + const mdText = + '## 分析结果\n\n' + + '我检查了相关代码,发现以下问题:\n\n' + + '- **超时配置** 目前硬编码在多处,应该统一到配置文件\n' + + '- **重试逻辑** 缺少指数退避(exponential backoff)\n\n' + + '我先读取 `src/api/client.ts` 确认当前实现:'; + + await streamMarkdown(sessionId, aMsgId, 0, mdText); + + const readCallId = ulid('tc_'); + await simulateToolUse( + sessionId, aMsgId, readCallId, 'read', + { path: 'src/api/client.ts' }, + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n' + ); + + await delay(200); + + const preWriteText = '\n\n找到了,超时硬编码为 `5000`。现在我来更新这个文件:'; + await streamMarkdown(sessionId, aMsgId, 0, preWriteText); + + await delay(200); + + const writeCallId = ulid('tc_'); + const approvalId = ulid('apv_'); + + broadcast('event.assistant.tool_use_started', sessionId, { + message_id: aMsgId, + tool_call_id: writeCallId, + tool_name: 'edit', + content_index: 1, + }); + broadcast('event.assistant.tool_use_completed', sessionId, { + message_id: aMsgId, + tool_call_id: writeCallId, + input: { + path: 'src/api/client.ts', + old_string: 'const TIMEOUT = 5000;', + new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);', + }, + }); + + await delay(150); + + broadcast('event.tool.started', sessionId, { + tool_call_id: writeCallId, + tool_name: 'edit', + input: { + path: 'src/api/client.ts', + old_string: 'const TIMEOUT = 5000;', + new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);', + }, + parent_message_id: aMsgId, + }); + + await delay(100); + + broadcast('event.session.status_changed', sessionId, { + status: 'awaiting_approval', + previous_status: 'running', + current_prompt_id: promptId, + }); + if (session) session.status = 'awaiting_approval'; + + broadcast('event.approval.requested', sessionId, { + approval_id: approvalId, + session_id: sessionId, + tool_call_id: writeCallId, + tool_name: 'edit', + action: 'Edit file src/api/client.ts', + display: { + kind: 'diff', + path: 'src/api/client.ts', + old_text: + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n', + new_text: + 'import { fetch } from "node-fetch";\n\nconst TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n', + summary: 'Replace hardcoded timeout with environment-variable-controlled value', + }, + expires_at: expires60(), + created_at: now(), + }); + + pendingApproval[sessionId] = approvalId; + + pendingContinuation[sessionId] = async () => { + delete pendingContinuation[sessionId]; + delete pendingApproval[sessionId]; + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'awaiting_approval', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(100); + + broadcast('event.tool.completed', sessionId, { + tool_call_id: writeCallId, + output: 'File edited successfully.', + is_error: false, + duration_ms: 34, + }); + + const toolResultMsgId = ulid('msg_'); + const trMsg = mkMsg(toolResultMsgId, sessionId, 'assistant', + [toolResult(writeCallId, 'File edited successfully.')], promptId); + messages[sessionId].push(trMsg); + broadcast('event.message.created', sessionId, { message: trMsg }); + + await delay(200); + + const conclusionText = + '\n\n文件已更新\n\n' + + '现在 `TIMEOUT` 会读取 `API_TIMEOUT_MS` 环境变量,不设置时回退到默认值 `5000`。\n\n' + + '需要我同时更新一下 `.env.example` 文件中的说明吗?'; + + await streamMarkdown(sessionId, aMsgId, 0, conclusionText); + + await delay(100); + + broadcast('event.assistant.completed', sessionId, { + message_id: aMsgId, + finish_reason: 'stop', + }); + + const finalContent = [t(mdText + preWriteText + conclusionText)]; + broadcast('event.message.updated', sessionId, { + message_id: aMsgId, + content: finalContent, + status: 'completed', + }); + const aEntry = messages[sessionId].find((m) => m.id === aMsgId); + if (aEntry) { aEntry.content = finalContent; aEntry.status = 'completed'; } + + await delay(100); + + const newCtx = (session?.usage?.context_tokens || 52000) + 8000; + const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1); + if (session) session.usage = newUsage; + broadcast('event.session.usage_updated', sessionId, { + usage: newUsage, + delta: { + input_tokens: 2400, + output_tokens: 800, + cache_read_tokens: 400, + cache_creation_tokens: 0, + cost_usd: 0.0032, + }, + }); + + await delay(80); + + broadcast('event.session.status_changed', sessionId, { + status: 'idle', + previous_status: 'running', + }); + if (session) session.status = 'idle'; + + broadcastTaskProgress(sessionId); + }; +} + +async function simulateQuestionReply(sessionId, userText) { + const promptId = ulid('pr_'); + const session = sessions.find((s) => s.id === sessionId); + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'idle', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(80); + + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + broadcast('event.message.created', sessionId, { message: userMsg }); + + await delay(200); + + const aMsgId = ulid('msg_'); + const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId); + messages[sessionId].push(aMsg); + broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } }); + + await delay(250); + await streamMarkdown(sessionId, aMsgId, 0, '在开始之前,我需要了解一下你的偏好:'); + + await delay(200); + + const questionId = ulid('qst_'); + + broadcast('event.session.status_changed', sessionId, { + status: 'awaiting_question', + previous_status: 'running', + current_prompt_id: promptId, + }); + if (session) session.status = 'awaiting_question'; + + broadcast('event.question.requested', sessionId, { + question_id: questionId, + session_id: sessionId, + questions: [ + { + id: 'q_1', + question: '你更倾向于哪种代码风格?', + header: '代码风格', + body: '影响注释、命名和函数长度等方面的偏好。', + options: [ + { id: 'opt_1a', label: '简洁优先', description: '短函数、少注释、精炼命名' }, + { id: 'opt_1b', label: '可读性优先', description: '长注释、描述性命名、函数分层' }, + { id: 'opt_1c', label: '性能优先', description: '尽量减少抽象和开销' }, + ], + multi_select: false, + allow_other: false, + }, + { + id: 'q_2', + question: '这次修改应该同时处理哪些子任务?', + header: '范围选择', + options: [ + { id: 'opt_2a', label: '更新单元测试' }, + { id: 'opt_2b', label: '更新文档注释' }, + { id: 'opt_2c', label: '更新 CHANGELOG' }, + { id: 'opt_2d', label: '更新 .env.example' }, + ], + multi_select: true, + allow_other: true, + other_label: '其他', + other_description: '如有特殊要求请填写', + }, + ], + expires_at: expires60(), + created_at: now(), + }); + + pendingQuestion[sessionId] = questionId; + + pendingContinuation[sessionId] = async () => { + delete pendingContinuation[sessionId]; + delete pendingQuestion[sessionId]; + + broadcast('event.session.status_changed', sessionId, { + status: 'running', + previous_status: 'awaiting_question', + current_prompt_id: promptId, + }); + if (session) session.status = 'running'; + + await delay(200); + + const replyText = '好的,已记录你的偏好,按照你的选择来实施修改。稍等……'; + await streamMarkdown(sessionId, aMsgId, 0, '\n\n' + replyText); + + await delay(100); + + broadcast('event.assistant.completed', sessionId, { message_id: aMsgId, finish_reason: 'stop' }); + broadcast('event.message.updated', sessionId, { + message_id: aMsgId, + content: [t(replyText)], + status: 'completed', + }); + + await delay(100); + + const newCtx = (session?.usage?.context_tokens || 20000) + 3000; + const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1); + if (session) session.usage = newUsage; + broadcast('event.session.usage_updated', sessionId, { + usage: newUsage, + delta: { input_tokens: 800, output_tokens: 200, cache_read_tokens: 0, cache_creation_tokens: 0, cost_usd: 0.0008 }, + }); + + await delay(80); + + broadcast('event.session.status_changed', sessionId, { status: 'idle', previous_status: 'running' }); + if (session) session.status = 'idle'; + }; +} + +function broadcastTaskProgress(sessionId) { + const sessionTasks = (tasks[sessionId] || []).filter((t) => t.status === 'running'); + if (!sessionTasks.length) return; + + const intervals = []; + + for (const task of sessionTasks) { + const lines = [ + 'Building TypeScript sources...', + 'Resolving entry points...', + 'Bundling 42 modules...', + 'Emitting declaration files...', + 'Running post-build checks...', + 'Done.', + ]; + let lineIdx = 0; + + const iv = setInterval(() => { + if (lineIdx < lines.length) { + broadcast('event.task.progress', sessionId, { + task_id: task.id, + output_chunk: lines[lineIdx] + '\n', + stream: 'stdout', + }); + lineIdx++; + } else { + clearInterval(iv); + task.status = 'completed'; + task.completed_at = now(); + task.output_preview += '\nDone.'; + broadcast('event.task.completed', sessionId, { + task_id: task.id, + status: 'completed', + output_preview: task.output_preview, + output_bytes: (task.output_bytes || 0) + 64, + }); + } + }, 600); + + intervals.push(iv); + } + + return intervals; +} + +// ---- raw agent-core event simulation (STUB_RAW_EVENTS=1) ---- +// Emits the real daemon's raw event shapes instead of projected event.* frames. +// This lets us verify the client-side agentEventProjector end-to-end. + +function broadcastRaw(type, sessionId, payload) { + const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1); + const session = sessions.find((s) => s.id === sessionId); + if (session) session.last_seq = seq; + const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload: { type, ...payload } }); + for (const ws of sockets) if (ws.readyState === 1) ws.send(frame); + return seq; +} + +async function simulateRawReply(sessionId, userText) { + const session = sessions.find((s) => s.id === sessionId); + const promptId = ulid('pr_'); + const turnId = Math.floor(Math.random() * 100000); + + // Store user message + const userMsgId = ulid('msg_'); + const userMsg = mkMsg(userMsgId, sessionId, 'user', [{ type: 'text', text: userText }], promptId); + (messages[sessionId] = messages[sessionId] || []).push(userMsg); + + await delay(80); + + // turn.started + broadcastRaw('turn.started', sessionId, { + turnId, + origin: { kind: 'user' }, + agentId: 'main', + sessionId, + }); + + await delay(100); + + // turn.step.started + broadcastRaw('turn.step.started', sessionId, { + turnId, + step: 0, + stepId: ulid('step_'), + agentId: 'main', + sessionId, + }); + + await delay(120); + + // thinking.delta + const thinkingText = '分析用户输入:「' + userText + '」,准备回复。'; + for (let i = 0; i < thinkingText.length; i += 10) { + await delay(40); + broadcastRaw('thinking.delta', sessionId, { + turnId, + delta: thinkingText.slice(i, i + 10), + agentId: 'main', + sessionId, + }); + } + + await delay(100); + + // assistant.delta — stream a reply in chunks + const replyText = + '你好!我是 Kimi,你的 AI 助手。\n\n' + + '你发送了:**' + userText + '**\n\n' + + '我已经收到你的消息,正在处理中。'; + + const chunkSize = 8; + for (let i = 0; i < replyText.length; i += chunkSize) { + await delay(60 + Math.random() * 40); + broadcastRaw('assistant.delta', sessionId, { + turnId, + delta: replyText.slice(i, i + chunkSize), + agentId: 'main', + sessionId, + }); + } + + await delay(100); + + // turn.step.completed + broadcastRaw('turn.step.completed', sessionId, { + turnId, + step: 0, + stepId: ulid('step_'), + usage: { + inputOther: 1200, + output: 80, + inputCacheRead: 400, + inputCacheCreation: 0, + }, + finishReason: 'end_turn', + agentId: 'main', + sessionId, + }); + + await delay(80); + + // agent.status.updated + const newCtx = (session?.usage?.context_tokens || 8000) + 2000; + broadcastRaw('agent.status.updated', sessionId, { + model: session?.agent_config?.model || 'moonshot-v1-128k', + contextTokens: newCtx, + maxContextTokens: 131072, + contextUsage: newCtx / 131072, + planMode: false, + permission: 'auto', + usage: { + byModel: {}, + total: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 }, + currentTurn: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 }, + }, + agentId: 'main', + sessionId, + }); + if (session) session.usage.context_tokens = newCtx; + + await delay(80); + + // turn.ended + broadcastRaw('turn.ended', sessionId, { + turnId, + reason: 'completed', + agentId: 'main', + sessionId, + }); + + await delay(50); + + // prompt.completed + broadcastRaw('prompt.completed', sessionId, { + agentId: 'main', + sessionId, + promptId, + }); + + if (session) session.status = 'idle'; +} + +function simulateReply(sessionId, userText) { + if (RAW_EVENTS_MODE) { + simulateRawReply(sessionId, userText).catch(console.error); + return; + } + + const lower = userText.toLowerCase(); + const isQuestion = lower.includes('问') || lower.includes('ask') || lower.includes('?') || lower.includes('?'); + + if (isQuestion) { + simulateQuestionReply(sessionId, userText).catch(console.error); + } else { + simulateDefaultReply(sessionId, userText).catch(console.error); + } +} + +// ---- REST ---- + +const server = http.createServer((req, res) => { + const { url = '', method = 'GET' } = req; + const path = url.split('?')[0]; + + // Permissive CORS so a browser dev server on another port can read responses. + res.setHeader('access-control-allow-origin', req.headers.origin || '*'); + res.setHeader('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS'); + res.setHeader('access-control-allow-headers', 'content-type,x-request-id,authorization'); + res.setHeader('access-control-allow-credentials', 'true'); + res.setHeader('access-control-max-age', '86400'); + if (method === 'OPTIONS') { res.statusCode = 204; return res.end(); } + res.setHeader('content-type', 'application/json; charset=utf-8'); + + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const json = () => { try { return JSON.parse(body || '{}'); } catch { return {}; } }; + // Segments after stripping /api/v1 prefix + // path: /api/v1/sessions/ses_1/messages → stripped: /sessions/ses_1/messages + // We route on the stripped path for clarity. + const isApiV1 = path.startsWith('/api/v1'); + const stripped = isApiV1 ? path.slice('/api/v1'.length) : path; + const seg = stripped.split('/').filter(Boolean); + // seg[0]: resource group (sessions, providers, models, auth, …) + // seg[1]: first id + // seg[2]: sub-resource or action + const sid = seg[0] === 'sessions' ? seg[1] : undefined; + + // ---- healthz / meta ---- + if (stripped === '/healthz' || path === '/healthz') { + return res.end(ok({ ok: true })); + } + if (stripped === '/meta' || path === '/meta') { + return res.end(ok({ + daemon_version: '0.0.0-stub', + capabilities: { websocket: true, file_upload: false, fs_query: false, mcp: false, background_tasks: true }, + server_id: 'stub', + started_at: STARTED_AT, + })); + } + + // Require /api/v1 prefix for everything below + if (!isApiV1) { + return res.end(ok({})); + } + + // ---- sessions collection ---- + if (stripped === '/sessions' && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const pageSize = Math.min(Number(sp.get('page_size') || '20'), 100); + const status = sp.get('status'); + let items = [...sessions]; + if (status) items = items.filter((s) => s.status === status); + items = items.slice(0, pageSize); + return res.end(ok({ items, has_more: false })); + } + if (stripped === '/sessions' && method === 'POST') { + const b = json(); + const s = mkSession(ulid('ses_'), b.title || '新会话', 'idle', 8000, 0); + if (b.metadata) Object.assign(s.metadata, b.metadata); + if (b.agent_config) Object.assign(s.agent_config, b.agent_config); + sessions.unshift(s); + messages[s.id] = []; + tasks[s.id] = []; + seqBySession[s.id] = 0; + broadcast('event.session.created', s.id, { session: s }); + return res.end(ok(s)); + } + + // ---- sessions/{id} ---- + if (seg[0] === 'sessions' && sid && seg.length === 2) { + const session = sessions.find((s) => s.id === sid); + if (method === 'GET') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + return res.end(ok(session)); + } + if (method === 'PATCH') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + if (b.title !== undefined) session.title = b.title; + if (b.metadata !== undefined) Object.assign(session.metadata, b.metadata); + if (b.agent_config !== undefined) Object.assign(session.agent_config, b.agent_config); + if (b.permission_rules !== undefined) session.permission_rules = b.permission_rules; + session.updated_at = now(); + broadcast('event.session.updated', sid, { session, changed_fields: Object.keys(b) }); + return res.end(ok(session)); + } + if (method === 'DELETE') { + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const idx = sessions.findIndex((s) => s.id === sid); + if (idx >= 0) sessions.splice(idx, 1); + broadcast('event.session.deleted', sid, { session_id: sid }); + return res.end(ok({ deleted: true })); + } + } + + // ---- messages ---- + if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 3 && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const pageSize = Math.min(Number(sp.get('page_size') || '50'), 100); + const items = (messages[sid] || []).slice(-pageSize); + return res.end(ok({ items, has_more: false })); + } + if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 4 && method === 'GET') { + const msgId = seg[3]; + const msg = (messages[sid] || []).find((m) => m.id === msgId); + if (!msg) return res.end(fail(40403, `message ${msgId} does not exist`)); + return res.end(ok(msg)); + } + + // ---- tasks ---- + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 3 && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const status = sp.get('status'); + let items = tasks[sid] || []; + if (status) items = items.filter((t) => t.status === status); + return res.end(ok({ items })); + } + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'GET') { + const taskId = seg[3]; + const task = (tasks[sid] || []).find((t) => t.id === taskId); + if (!task) return res.end(fail(40406, `task ${taskId} does not exist`)); + return res.end(ok(task)); + } + if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':cancel')) { + const taskId = seg[3].replace(':cancel', ''); + const task = (tasks[sid] || []).find((t) => t.id === taskId); + if (!task) return res.end(fail(40406, `task ${taskId} does not exist`)); + if (task.status !== 'running') return res.end(fail(40904, 'task already finished')); + task.status = 'cancelled'; + task.completed_at = now(); + return res.end(ok({ cancelled: true })); + } + + // ---- prompts ---- + if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 3 && method === 'POST') { + const b = json(); + const content = b.content || []; + // Extract text from text-type parts only; tolerate image and other part types without crashing. + const userText = content.filter((c) => c.type === 'text').map((c) => c.text).filter(Boolean).join(''); + const imageCount = content.filter((c) => c.type === 'image').length; + const promptId = ulid('pr_'); + const userMsgId = ulid('msg_'); + const effectiveText = userText || (imageCount > 0 ? `[${imageCount} image(s) attached]` : '你好'); + setTimeout(() => simulateReply(sid, effectiveText), 100); + return res.end(ok({ prompt_id: promptId, user_message_id: userMsgId })); + } + if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':abort')) { + return res.end(fail(40903, 'prompt already completed', { aborted: false })); + } + + // ---- approvals ---- + if (seg[0] === 'sessions' && seg[2] === 'approvals' && seg.length === 4 && method === 'POST') { + const approvalId = seg[3]; + const b = json(); + const resolvedAt = now(); + + broadcast('event.approval.resolved', sid, { + approval_id: approvalId, + decision: b.decision || 'approved', + scope: b.scope, + feedback: b.feedback, + resolved_by: 'user', + resolved_at: resolvedAt, + }); + + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + + return res.end(ok({ resolved: true, resolved_at: resolvedAt })); + } + + // ---- questions ---- + if (seg[0] === 'sessions' && seg[2] === 'questions' && seg.length === 4 && method === 'POST') { + const questionIdRaw = seg[3]; + + if (questionIdRaw.endsWith(':dismiss')) { + const questionId = questionIdRaw.replace(':dismiss', ''); + const dismissedAt = now(); + broadcast('event.question.dismissed', sid, { + question_id: questionId, + dismissed_by: 'user', + dismissed_at: dismissedAt, + }); + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + return res.end(fail(40909, 'question.dismissed', { dismissed: true, dismissed_at: dismissedAt })); + } + + const questionId = questionIdRaw; + const b = json(); + const resolvedAt = now(); + + broadcast('event.question.answered', sid, { + question_id: questionId, + answers: b.answers || {}, + method: b.method, + note: b.note, + resolved_by: 'user', + resolved_at: resolvedAt, + }); + + if (pendingContinuation[sid]) { + const cont = pendingContinuation[sid]; + setTimeout(() => cont(), 200); + } + + return res.end(ok({ resolved: true, resolved_at: resolvedAt })); + } + + // ---- PRESUMED: models ---- + // PRESUMED — not in current daemon docs; swap when backend defines them. + if (stripped === '/models' && method === 'GET') { + return res.end(ok({ items: models })); + } + + // ---- PRESUMED: providers ---- + // PRESUMED — not in current daemon docs; swap when backend defines them. + if (stripped === '/providers' && method === 'GET') { + return res.end(ok({ items: providers })); + } + if (stripped === '/providers' && method === 'POST') { + const b = json(); + const newId = ulid('prov_'); + const newProv = { + id: newId, + type: b.type || 'custom', + base_url: b.base_url || undefined, + default_model: b.default_model || undefined, + has_api_key: !!b.api_key, + status: 'connected', + models: [], + }; + providers.push(newProv); + models.push( + { provider: newId, model: `${b.type || 'custom'}-default`, display_name: `${b.type || 'custom'} Default`, max_context_size: 128000, capabilities: [] }, + ); + newProv.models = [`${b.type || 'custom'}-default`]; + return res.end(ok(newProv)); + } + if (seg[0] === 'providers' && seg.length === 2 && method === 'DELETE') { + const provId = seg[1]; + const idx = providers.findIndex((p) => p.id === provId); + if (idx < 0) return res.end(fail(40401, `provider ${provId} not found`)); + providers.splice(idx, 1); + const toRemove = models.filter((m) => m.provider === provId); + for (const m of toRemove) { + const mi = models.indexOf(m); + if (mi >= 0) models.splice(mi, 1); + } + return res.end(ok({ deleted: true })); + } + if (seg[0] === 'providers' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':refresh')) { + const provId = seg[1].replace(':refresh', ''); + const prov = providers.find((p) => p.id === provId); + if (!prov) return res.end(fail(40401, `provider ${provId} not found`)); + prov.status = 'connected'; + return res.end(ok(prov)); + } + + // ---- workspaces + daemon folder browser (demo) ---- + // GET /api/v1/workspaces — derived from seeded session cwds + a couple demo + // workspaces, each with a wd__ id (matches the real shape). + if (stripped === '/workspaces' && method === 'GET') { + return res.end(ok({ items: workspaces })); + } + // POST /api/v1/workspaces { root, name? } — echo a wd_ workspace (idempotent per root) + if (stripped === '/workspaces' && method === 'POST') { + const b = json(); + const root = String(b.root || '').replace(/\/+$/, '') || '/'; + const existing = workspaces.find((w) => w.root === root); + if (existing) return res.end(ok(existing)); + const ws = mkWorkspace(root, b.name); + workspaces.unshift(ws); + return res.end(ok(ws)); + } + // PATCH /api/v1/workspaces/{id} { name } + if (seg[0] === 'workspaces' && seg.length === 2 && method === 'PATCH') { + const ws = workspaces.find((w) => w.id === seg[1]); + if (!ws) return res.end(fail(40401, `workspace ${seg[1]} not found`)); + const b = json(); + if (b.name !== undefined) ws.name = b.name; + return res.end(ok(ws)); + } + // DELETE /api/v1/workspaces/{id} (registry only) + if (seg[0] === 'workspaces' && seg.length === 2 && method === 'DELETE') { + const idx = workspaces.findIndex((w) => w.id === seg[1]); + if (idx < 0) return res.end(fail(40401, `workspace ${seg[1]} not found`)); + workspaces.splice(idx, 1); + return res.end(ok({ deleted: true })); + } + + // GET /api/v1/fs:home — picker start dir + recent roots + if (stripped === '/fs:home' && method === 'GET') { + return res.end(ok({ home: FS_HOME, recent_roots: FS_RECENT })); + } + // GET /api/v1/fs:browse?path= — subdirs only + if (stripped === '/fs:browse' && method === 'GET') { + const sp = new URLSearchParams(url.split('?')[1] || ''); + const reqPath = (sp.get('path') || FS_HOME).replace(/\/+$/, '') || '/'; + return res.end(ok(browseDir(reqPath))); + } + + // ---- REAL auth endpoints ---- + + // GET /api/v1/auth — readiness check + if (stripped === '/auth' && method === 'GET') { + return res.end(ok({ + ready: loggedIn, + providers_count: loggedIn ? 1 : 0, + default_model: loggedIn ? 'kimi-code/kimi-for-coding' : null, + managed_provider: loggedIn ? { status: 'authenticated' } : null, + })); + } + + // POST /api/v1/oauth/login — start singleton device flow + if (stripped === '/oauth/login' && method === 'POST') { + const flowId = ulid('flow_'); + const userCode = 'DEMO-1234'; + const expiresIn = 1800; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + currentFlow = { + flow_id: flowId, + provider: 'managed:kimi-code', + verification_uri: 'https://www.kimi.com/code/authorize_device', + verification_uri_complete: `https://www.kimi.com/code/authorize_device?user_code=${userCode}`, + user_code: userCode, + expires_in: expiresIn, + interval: 2, + status: 'pending', + expires_at: expiresAt, + }; + + // Auto-flip to 'authenticated' after 5 seconds + const capturedFlowId = flowId; + setTimeout(() => { + if (currentFlow && currentFlow.flow_id === capturedFlowId && currentFlow.status === 'pending') { + currentFlow.status = 'authenticated'; + currentFlow.resolved_at = now(); + loggedIn = true; + } + }, 5000); + + return res.end(ok({ ...currentFlow })); + } + + // GET /api/v1/oauth/login — poll current singleton flow + if (stripped === '/oauth/login' && method === 'GET') { + return res.end(ok(currentFlow)); + } + + // DELETE /api/v1/oauth/login — cancel current flow + if (stripped === '/oauth/login' && method === 'DELETE') { + if (currentFlow) { + currentFlow.status = 'cancelled'; + currentFlow.resolved_at = now(); + } + const wasCancelled = currentFlow !== null; + currentFlow = null; + return res.end(ok({ cancelled: wasCancelled, status: 'cancelled' })); + } + + // POST /api/v1/oauth/logout — logout + if (stripped === '/oauth/logout' && method === 'POST') { + loggedIn = false; + currentFlow = null; + return res.end(ok({ logged_out: true })); + } + + // ---- fs:git_status ---- + // POST /api/v1/sessions/{id}/fs:git_status + if (seg[0] === 'sessions' && seg[2] === 'fs:git_status' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + // Return realistic git status keyed by session + const gitStatusBySid = { + ses_1: { + branch: 'feat/web', + ahead: 1, + behind: 0, + entries: { + 'apps/kimi-code/package.json': 'modified', + 'packages/daemon/src/middleware/schema.ts': 'added', + 'apps/kimi-web/src/composables/useKimiWebClient.ts': 'modified', + }, + }, + ses_2: { + branch: 'fix/tui-render', + ahead: 0, + behind: 2, + entries: { + 'apps/kimi-tui/src/renderer/loop.ts': 'modified', + }, + }, + ses_3: { + branch: 'refactor/auth-errors', + ahead: 3, + behind: 0, + entries: { + 'apps/kimi-cli/src/auth/errors.ts': 'added', + 'apps/kimi-cli/src/auth/login.ts': 'modified', + 'apps/kimi-cli/src/auth/refresh.ts': 'modified', + 'apps/kimi-cli/src/commands/auth.ts': 'modified', + }, + }, + ses_4: { + branch: 'feat/file-search', + ahead: 0, + behind: 0, + entries: {}, + }, + }; + const gs = gitStatusBySid[sid] ?? { + branch: 'main', + ahead: 0, + behind: 0, + entries: {}, + }; + return res.end(ok(gs)); + } + + // ---- fs:list ---- + // POST /api/v1/sessions/{id}/fs:list body: { path, depth?, include_git_status? } + if (seg[0] === 'sessions' && seg[2] === 'fs:list' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + const reqPath = (b.path || '.').replace(/^\.\//, '').replace(/\/$/, '') || '.'; + + const modifiedNow = now(); + + // Nested stub filesystem tree + const fsTree = { + '.': [ + { path: 'src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_src', child_count: 5 }, + { path: 'docs', name: 'docs', kind: 'directory', modified_at: modifiedNow, etag: 'etag_docs', child_count: 2 }, + { path: 'packages', name: 'packages', kind: 'directory', modified_at: modifiedNow, etag: 'etag_pkg', child_count: 3 }, + { path: 'package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_pkgjson', size: 892, mime: 'application/json', language_id: 'json' }, + { path: 'README.md', name: 'README.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_readme', size: 1240, mime: 'text/markdown', language_id: 'markdown' }, + { path: 'tsconfig.json', name: 'tsconfig.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_tsconfig', size: 320, mime: 'application/json', language_id: 'json' }, + { path: 'logo.png', name: 'logo.png', kind: 'file', modified_at: modifiedNow, etag: 'etag_logo', size: 68, mime: 'image/png', is_binary: true }, + ], + 'src': [ + { path: 'src/components', name: 'components', kind: 'directory', modified_at: modifiedNow, etag: 'etag_comp', child_count: 4 }, + { path: 'src/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api', child_count: 2 }, + { path: 'src/main.ts', name: 'main.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_main', size: 420, mime: 'text/typescript', language_id: 'typescript' }, + { path: 'src/App.vue', name: 'App.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_app', size: 3200, mime: 'text/x-vue', language_id: 'vue' }, + { path: 'src/style.css', name: 'style.css', kind: 'file', modified_at: modifiedNow, etag: 'etag_style', size: 680, mime: 'text/css', language_id: 'css', git_status: 'modified' }, + ], + 'src/components': [ + { path: 'src/components/TabBar.vue', name: 'TabBar.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_tabbar', size: 1800, mime: 'text/x-vue', language_id: 'vue' }, + { path: 'src/components/FileTree.vue', name: 'FileTree.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filetree', size: 4200, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' }, + { path: 'src/components/FilePreview.vue', name: 'FilePreview.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filepreview', size: 3900, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' }, + { path: 'src/components/DiffView.vue', name: 'DiffView.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_diffview', size: 2800, mime: 'text/x-vue', language_id: 'vue' }, + ], + 'src/api': [ + { path: 'src/api/types.ts', name: 'types.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_types', size: 5200, mime: 'text/typescript', language_id: 'typescript' }, + { path: 'src/api/client.ts', name: 'client.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_client', size: 1800, mime: 'text/typescript', language_id: 'typescript', git_status: 'modified' }, + ], + 'docs': [ + { path: 'docs/api.md', name: 'api.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_apidoc', size: 2400, mime: 'text/markdown', language_id: 'markdown', git_status: 'modified' }, + { path: 'docs/CHANGELOG.md', name: 'CHANGELOG.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_changelog', size: 5600, mime: 'text/markdown', language_id: 'markdown' }, + ], + 'packages': [ + { path: 'packages/daemon', name: 'daemon', kind: 'directory', modified_at: modifiedNow, etag: 'etag_daemon', child_count: 8 }, + { path: 'packages/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api_pkg', child_count: 5 }, + { path: 'packages/types', name: 'types', kind: 'directory', modified_at: modifiedNow, etag: 'etag_types_pkg', child_count: 3 }, + ], + 'packages/daemon': [ + { path: 'packages/daemon/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_dsrc', child_count: 6 }, + { path: 'packages/daemon/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_dpkg', size: 640, mime: 'application/json', language_id: 'json' }, + ], + 'packages/api': [ + { path: 'packages/api/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_asrc', child_count: 3 }, + { path: 'packages/api/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_apkg', size: 420, mime: 'application/json', language_id: 'json' }, + ], + }; + + const items = fsTree[reqPath] || []; + return res.end(ok({ items, truncated: false })); + } + + // ---- fs:read ---- + // POST /api/v1/sessions/{id}/fs:read body: { path, offset?, length? } + if (seg[0] === 'sessions' && seg[2] === 'fs:read' && seg.length === 3 && method === 'POST') { + const session = sessions.find((s) => s.id === sid); + if (!session) return res.end(fail(40401, `session ${sid} does not exist`)); + const b = json(); + const reqPath = b.path || 'README.md'; + const modifiedNow = now(); + + // Tiny 1×1 transparent PNG (base64) + const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + const fileContents = { + 'README.md': { + content: + '# Kimi Code Web\n\n' + + 'A browser-based workspace client for the Kimi Code daemon.\n\n' + + '## Features\n\n' + + '- **~/chat** — Conversational AI interface with tool calls and approvals\n' + + '- **~/diff** — Real-time git status and changed-file tracking\n' + + '- **~/tasks** — Background task monitoring (subagents, bash, tools)\n' + + '- **~/files** — Workspace file browser with lazy tree and preview\n\n' + + '## Quick Start\n\n' + + '```bash\n' + + 'pnpm install\n' + + 'pnpm -C apps/kimi-web run dev:stub # start stub daemon\n' + + 'pnpm -C apps/kimi-web run dev # start Vite dev server\n' + + '```\n\n' + + '## Architecture\n\n' + + 'The web client is a Vue 3 + TypeScript SPA. All daemon calls go through\n' + + '`useKimiWebClient` composable, which owns the reactive state and exposes\n' + + 'typed action functions to components.\n\n' + + '> **Note:** The stub daemon (`dev/stub-daemon.mjs`) speaks the real wire\n' + + '> protocol closely enough for all UI features to be fully demoable.\n', + encoding: 'utf-8', + mime: 'text/markdown', + language_id: 'markdown', + size: 1240, + line_count: 35, + is_binary: false, + etag: 'etag_readme', + truncated: false, + }, + 'package.json': { + content: JSON.stringify({ + name: '@moonshot-ai/kimi-web', + version: '0.1.1', + private: true, + license: 'MIT', + type: 'module', + scripts: { + dev: 'vite', + 'dev:stub': 'node dev/stub-daemon.mjs', + build: 'vite build', + typecheck: 'vue-tsc --noEmit', + test: 'vitest run', + }, + dependencies: { marked: '^14.1.4', vue: '^3.5.35' }, + devDependencies: { + '@vitejs/plugin-vue': '^5.2.4', + '@vue/test-utils': '^2.4.6', + typescript: '6.0.2', + vite: '^6.3.3', + vitest: '^2.1.8', + }, + }, null, 2), + encoding: 'utf-8', + mime: 'application/json', + language_id: 'json', + size: 892, + line_count: 28, + is_binary: false, + etag: 'etag_pkgjson', + truncated: false, + }, + 'tsconfig.json': { + content: JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + jsx: 'preserve', + lib: ['ES2022', 'DOM'], + }, + include: ['src/**/*'], + exclude: ['node_modules'], + }, null, 2), + encoding: 'utf-8', + mime: 'application/json', + language_id: 'json', + size: 320, + line_count: 14, + is_binary: false, + etag: 'etag_tsconfig', + truncated: false, + }, + 'src/api/client.ts': { + content: + '// src/api/client.ts\n' + + '// Daemon HTTP + WS client — maps wire protocol to app types.\n\n' + + 'import type { KimiWebApi } from \'./types\';\n\n' + + 'const DEFAULT_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS ?? 30_000);\n\n' + + 'export function createApiClient(baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS): KimiWebApi {\n' + + ' // Implementation elided for brevity in the stub.\n' + + ' return {} as KimiWebApi;\n' + + '}\n', + encoding: 'utf-8', + mime: 'text/typescript', + language_id: 'typescript', + size: 1800, + line_count: 11, + is_binary: false, + etag: 'etag_client', + truncated: false, + }, + 'src/style.css': { + content: + '@import "tailwindcss";\n\n' + + ':root {\n' + + ' --ink: #14171c;\n' + + ' --text: #3f454d;\n' + + ' --dim: #697079;\n' + + ' --muted: #8b929b;\n' + + ' --line: #e7eaee;\n' + + ' --panel: #fafbfc;\n' + + ' --bg: #ffffff;\n' + + ' --blue: #1565c0;\n' + + ' --blue2: #0d4f9e;\n' + + ' --soft: #e9f0fa;\n' + + ' --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;\n' + + '}\n\n' + + 'body {\n' + + ' font-family: var(--mono);\n' + + ' color: var(--text);\n' + + ' background: var(--bg);\n' + + ' font-size: 12.5px;\n' + + ' line-height: 1.55;\n' + + '}\n', + encoding: 'utf-8', + mime: 'text/css', + language_id: 'css', + size: 680, + line_count: 24, + is_binary: false, + etag: 'etag_style', + truncated: false, + }, + 'docs/api.md': { + content: + '# API Reference\n\n' + + '## File System Endpoints\n\n' + + '### `POST /api/v1/sessions/{id}/fs:list`\n\n' + + 'List directory contents.\n\n' + + '**Request body:**\n' + + '```json\n' + + '{ "path": "src", "include_git_status": true }\n' + + '```\n\n' + + '**Response:**\n' + + '```json\n' + + '{ "items": [...], "truncated": false }\n' + + '```\n\n' + + '### `POST /api/v1/sessions/{id}/fs:read`\n\n' + + 'Read file content.\n\n' + + '**Request body:**\n' + + '```json\n' + + '{ "path": "README.md" }\n' + + '```\n\n' + + '**Response:**\n' + + '```json\n' + + '{ "content": "...", "encoding": "utf-8", "mime": "text/markdown", ... }\n' + + '```\n', + encoding: 'utf-8', + mime: 'text/markdown', + language_id: 'markdown', + size: 2400, + line_count: 42, + is_binary: false, + etag: 'etag_apidoc', + truncated: false, + }, + 'logo.png': { + content: PNG_1X1, + encoding: 'base64', + mime: 'image/png', + size: 68, + is_binary: true, + etag: 'etag_logo', + truncated: false, + }, + }; + + const fileData = fileContents[reqPath]; + if (fileData) { + return res.end(ok({ path: reqPath, ...fileData })); + } + + // Generic fallback for any unknown path + const ext = reqPath.split('.').pop() || ''; + const mimeMap = { + ts: 'text/typescript', vue: 'text/x-vue', js: 'text/javascript', + json: 'application/json', md: 'text/markdown', css: 'text/css', + html: 'text/html', sh: 'text/x-sh', txt: 'text/plain', + }; + const langMap = { + ts: 'typescript', vue: 'vue', js: 'javascript', json: 'json', + md: 'markdown', css: 'css', html: 'html', sh: 'shellscript', + }; + const mime = mimeMap[ext] || 'text/plain'; + const lang = langMap[ext] || ext; + const fallbackContent = `// ${reqPath}\n// (stub: content not seeded for this path)\n`; + return res.end(ok({ + path: reqPath, + content: fallbackContent, + encoding: 'utf-8', + mime, + language_id: lang || undefined, + size: fallbackContent.length, + line_count: 2, + is_binary: false, + etag: 'etag_fallback_' + reqPath.replace(/[^a-z0-9]/gi, '_'), + truncated: false, + })); + } + + // ---- file upload ---- + // POST /api/v1/files (multipart/form-data: file, name?, expires_in_sec?) + // The stub does not parse multipart fully — it just returns a synthesised FileMeta. + if (stripped === '/files' && method === 'POST') { + const fileId = ulid('file_'); + // Try to extract filename from Content-Disposition if possible; fall back to generic name. + const contentType = req.headers['content-type'] || ''; + const nameMatch = body.match(/filename="([^"]+)"/); + const fileName = nameMatch ? nameMatch[1] : 'upload.png'; + const fileMeta = { + id: fileId, + name: fileName, + media_type: 'image/png', + size: body.length, + created_at: now(), + }; + return res.end(ok(fileMeta)); + } + + // ---- tools / mcp ---- + if (stripped === '/tools' && method === 'GET') { + return res.end(ok({ + tools: [ + { name: 'read', description: 'Read a file from disk', input_schema: {}, source: 'builtin' }, + { name: 'bash', description: 'Run a shell command', input_schema: {}, source: 'builtin' }, + { name: 'edit', description: 'Edit a file with old/new string replacement', input_schema: {}, source: 'builtin' }, + { name: 'write', description: 'Write a new file', input_schema: {}, source: 'builtin' }, + { name: 'ls', description: 'List directory contents', input_schema: {}, source: 'builtin' }, + { name: 'grep', description: 'Search file contents with regex', input_schema: {}, source: 'builtin' }, + ], + })); + } + if (stripped === '/mcp/servers' && method === 'GET') { + return res.end(ok({ servers: [] })); + } + + // Fallback + return res.end(ok({})); + }); +}); + +// ---- WS ---- + +const wss = new WebSocketServer({ server, path: '/api/v1/ws' }); + +wss.on('connection', (ws) => { + sockets.add(ws); + + ws.send(JSON.stringify({ + type: 'server_hello', + timestamp: now(), + payload: { + server_id: 'stub', + heartbeat_ms: 30000, + max_event_buffer_size: 1000, + capabilities: { event_batching: false, compression: false }, + }, + })); + + const ping = setInterval(() => { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ type: 'ping', timestamp: now(), payload: { nonce: ulid('n_') } })); + } + }, 30000); + + ws.on('message', (raw) => { + let m; + try { m = JSON.parse(String(raw)); } catch { return; } + + if (m.type === 'client_hello') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { + accepted_subscriptions: m.payload?.subscriptions || [], + resync_required: [], + }, + })); + } + + if (m.type === 'subscribe') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { + accepted: m.payload?.session_ids || [], + not_found: [], + resync_required: [], + }, + })); + } + + if (m.type === 'unsubscribe') { + ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: {} })); + } + + if (m.type === 'abort') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { aborted: false }, + })); + } + + if (m.type === 'pong') { + // heartbeat response — no-op + } + + if (m.type === 'watch_fs_add' || m.type === 'watch_fs_remove') { + ws.send(JSON.stringify({ + type: 'ack', id: m.id, code: 0, msg: 'success', + payload: { watched_paths: m.payload?.paths || [] }, + })); + } + }); + + ws.on('close', () => { + clearInterval(ping); + sockets.delete(ws); + }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[stub-daemon] REST+WS on http://127.0.0.1:${PORT} (Ctrl+C to stop)`); + console.log(`[stub-daemon] Routes: /api/v1/* (healthz, models, providers, auth/login, auth/logout, auth/status)`); + console.log(`[stub-daemon] WS path: /api/v1/ws`); + console.log(`[stub-daemon] Event mode: ${RAW_EVENTS_MODE ? 'RAW agent-core events (STUB_RAW_EVENTS=1)' : 'projected event.* protocol (default)'}`); + console.log(`[stub-daemon] Seeded ${sessions.length} sessions, ${Object.values(messages).flat().length} messages`); +}); diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html new file mode 100644 index 000000000..d7f0ce58d --- /dev/null +++ b/apps/kimi-web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Kimi Web + + +

+ + + diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json new file mode 100644 index 000000000..61df37b9d --- /dev/null +++ b/apps/kimi-web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@moonshot-ai/kimi-web", + "version": "0.1.1", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "dev:stub": "node dev/stub-daemon.mjs", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "highlight.js": "^11.11.1", + "marked": "^14.1.4", + "vue": "^3.5.35", + "vue-i18n": "^11.4.5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@vitejs/plugin-vue": "^5.2.4", + "@vue/test-utils": "^2.4.6", + "jsdom": "^25.0.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.2", + "vite": "^6.3.3", + "vitest": "^2.1.8", + "vue-tsc": "~3.2.0", + "ws": "^8.18.0" + } +} diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue new file mode 100644 index 000000000..3ddb999b9 --- /dev/null +++ b/apps/kimi-web/src/App.vue @@ -0,0 +1,452 @@ + + + + + + diff --git a/apps/kimi-web/src/api/config.ts b/apps/kimi-web/src/api/config.ts new file mode 100644 index 000000000..4d3bca10e --- /dev/null +++ b/apps/kimi-web/src/api/config.ts @@ -0,0 +1,60 @@ +// apps/kimi-web/src/api/config.ts +// Reads Vite env, builds REST/WS URLs, manages stable clientId. + +const CLIENT_ID_KEY = 'kimi-web.client-id'; + +export interface KimiApiConfig { + daemonHttpUrl: string; + clientId: string; +} + +export function readKimiApiConfig(): KimiApiConfig { + return { + daemonHttpUrl: normalizeDaemonOrigin(import.meta.env.VITE_KIMI_DAEMON_HTTP_URL), + clientId: getClientId(), + }; +} + +// Default to SAME-ORIGIN so we never depend on CORS: +// - dev: the SPA is served by Vite; the Vite dev proxy forwards /v1, /healthz +// and /v1/ws to the daemon (see vite.config.ts), so the browser only ever +// talks to its own origin. +// - prod: `kimi web` serves this built SPA from the daemon itself, so the +// daemon's origin already is the API origin. +// Set VITE_KIMI_DAEMON_HTTP_URL to connect directly to an absolute daemon +// origin instead (that path does require the daemon to send CORS headers). +function defaultDaemonOrigin(): string { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin; + } + return 'http://127.0.0.1:7878'; +} + +export function normalizeDaemonOrigin(value: string | undefined): string { + const raw = value && value.trim() ? value : defaultDaemonOrigin(); + const url = new URL(raw); + url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +// The real daemon serves everything (incl. healthz + ws) under the /api/v1 prefix. +export function buildRestUrl(origin: string, path: string): string { + return `${origin}/api/v1${path.startsWith('/') ? path : `/${path}`}`; +} + +export function buildWsUrl(origin: string, clientId: string): string { + const url = new URL(`${origin}/api/v1/ws`); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('client_id', clientId); + return url.toString(); +} + +function getClientId(): string { + const stored = globalThis.localStorage?.getItem(CLIENT_ID_KEY); + if (stored) return stored; + const generated = `web_${globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)}`; + globalThis.localStorage?.setItem(CLIENT_ID_KEY, generated); + return generated; +} diff --git a/apps/kimi-web/src/api/daemon/__tests__/agentEventProjector.test.ts b/apps/kimi-web/src/api/daemon/__tests__/agentEventProjector.test.ts new file mode 100644 index 000000000..ad0070cd9 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/__tests__/agentEventProjector.test.ts @@ -0,0 +1,551 @@ +// apps/kimi-web/src/api/daemon/__tests__/agentEventProjector.test.ts +// +// End-to-end unit test: feeds the projector a real captured sequence of raw +// agent-core events, runs the resulting AppEvents through reduceAppEvent, and +// asserts on the final KimiClientState. + +import { describe, expect, it } from 'vitest'; +import { classifyFrame, createAgentProjector, isRawAgentCoreEvent } from '../agentEventProjector'; +import { createInitialState, reduceAppEvent } from '../eventReducer'; +import { toAppEvent } from '../mappers'; +import type { AppEvent, AppMessage } from '../../types'; + +// --------------------------------------------------------------------------- +// Helper: feed events through projector + reducer +// --------------------------------------------------------------------------- + +interface RawEvent { + type: string; + payload: unknown; +} + +function runSequence(sessionId: string, rawEvents: RawEvent[]) { + const projector = createAgentProjector(); + let state = createInitialState(); + + // Pre-populate the session in state so sessionStatusChanged / sessionUsageUpdated + // have something to mutate (the reducer silently ignores unknown session ids for + // these events, so we seed a minimal session). + const dummySession = { + id: sessionId, + title: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'idle' as const, + cwd: '/', + model: '', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }; + state = reduceAppEvent( + state, + { type: 'sessionCreated', session: dummySession }, + { sessionId, seq: 0 }, + ); + + let seq = 1; + const allAppEvents: AppEvent[] = []; + + for (const raw of rawEvents) { + const appEvents = projector.project(raw.type, raw.payload, sessionId); + for (const appEvent of appEvents) { + allAppEvents.push(appEvent); + state = reduceAppEvent(state, appEvent, { sessionId, seq: seq++ }); + } + } + + return { state, allAppEvents }; +} + +/** + * Mirror the real ws.ts routing: classify each incoming frame, then either + * project it (agent path, using the prefix-stripped type) or run it through + * toAppEvent() (protocol path). This exercises the "event."-prefix handling. + */ +function runFramesThroughRouting(sessionId: string, frames: RawEvent[]) { + const projector = createAgentProjector(); + let state = createInitialState(); + + const dummySession = { + id: sessionId, + title: 'test', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'idle' as const, + cwd: '/', + model: '', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }; + state = reduceAppEvent( + state, + { type: 'sessionCreated', session: dummySession }, + { sessionId, seq: 0 }, + ); + + let seq = 1; + const allAppEvents: AppEvent[] = []; + + for (const frame of frames) { + const decision = classifyFrame(frame.type, frame.payload); + if (decision.route === 'agent') { + const appEvents = projector.project(decision.agentType, frame.payload, sessionId); + for (const appEvent of appEvents) { + allAppEvents.push(appEvent); + state = reduceAppEvent(state, appEvent, { sessionId, seq: seq++ }); + } + } else if (decision.route === 'protocol') { + // toAppEvent expects a full wire frame; build a minimal one. + const wireFrame = { + type: frame.type, + seq: seq, + session_id: sessionId, + timestamp: new Date().toISOString(), + payload: frame.payload, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + const appEvent = toAppEvent(wireFrame); + allAppEvents.push(appEvent); + state = reduceAppEvent(state, appEvent, { sessionId, seq: seq++ }); + } + } + + return { state, allAppEvents }; +} + +// --------------------------------------------------------------------------- +// Captured sequence (real daemon shape) +// --------------------------------------------------------------------------- + +const SESSION_ID = 'ses_test_001'; +const TURN_ID = 42; + +const capturedSequence: RawEvent[] = [ + { + type: 'turn.started', + payload: { + type: 'turn.started', + turnId: TURN_ID, + origin: { kind: 'user' }, + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'turn.step.started', + payload: { + type: 'turn.step.started', + turnId: TURN_ID, + step: 0, + stepId: 'step_001', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'thinking.delta', + payload: { + type: 'thinking.delta', + turnId: TURN_ID, + delta: 'We', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'assistant.delta', + payload: { + type: 'assistant.delta', + turnId: TURN_ID, + delta: '我是', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'assistant.delta', + payload: { + type: 'assistant.delta', + turnId: TURN_ID, + delta: 'Kimi', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'turn.step.completed', + payload: { + type: 'turn.step.completed', + turnId: TURN_ID, + step: 0, + stepId: 'step_001', + usage: { + inputOther: 1200, + output: 80, + inputCacheRead: 400, + inputCacheCreation: 0, + }, + finishReason: 'end_turn', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'agent.status.updated', + payload: { + type: 'agent.status.updated', + model: 'moonshot-v1-128k', + contextTokens: 8500, + maxContextTokens: 131072, + contextUsage: 0.065, + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'turn.ended', + payload: { + type: 'turn.ended', + turnId: TURN_ID, + reason: 'completed', + agentId: 'main', + sessionId: SESSION_ID, + }, + }, + { + type: 'prompt.completed', + payload: { + type: 'prompt.completed', + agentId: 'main', + sessionId: SESSION_ID, + promptId: 'pr_test', + }, + }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createAgentProjector', () => { + it('projects the captured sequence: session ends idle', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session).toBeDefined(); + expect(session!.status).toBe('idle'); + }); + + it('projects the captured sequence: assistant message contains "我是Kimi"', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const msgs: AppMessage[] = state.messagesBySession[SESSION_ID] ?? []; + const assistantMsgs = msgs.filter((m) => m.role === 'assistant'); + expect(assistantMsgs.length).toBeGreaterThan(0); + + // Find a message whose text content contains the expected string + const hasText = assistantMsgs.some((m) => + m.content.some( + (c) => c.type === 'text' && (c as { type: 'text'; text: string }).text.includes('我是Kimi'), + ), + ); + expect(hasText).toBe(true); + }); + + it('projects thinking delta into a thinking content block', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const msgs: AppMessage[] = state.messagesBySession[SESSION_ID] ?? []; + const assistantMsgs = msgs.filter((m) => m.role === 'assistant'); + const hasThinking = assistantMsgs.some((m) => + m.content.some( + (c) => c.type === 'thinking' && (c as { type: 'thinking'; thinking: string }).thinking.includes('We'), + ), + ); + expect(hasThinking).toBe(true); + }); + + it('projects usage: context tokens updated', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session!.usage.contextTokens).toBe(8500); + expect(session!.usage.contextLimit).toBe(131072); + }); + + it('projects the live model name from agent.status.updated onto the session', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session!.model).toBe('moonshot-v1-128k'); + }); + + it('projects session.meta.updated title onto the session (sidebar refresh)', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'session.meta.updated', + { type: 'session.meta.updated', agentId: 'main', title: 'Fix the login bug', patch: { title: 'Fix the login bug' } }, + SESSION_ID, + ); + expect(events).toContainEqual({ type: 'sessionMetaUpdated', sessionId: SESSION_ID, title: 'Fix the login bug' }); + + let state = createInitialState(); + state = reduceAppEvent( + state, + { + type: 'sessionCreated', + session: { + id: SESSION_ID, + title: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + status: 'idle', + cwd: '/', + model: '', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }, + }, + { sessionId: SESSION_ID, seq: 0 }, + ); + for (const e of events) state = reduceAppEvent(state, e, { sessionId: SESSION_ID, seq: 1 }); + expect(state.sessions.find((s) => s.id === SESSION_ID)!.title).toBe('Fix the login bug'); + }); + + it('projects compaction.completed into a historyCompacted event (triggers reload)', () => { + const projector = createAgentProjector(); + const events = projector.project( + 'compaction.completed', + { type: 'compaction.completed', result: { summary: 's', compactedCount: 5, tokensBefore: 9000, tokensAfter: 2000 } }, + SESSION_ID, + ); + const compacted = events.filter((e) => e.type === 'historyCompacted'); + expect(compacted).toHaveLength(1); + expect((compacted[0] as { sessionId: string }).sessionId).toBe(SESSION_ID); + }); + + it('projects usage: turn count incremented', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session!.usage.turnCount).toBe(1); + }); + + it('projects usage: token counts from turn.step.completed', () => { + const { state } = runSequence(SESSION_ID, capturedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session!.usage.inputTokens).toBe(1200); + expect(session!.usage.outputTokens).toBe(80); + expect(session!.usage.cacheReadTokens).toBe(400); + }); + + it('projects sessionStatusChanged running then idle', () => { + const { allAppEvents } = runSequence(SESSION_ID, capturedSequence); + const statusChanges = allAppEvents.filter((e) => e.type === 'sessionStatusChanged'); + const statuses = statusChanges.map((e) => (e as { type: 'sessionStatusChanged'; status: string }).status); + expect(statuses).toContain('running'); + expect(statuses[statuses.length - 1]).toBe('idle'); + }); + + it('emits messageCreated for the assistant turn', () => { + const { allAppEvents } = runSequence(SESSION_ID, capturedSequence); + const created = allAppEvents.filter((e) => e.type === 'messageCreated'); + expect(created.length).toBeGreaterThan(0); + const hasAssistant = created.some( + (e) => (e as { type: 'messageCreated'; message: AppMessage }).message.role === 'assistant', + ); + expect(hasAssistant).toBe(true); + }); + + it('emits assistantDelta events for text', () => { + const { allAppEvents } = runSequence(SESSION_ID, capturedSequence); + const deltas = allAppEvents.filter((e) => e.type === 'assistantDelta'); + expect(deltas.length).toBeGreaterThan(0); + const textDeltas = deltas.filter( + (e) => (e as { type: 'assistantDelta'; delta: { text?: string } }).delta.text !== undefined, + ); + expect(textDeltas.length).toBeGreaterThan(0); + }); + + it('never throws on unknown event types', () => { + const projector = createAgentProjector(); + expect(() => { + projector.project('totally.unknown.event.type', { foo: 'bar' }, 'ses_x'); + }).not.toThrow(); + expect(() => { + projector.project('', null, 'ses_x'); + }).not.toThrow(); + }); + + it('reset() clears per-session state', () => { + const projector = createAgentProjector(); + // Start a turn + projector.project('turn.started', { turnId: 1, agentId: 'main', sessionId: 'ses_r' }, 'ses_r'); + projector.project('turn.step.started', { turnId: 1, step: 0, stepId: 's1', agentId: 'main', sessionId: 'ses_r' }, 'ses_r'); + // Reset and start fresh — should not carry over the old assistant message id + projector.reset('ses_r'); + const events = projector.project('assistant.delta', { turnId: 1, delta: 'hello', agentId: 'main', sessionId: 'ses_r' }, 'ses_r'); + // After reset there is no current assistant message, so delta is dropped + expect(events.filter((e) => e.type === 'assistantDelta')).toHaveLength(0); + }); +}); + +describe('isRawAgentCoreEvent', () => { + it('returns false for event.* frames', () => { + expect(isRawAgentCoreEvent('event.assistant.delta')).toBe(false); + expect(isRawAgentCoreEvent('event.session.status_changed')).toBe(false); + expect(isRawAgentCoreEvent('event.message.created')).toBe(false); + }); + + it('returns false for control frames', () => { + expect(isRawAgentCoreEvent('server_hello')).toBe(false); + expect(isRawAgentCoreEvent('ack')).toBe(false); + expect(isRawAgentCoreEvent('ping')).toBe(false); + expect(isRawAgentCoreEvent('resync_required')).toBe(false); + expect(isRawAgentCoreEvent('error')).toBe(false); + }); + + it('returns true for raw agent-core event types', () => { + expect(isRawAgentCoreEvent('turn.started')).toBe(true); + expect(isRawAgentCoreEvent('turn.step.started')).toBe(true); + expect(isRawAgentCoreEvent('assistant.delta')).toBe(true); + expect(isRawAgentCoreEvent('thinking.delta')).toBe(true); + expect(isRawAgentCoreEvent('turn.ended')).toBe(true); + expect(isRawAgentCoreEvent('agent.status.updated')).toBe(true); + expect(isRawAgentCoreEvent('tool.call.started')).toBe(true); + expect(isRawAgentCoreEvent('tool.result')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// classifyFrame — routing of raw / prefixed / protocol frames +// --------------------------------------------------------------------------- + +describe('classifyFrame', () => { + it('routes unprefixed agent-core events to the agent projector', () => { + expect(classifyFrame('turn.started', { turnId: 1 })).toEqual({ + route: 'agent', + agentType: 'turn.started', + }); + expect(classifyFrame('turn.ended', { reason: 'completed' })).toEqual({ + route: 'agent', + agentType: 'turn.ended', + }); + }); + + it('strips "event." and routes prefixed agent-core events to the projector', () => { + expect(classifyFrame('event.turn.started', { turnId: 1 })).toEqual({ + route: 'agent', + agentType: 'turn.started', + }); + expect(classifyFrame('event.turn.step.started', { turnId: 1 })).toEqual({ + route: 'agent', + agentType: 'turn.step.started', + }); + expect(classifyFrame('event.prompt.completed', {})).toEqual({ + route: 'agent', + agentType: 'prompt.completed', + }); + }); + + it('keeps genuine protocol events on the protocol path', () => { + expect(classifyFrame('event.message.created', { message: {} })).toEqual({ route: 'protocol' }); + expect(classifyFrame('event.session.status_changed', {})).toEqual({ route: 'protocol' }); + expect(classifyFrame('event.session.usage_updated', {})).toEqual({ route: 'protocol' }); + }); + + it('keeps approval/question requests on the protocol path (drive the UI)', () => { + expect(classifyFrame('event.approval.requested', {})).toEqual({ route: 'protocol' }); + expect(classifyFrame('event.question.requested', {})).toEqual({ route: 'protocol' }); + }); + + it('disambiguates assistant.delta by payload shape', () => { + // Raw agent-core: delta is a STRING → project. + expect(classifyFrame('event.assistant.delta', { delta: '我是' })).toEqual({ + route: 'agent', + agentType: 'assistant.delta', + }); + expect(classifyFrame('assistant.delta', { delta: 'Kimi' })).toEqual({ + route: 'agent', + agentType: 'assistant.delta', + }); + // Protocol: delta is an object, or message_id/content_index present → protocol. + expect( + classifyFrame('event.assistant.delta', { message_id: 'm1', content_index: 0, delta: { text: 'x' } }), + ).toEqual({ route: 'protocol' }); + }); + + it('disambiguates thinking.delta by payload shape', () => { + expect(classifyFrame('event.thinking.delta', { delta: 'We' })).toEqual({ + route: 'agent', + agentType: 'thinking.delta', + }); + expect( + classifyFrame('event.thinking.delta', { message_id: 'm1', content_index: 0, delta: { thinking: 'x' } }), + ).toEqual({ route: 'protocol' }); + }); + + it('ignores control frames', () => { + expect(classifyFrame('server_hello', {})).toEqual({ route: 'ignore' }); + expect(classifyFrame('ping', {})).toEqual({ route: 'ignore' }); + }); +}); + +// --------------------------------------------------------------------------- +// "event."-prefixed agent-core sequence (newer daemon shape) +// --------------------------------------------------------------------------- + +const prefixedSequence: RawEvent[] = [ + { type: 'event.turn.started', payload: { turnId: TURN_ID, agentId: 'main', sessionId: SESSION_ID } }, + { type: 'event.turn.step.started', payload: { turnId: TURN_ID, step: 0, stepId: 's1', agentId: 'main', sessionId: SESSION_ID } }, + { type: 'event.assistant.delta', payload: { turnId: TURN_ID, delta: '我是', agentId: 'main', sessionId: SESSION_ID } }, + { type: 'event.assistant.delta', payload: { turnId: TURN_ID, delta: 'Kimi', agentId: 'main', sessionId: SESSION_ID } }, + { type: 'event.turn.ended', payload: { turnId: TURN_ID, reason: 'completed', agentId: 'main', sessionId: SESSION_ID } }, + { type: 'event.prompt.completed', payload: { agentId: 'main', sessionId: SESSION_ID, promptId: 'pr_test' } }, +]; + +describe('event.-prefixed agent-core routing', () => { + it('projects the prefixed sequence: assistant text contains "我是Kimi"', () => { + const { state } = runFramesThroughRouting(SESSION_ID, prefixedSequence); + const msgs: AppMessage[] = state.messagesBySession[SESSION_ID] ?? []; + const assistantMsgs = msgs.filter((m) => m.role === 'assistant'); + expect(assistantMsgs.length).toBeGreaterThan(0); + const hasText = assistantMsgs.some((m) => + m.content.some( + (c) => c.type === 'text' && (c as { type: 'text'; text: string }).text.includes('我是Kimi'), + ), + ); + expect(hasText).toBe(true); + }); + + it('projects the prefixed sequence: session ends idle', () => { + const { state } = runFramesThroughRouting(SESSION_ID, prefixedSequence); + const session = state.sessions.find((s) => s.id === SESSION_ID); + expect(session).toBeDefined(); + expect(session!.status).toBe('idle'); + }); +}); diff --git a/apps/kimi-web/src/api/daemon/__tests__/client.workspace.test.ts b/apps/kimi-web/src/api/daemon/__tests__/client.workspace.test.ts new file mode 100644 index 000000000..713d8a6ad --- /dev/null +++ b/apps/kimi-web/src/api/daemon/__tests__/client.workspace.test.ts @@ -0,0 +1,97 @@ +// apps/kimi-web/src/api/daemon/__tests__/client.workspace.test.ts +// Adapter-level tests for the workspace + :activate graceful fallbacks. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DaemonKimiWebApi } from '../client'; + +const config = { daemonHttpUrl: 'http://127.0.0.1:7878', clientId: 'test' }; + +function envelope(data: unknown, code = 0, msg = 'ok') { + return { + ok: true, + json: async () => ({ code, msg, data, request_id: 'r1' }), + } as unknown as Response; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('DaemonKimiWebApi — workspace fallbacks', () => { + it('listWorkspaces returns [] when /workspaces errors (404-style)', async () => { + // Daemon returns a non-zero envelope code (endpoint not shipped). + fetchMock.mockResolvedValue(envelope(null, 40400, 'not found')); + const api = new DaemonKimiWebApi(config); + await expect(api.listWorkspaces()).resolves.toEqual([]); + }); + + it('listWorkspaces maps wire workspaces when present', async () => { + fetchMock.mockResolvedValue( + envelope({ + items: [ + { + id: 'w1', + root: '/Users/me/p', + name: 'p', + is_git_repo: true, + branch: 'main', + session_count: 3, + }, + ], + }), + ); + const api = new DaemonKimiWebApi(config); + const ws = await api.listWorkspaces(); + expect(ws).toEqual([ + { + id: 'w1', + root: '/Users/me/p', + name: 'p', + isGitRepo: true, + branch: 'main', + lastOpenedAt: undefined, + sessionCount: 3, + }, + ]); + }); + + it('createSession sends workspace_id AND metadata.cwd fallback', async () => { + fetchMock.mockResolvedValue( + envelope({ + id: 's1', + title: '', + created_at: '', + updated_at: '', + status: 'idle', + metadata: { cwd: '/root' }, + agent_config: { model: '' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + }), + ); + const api = new DaemonKimiWebApi(config); + await api.createSession({ workspaceId: 'w1', cwd: '/root' }); + + const [, init] = fetchMock.mock.calls[0]!; + const body = JSON.parse((init as RequestInit).body as string); + expect(body.workspace_id).toBe('w1'); + expect(body.metadata).toEqual({ cwd: '/root' }); + }); +}); diff --git a/apps/kimi-web/src/api/daemon/__tests__/mappers.model.test.ts b/apps/kimi-web/src/api/daemon/__tests__/mappers.model.test.ts new file mode 100644 index 000000000..616441a79 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/__tests__/mappers.model.test.ts @@ -0,0 +1,93 @@ +// apps/kimi-web/src/api/daemon/__tests__/mappers.model.test.ts +// Unit tests for toAppModel and toAppProvider mappers. +import { describe, expect, it } from 'vitest'; +import { toAppModel, toAppProvider } from '../mappers'; +import type { WireModel, WireProvider } from '../wire'; + +describe('toAppModel', () => { + it('maps required fields', () => { + const wire: WireModel = { + provider: 'prov_moonshot', + model: 'moonshot-v1-128k', + max_context_size: 131072, + }; + const app = toAppModel(wire); + expect(app.id).toBe('moonshot-v1-128k'); + expect(app.provider).toBe('prov_moonshot'); + expect(app.model).toBe('moonshot-v1-128k'); + expect(app.maxContextSize).toBe(131072); + expect(app.displayName).toBeUndefined(); + expect(app.capabilities).toBeUndefined(); + }); + + it('maps optional display_name and capabilities', () => { + const wire: WireModel = { + provider: 'prov_anthropic', + model: 'claude-sonnet-4-6', + max_context_size: 200000, + display_name: 'Claude Sonnet 4.6', + capabilities: ['thinking'], + }; + const app = toAppModel(wire); + expect(app.displayName).toBe('Claude Sonnet 4.6'); + expect(app.capabilities).toEqual(['thinking']); + }); + + it('id equals the model field (not provider)', () => { + const wire: WireModel = { + provider: 'prov_foo', + model: 'my-model-name', + max_context_size: 8192, + }; + const app = toAppModel(wire); + // id should be the model string so PATCH session can use it directly + expect(app.id).toBe('my-model-name'); + }); +}); + +describe('toAppProvider', () => { + it('maps all fields correctly', () => { + const wire: WireProvider = { + id: 'prov_moonshot', + type: 'moonshot', + has_api_key: true, + status: 'connected', + models: ['moonshot-v1-128k', 'moonshot-v1-32k'], + }; + const app = toAppProvider(wire); + expect(app.id).toBe('prov_moonshot'); + expect(app.type).toBe('moonshot'); + expect(app.hasApiKey).toBe(true); + expect(app.status).toBe('connected'); + expect(app.models).toEqual(['moonshot-v1-128k', 'moonshot-v1-32k']); + expect(app.baseUrl).toBeUndefined(); + expect(app.defaultModel).toBeUndefined(); + }); + + it('maps optional base_url and default_model', () => { + const wire: WireProvider = { + id: 'prov_custom', + type: 'custom', + base_url: 'https://my-api.example.com', + default_model: 'my-model', + has_api_key: false, + status: 'unconfigured', + }; + const app = toAppProvider(wire); + expect(app.baseUrl).toBe('https://my-api.example.com'); + expect(app.defaultModel).toBe('my-model'); + expect(app.hasApiKey).toBe(false); + expect(app.status).toBe('unconfigured'); + }); + + it('maps error status', () => { + const wire: WireProvider = { + id: 'prov_err', + type: 'openai', + has_api_key: true, + status: 'error', + }; + const app = toAppProvider(wire); + expect(app.status).toBe('error'); + }); +}); diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts new file mode 100644 index 000000000..ece49cc3c --- /dev/null +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -0,0 +1,826 @@ +// apps/kimi-web/src/api/daemon/agentEventProjector.ts +// +// Client-side projector: raw agent-core WS events → AppEvent[] +// +// The real daemon pushes raw agent-core events (NOT the projected "event.*" +// protocol events). This projector translates them into the same AppEvent union +// that the existing reducer (eventReducer.ts) consumes. +// +// Ported from the daemon-side reference implementation: +// apps/kimi-daemon/src/session/event-projector.ts +// apps/kimi-daemon/src/session/message-log.ts +// apps/kimi-daemon/src/session/usage-tracker.ts +// +// Usage: +// const projector = createAgentProjector(); +// const appEvents = projector.project(rawType, payload, sessionId); +// // call reset() when re-subscribing / resyncing a session + +import type { AppEvent, AppMessage, AppSessionUsage } from '../types'; +import { i18n } from '../../i18n'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function ulid(prefix = 'msg_'): string { + const t = Date.now().toString(36).padStart(10, '0'); + const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0'); + return `${prefix}${t}${r}`; +} + +/** Normalise the raw token usage shape emitted by agent-core. */ +function normalizeUsage(raw: unknown): { + input: number; + output: number; + cacheRead: number; + cacheCreate: number; +} { + if (!raw || typeof raw !== 'object') { + return { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 }; + } + const u = raw as Record; + return { + input: u['inputOther'] ?? u['input_tokens'] ?? 0, + output: u['output'] ?? u['output_tokens'] ?? 0, + cacheRead: u['inputCacheRead'] ?? u['cache_read_input_tokens'] ?? 0, + cacheCreate: u['inputCacheCreation'] ?? u['cache_creation_input_tokens'] ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Per-session projector state +// --------------------------------------------------------------------------- + +interface SessionState { + // Turn ID → promptId binding + turnPromptId: Map; + currentPromptId: string | undefined; + + // Assistant message tracking + currentAssistantMsgId: string | undefined; + thinkingStarted: boolean; + thinkingContentIndex: number; + textContentIndex: number; + + // Tool timing + toolStartTimes: Map; + + // Usage accumulator + totalInput: number; + totalOutput: number; + totalCacheRead: number; + totalCacheCreate: number; + contextTokens: number; + contextLimit: number; + turnCount: number; + model: string; + + // In-memory message log (mirrors daemon message-log.ts) + messages: AppMessage[]; +} + +function createSessionState(): SessionState { + return { + turnPromptId: new Map(), + currentPromptId: undefined, + currentAssistantMsgId: undefined, + thinkingStarted: false, + thinkingContentIndex: 0, + textContentIndex: 0, + toolStartTimes: new Map(), + totalInput: 0, + totalOutput: 0, + totalCacheRead: 0, + totalCacheCreate: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + model: '', + messages: [], + }; +} + +// --------------------------------------------------------------------------- +// Message-log helpers (inlined; mirrors message-log.ts) +// --------------------------------------------------------------------------- + +function startAssistantMessage(state: SessionState, sessionId: string, promptId: string): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'assistant', + content: [], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function appendAssistantText(state: SessionState, messageId: string, contentIndex: number, delta: string): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + while (msg.content.length <= contentIndex) { + msg.content.push({ type: 'text', text: '' }); + } + const slot = msg.content[contentIndex]!; + if (slot.type === 'text') { + (slot as { type: 'text'; text: string }).text += delta; + } else { + msg.content[contentIndex] = { type: 'text', text: delta }; + } +} + +function appendAssistantThinking(state: SessionState, messageId: string, contentIndex: number, delta: string): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + while (msg.content.length <= contentIndex) { + msg.content.push({ type: 'thinking', thinking: '' }); + } + const slot = msg.content[contentIndex]!; + if (slot.type === 'thinking') { + (slot as { type: 'thinking'; thinking: string }).thinking += delta; + } else { + msg.content[contentIndex] = { type: 'thinking', thinking: delta }; + } +} + +function appendToolUse( + state: SessionState, + messageId: string, + toolCallId: string, + toolName: string, + input: unknown, +): void { + const msg = state.messages.find((m) => m.id === messageId); + if (!msg) return; + msg.content.push({ type: 'toolUse', toolCallId, toolName, input }); +} + +function finishAssistantMessage(state: SessionState, messageId: string): void { + const msg = state.messages.find((m) => m.id === messageId); + // We record nothing extra here — status is implicit in the downstream reducer + void msg; +} + +function appendToolResultMessage( + state: SessionState, + sessionId: string, + toolCallId: string, + output: unknown, + isError: boolean, + promptId: string, +): AppMessage { + const msg: AppMessage = { + id: ulid('msg_'), + sessionId, + role: 'tool', + content: [{ type: 'toolResult', toolCallId, output, isError }], + createdAt: new Date().toISOString(), + promptId, + }; + state.messages.push(msg); + return msg; +} + +function getMsgById(state: SessionState, messageId: string): AppMessage | undefined { + return state.messages.find((m) => m.id === messageId); +} + +// --------------------------------------------------------------------------- +// Usage snapshot builder +// --------------------------------------------------------------------------- + +function buildUsageSnapshot(state: SessionState): AppSessionUsage { + return { + inputTokens: state.totalInput, + outputTokens: state.totalOutput, + cacheReadTokens: state.totalCacheRead, + cacheCreationTokens: state.totalCacheCreate, + totalCostUsd: 0, + contextTokens: state.contextTokens, + contextLimit: state.contextLimit, + turnCount: state.turnCount, + }; +} + +// --------------------------------------------------------------------------- +// AgentProjector +// --------------------------------------------------------------------------- + +export interface AgentProjector { + /** Project a single raw agent-core event into zero or more AppEvents. Never throws. */ + project(rawType: string, payload: unknown, sessionId: string): AppEvent[]; + /** + * Bind an externally-known promptId to the next turn.startd for this session. + * Call this right after submitPrompt() returns, before the first turn.started arrives. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + /** Reset all per-session state (call on re-subscribe / resync). */ + reset(sessionId: string): void; +} + +export function createAgentProjector(): AgentProjector { + const sessions = new Map(); + + function getOrCreate(sessionId: string): SessionState { + let s = sessions.get(sessionId); + if (!s) { + s = createSessionState(); + sessions.set(sessionId, s); + } + return s; + } + + function reset(sessionId: string): void { + sessions.set(sessionId, createSessionState()); + } + + function bindNextPromptId(sessionId: string, promptId: string): void { + const s = getOrCreate(sessionId); + s.currentPromptId = promptId; + } + + function project(rawType: string, payload: unknown, sessionId: string): AppEvent[] { + try { + return _project(rawType, payload, sessionId); + } catch (err) { + // Defensive: log but never crash the caller + console.error('[agentProjector] Error projecting event:', rawType, err instanceof Error ? err.message : err); + return []; + } + } + + function _project(rawType: string, payload: unknown, sessionId: string): AppEvent[] { + const s = getOrCreate(sessionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any; + const out: AppEvent[] = []; + + switch (rawType) { + // ----------------------------------------------------------------------- + case 'session.meta.updated': { + // The daemon auto-generates a title from the first prompt (and other + // clients can rename a session). It announces both via this event. We + // don't have the full AppSession here, so emit a lightweight + // sessionMetaUpdated that patches only the title field. + const title: string | undefined = p?.patch?.title ?? p?.title; + if (typeof title === 'string' && title.length > 0) { + out.push({ type: 'sessionMetaUpdated', sessionId, title }); + } + break; + } + + // ----------------------------------------------------------------------- + case 'turn.started': { + // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + const turnId: number = p?.turnId; + const existingPromptId = s.currentPromptId ?? ulid('pr_'); + s.currentPromptId = existingPromptId; + if (turnId !== undefined) { + s.turnPromptId.set(turnId, existingPromptId); + } + + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: 'running', + previousStatus: 'idle', + currentPromptId: existingPromptId, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.started': { + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) break; + + // Create a new pending assistant message + const msg = startAssistantMessage(s, sessionId, promptId); + s.currentAssistantMsgId = msg.id; + s.thinkingStarted = false; + s.thinkingContentIndex = 0; + s.textContentIndex = 0; + + out.push({ type: 'messageCreated', message: msg }); + break; + } + + // ----------------------------------------------------------------------- + case 'thinking.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + if (!s.thinkingStarted) { + s.thinkingStarted = true; + s.thinkingContentIndex = 0; + s.textContentIndex = 1; + } + + appendAssistantThinking(s, msgId, s.thinkingContentIndex, delta); + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: s.thinkingContentIndex, + delta: { thinking: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'assistant.delta': { + const msgId = s.currentAssistantMsgId; + if (!msgId) break; + const delta: string = p?.delta ?? ''; + if (!delta) break; + + const textIdx = s.textContentIndex; + appendAssistantText(s, msgId, textIdx, delta); + out.push({ + type: 'assistantDelta', + sessionId, + messageId: msgId, + contentIndex: textIdx, + delta: { text: delta }, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'tool.use': + case 'tool.call.started': { + const msgId = s.currentAssistantMsgId; + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!msgId || !promptId) break; + + const toolCallId: string = p?.toolCallId; + // Real daemon field name is 'name' per event-projector.ts + const toolName: string = p?.name ?? p?.toolName ?? ''; + const args = p?.args ?? p?.input ?? {}; + + appendToolUse(s, msgId, toolCallId, toolName, args); + + const msg = getMsgById(s, msgId); + const contentIndex = msg ? msg.content.length - 1 : 0; + + // Record start time + s.toolStartTimes.set(toolCallId, Date.now()); + + // Emit messageUpdated so the reducer knows about the new tool-use slot + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: [...msg.content], + status: 'pending', + }); + } + void contentIndex; + break; + } + + // ----------------------------------------------------------------------- + case 'tool.call.delta': { + // Input streaming — no-op for the web client (content already in tool.call.started.args) + break; + } + + // ----------------------------------------------------------------------- + case 'tool.progress': { + // No-op — tool output streaming is not rendered at the AppEvent level + break; + } + + // ----------------------------------------------------------------------- + case 'tool.result': { + const turnId: number = p?.turnId; + const promptId = s.turnPromptId.get(turnId) ?? s.currentPromptId; + if (!promptId) break; + + const toolCallId: string = p?.toolCallId; + const output = p?.output; + const isError: boolean = p?.isError ?? false; + + const startTime = s.toolStartTimes.get(toolCallId) ?? Date.now(); + s.toolStartTimes.delete(toolCallId); + void (Date.now() - startTime); // duration — unused at client level + + const resultMsg = appendToolResultMessage(s, sessionId, toolCallId, output, isError, promptId); + out.push({ type: 'messageCreated', message: resultMsg }); + + // Reset assistant message tracking — next step.started will create a fresh one + s.currentAssistantMsgId = undefined; + s.thinkingStarted = false; + s.thinkingContentIndex = 0; + s.textContentIndex = 0; + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.completed': { + const msgId = s.currentAssistantMsgId; + + // Feed usage + const u = normalizeUsage(p?.usage); + s.totalInput += u.input; + s.totalOutput += u.output; + s.totalCacheRead += u.cacheRead; + s.totalCacheCreate += u.cacheCreate; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: [...msg.content], + status: 'completed', + }); + } + } + break; + } + + // ----------------------------------------------------------------------- + case 'agent.status.updated': { + if (p?.model) s.model = p.model; + if (p?.contextTokens !== undefined) s.contextTokens = p.contextTokens; + if (p?.maxContextTokens !== undefined) s.contextLimit = p.maxContextTokens; + + out.push({ + type: 'sessionUsageUpdated', + sessionId, + usage: buildUsageSnapshot(s), + // Carry the live model so the status bar shows the real running model + // instead of falling back to the daemon's (empty) REST model. + model: s.model || undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'turn.ended': { + const msgId = s.currentAssistantMsgId; + const reason: string = p?.reason ?? 'completed'; + + if (msgId) { + finishAssistantMessage(s, msgId); + const msg = getMsgById(s, msgId); + if (msg) { + out.push({ + type: 'messageUpdated', + sessionId, + messageId: msgId, + content: [...msg.content], + status: reason === 'failed' ? 'error' : 'completed', + }); + } + } + + s.turnCount++; + const usageSnapshot = buildUsageSnapshot(s); + out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); + + const newStatus = reason === 'cancelled' ? 'aborted' : reason === 'failed' ? 'aborted' : 'idle'; + out.push({ + type: 'sessionStatusChanged', + sessionId, + status: newStatus, + previousStatus: 'running', + }); + + // Clear per-turn state + s.currentAssistantMsgId = undefined; + s.thinkingStarted = false; + s.thinkingContentIndex = 0; + s.textContentIndex = 0; + s.currentPromptId = undefined; + break; + } + + // ----------------------------------------------------------------------- + case 'prompt.completed': { + // No-op at AppEvent level — turn.ended already handles the transition to idle + break; + } + + // ----------------------------------------------------------------------- + case 'turn.step.retrying': + case 'turn.step.interrupted': { + // Discard current assistant message; next step.started will create a new one + s.currentAssistantMsgId = undefined; + s.thinkingStarted = false; + s.thinkingContentIndex = 0; + s.textContentIndex = 0; + break; + } + + // ----------------------------------------------------------------------- + case 'subagent.spawned': { + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: p?.subagentId ?? ulid('task_'), + sessionId, + kind: 'subagent', + description: p?.subagentName ?? 'subagent', + status: 'running', + createdAt: new Date().toISOString(), + }, + }); + break; + } + + case 'subagent.completed': { + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'completed', + outputPreview: typeof p?.resultSummary === 'string' ? p.resultSummary : undefined, + }); + break; + } + + case 'subagent.failed': { + out.push({ + type: 'taskCompleted', + sessionId, + taskId: p?.subagentId ?? '', + status: 'failed', + outputPreview: typeof p?.error === 'string' ? p.error : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'error': { + // Fold into an unknown event so the reducer pushes a warning string + out.push({ + type: 'unknown', + raw: { _agentError: true, code: p?.code, message: p?.message }, + }); + break; + } + + case 'warning': { + out.push({ + type: 'unknown', + raw: { _agentWarning: true, message: p?.message }, + }); + break; + } + + // ----------------------------------------------------------------------- + // Background tasks (e.g. a backgrounded Bash command). Real daemon shape: + // payload.info = { taskId, description, status, startedAt(ms), endedAt, + // kind:'process', command, pid, exitCode }. + case 'background.task.started': { + const info = (p?.info ?? {}) as Record; + const startedAt = + typeof info.startedAt === 'number' ? new Date(info.startedAt).toISOString() : undefined; + out.push({ + type: 'taskCreated', + sessionId, + task: { + id: String(info.taskId ?? ulid('task_')), + sessionId, + kind: 'bash', + description: String(info.description ?? info.command ?? i18n.global.t('tasks.defaultDescription')), + status: 'running', + createdAt: startedAt ?? new Date().toISOString(), + startedAt, + outputPreview: typeof info.command === 'string' ? `$ ${info.command}` : undefined, + }, + }); + break; + } + case 'background.task.terminated': { + const info = (p?.info ?? {}) as Record; + const failed = + info.status === 'failed' || + (typeof info.exitCode === 'number' && info.exitCode !== 0); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: String(info.taskId ?? ''), + status: failed ? 'failed' : 'completed', + outputPreview: typeof info.command === 'string' ? `$ ${info.command}` : undefined, + }); + break; + } + + // ----------------------------------------------------------------------- + case 'compaction.completed': { + // Auto-compaction replaced a batch of old messages with a summary on the + // daemon side. The in-memory transcript is now stale, so signal a reload. + // beforeSeq is patched to the real frame.seq by the client (the projector + // does not receive the wire seq); the client routes historyCompacted to + // onResync to refetch /messages. + out.push({ + type: 'historyCompacted', + sessionId, + beforeSeq: 0, + reason: 'auto_compact', + }); + break; + } + + // ----------------------------------------------------------------------- + // Explicitly known but not projected + case 'compaction.started': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'cron.fired': + case 'goal.updated': + case 'hook.result': + case 'mcp.server.status': + case 'skill.activated': + case 'tool.list.updated': + break; + + // ----------------------------------------------------------------------- + default: + // Unknown future events — safe no-op + break; + } + + return out; + } + + return { project, bindNextPromptId, reset }; +} + +// --------------------------------------------------------------------------- +// Helpers for integration layer +// --------------------------------------------------------------------------- + +/** + * Detect whether an incoming WS frame type is a raw agent-core event + * (as opposed to a projected "event.*" protocol event or a control frame). + * + * Raw agent-core events do NOT start with "event." and are not control frames. + * Control frames: server_hello, ack, ping, resync_required, error. + */ +const CONTROL_FRAME_TYPES = new Set([ + 'server_hello', + 'ack', + 'ping', + 'resync_required', + 'error', + 'pong', +]); + +export function isRawAgentCoreEvent(frameType: string): boolean { + if (frameType.startsWith('event.')) return false; + if (CONTROL_FRAME_TYPES.has(frameType)) return false; + return true; +} + +/** + * Agent-core event names the projector knows how to project. These are the + * raw events the real daemon emits. The same names may arrive WITH an "event." + * prefix (newer daemon) or WITHOUT it (older daemon). + */ +const KNOWN_AGENT_CORE_TYPES = new Set([ + 'turn.started', + 'turn.step.started', + 'turn.step.completed', + 'turn.step.retrying', + 'turn.step.interrupted', + 'turn.ended', + 'thinking.delta', + 'assistant.delta', + 'tool.call.started', + 'tool.use', // alias the daemon may use for tool.call.started + 'tool.call.delta', + 'tool.progress', + 'tool.result', + 'agent.status.updated', + 'prompt.completed', + 'session.meta.updated', + 'compaction.completed', + 'error', + 'warning', + 'subagent.spawned', + 'subagent.completed', + 'subagent.failed', + 'background.task.started', + 'background.task.terminated', +]); + +/** + * "event."-prefixed names that are GENUINE protocol events (control/projected + * events produced server-side). The agent projector must NOT re-handle these — + * they go through the existing toAppEvent() path. This includes approval / + * question requests (which drive the approval/question UI) and the no-op-but- + * known streaming/tool protocol events. + */ +const PROTOCOL_EVENT_NAMES = new Set([ + // Session lifecycle (projected) + 'session.created', + 'session.updated', + 'session.deleted', + 'session.status_changed', + 'session.usage_updated', + 'session.history_compacted', + // Message lifecycle (projected) + 'message.created', + 'message.updated', + // Approval / Question — MUST stay on the protocol path to drive the UI + 'approval.requested', + 'approval.resolved', + 'approval.expired', + 'question.requested', + 'question.answered', + 'question.dismissed', + 'question.expired', + // Background tasks (projected) + 'task.created', + 'task.progress', + 'task.completed', + // No-op-but-known protocol streaming / tool events + 'assistant.tool_use_started', + 'assistant.tool_use_delta', + 'assistant.tool_use_completed', + 'assistant.completed', + 'tool.started', + 'tool.output', + 'tool.completed', +]); + +/** + * Names that are ambiguous between the raw agent-core form (payload.delta is a + * STRING) and the already-projected protocol form (payload.delta is an object + * { text? | thinking? }, or the payload carries message_id / content_index). + */ +const AMBIGUOUS_DELTA_NAMES = new Set(['assistant.delta', 'thinking.delta']); + +export type FrameRoute = + | { route: 'protocol' } + | { route: 'agent'; agentType: string } + | { route: 'ignore' }; + +/** + * Classify a (possibly "event."-prefixed) WS frame into the path it should take. + * + * - 'protocol' → hand the original frame to toAppEvent() (existing path). + * - 'agent' → hand `agentType` + payload to the agent projector. + * - 'ignore' → drop (no session context / unroutable). + * + * Robust to all three observed shapes: + * 1) raw agent-core (no prefix): turn.started, assistant.delta{delta:'…'} + * 2) "event."-prefixed agent-core: event.turn.started, event.assistant.delta{delta:'…'} + * 3) genuine protocol "event.*" events: event.message.created, event.session.*, … + */ +export function classifyFrame(rawType: string, payload: unknown): FrameRoute { + if (CONTROL_FRAME_TYPES.has(rawType)) return { route: 'ignore' }; + + const hasPrefix = rawType.startsWith('event.'); + const name = hasPrefix ? rawType.slice('event.'.length) : rawType; + + // Ambiguous delta events: disambiguate by payload shape regardless of prefix. + if (AMBIGUOUS_DELTA_NAMES.has(name)) { + if (deltaIsRawAgentCore(payload)) return { route: 'agent', agentType: name }; + // Object delta or protocol-shaped payload → projected protocol event. + return { route: 'protocol' }; + } + + // Unprefixed frames are raw agent-core (real daemon) when we know the name. + if (!hasPrefix) { + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown unprefixed name with no protocol meaning → still try the projector + // (it safely no-ops on unknown types and advances nothing). + return { route: 'agent', agentType: name }; + } + + // Prefixed frames: genuine protocol events take priority. + if (PROTOCOL_EVENT_NAMES.has(name)) return { route: 'protocol' }; + // Prefixed agent-core event (e.g. event.turn.started) → strip + project. + if (KNOWN_AGENT_CORE_TYPES.has(name)) return { route: 'agent', agentType: name }; + // Unknown "event.*" → let toAppEvent() record it as an unknown protocol event. + return { route: 'protocol' }; +} + +/** + * True when an assistant.delta / thinking.delta payload is in the RAW agent-core + * form: payload.delta is a plain string, and there is no protocol-only field + * (message_id / content_index). The protocol form uses delta:{text|thinking}. + */ +function deltaIsRawAgentCore(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') return false; + const p = payload as Record; + if ('message_id' in p || 'content_index' in p) return false; + return typeof p['delta'] === 'string'; +} diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts new file mode 100644 index 000000000..017984cb5 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -0,0 +1,847 @@ +// apps/kimi-web/src/api/daemon/client.ts +// DaemonKimiWebApi — implements KimiWebApi using the daemon REST + WS APIs. + +import type { KimiApiConfig } from '../config'; +import { buildWsUrl } from '../config'; +import type { + AppMessage, + AppMessageRole, + AppModel, + AppProvider, + AppSession, + AppSessionStatus, + AppTask, + AppTaskStatus, + AppWorkspace, + ApprovalResponse, + FsBrowseResult, + FsEntry, + KimiEventConnection, + KimiEventHandlers, + KimiWebApi, + Page, + PageRequest, + PromptSubmission, + PromptSubmitResult, + QuestionResponse, +} from '../types'; +import { createAgentProjector } from './agentEventProjector'; +import { DaemonHttpClient } from './http'; +import { + toAppEvent, + toAppFsEntry, + toAppMessage, + toAppModel, + toAppProvider, + toAppSession, + toAppTask, + toWireApprovalResponse, + toWirePromptSubmission, + toWireQuestionResponse, + toWireSessionStatus, + toAppWorkspace, + wireEventSeq, + wireEventSessionId, +} from './mappers'; +import type { + WireAuthResult, + WireBackgroundTask, + WireEvent, + WireFileMeta, + WireFsBrowseResult, + WireFsEntry, + WireFsHomeResult, + WireMessage, + WireModel, + WireOAuthCancelResult, + WireOAuthLoginPollResult, + WireOAuthLoginStartResult, + WirePage, + WirePromptSubmitResult, + WireProvider, + WireSession, + WireWorkspace, + WireLogoutResult, +} from './wire'; +import { DaemonEventSocket } from './ws'; + +// --------------------------------------------------------------------------- +// Wire response shapes for endpoints not in shared wire.ts +// --------------------------------------------------------------------------- + +interface WireHealth { + status: 'ok'; + uptime_sec: number; +} + +interface WireMeta { + daemon_version: string; + server_id: string; + started_at: string; + capabilities: Record; +} + +interface WireAbortResult { + aborted: boolean; + at_seq?: number; +} + +interface WireDismissResult { + dismissed: boolean; + dismissed_at: string; +} + +interface WireApprovalResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireQuestionResolveResult { + resolved: true; + resolved_at: string; +} + +interface WireCancelResult { + cancelled: true; +} + +interface WireDeleteResult { + deleted: true; +} + +interface WireListDirectoryResult { + items: WireFsEntry[]; + children_by_path?: Record; + truncated: boolean; +} + +interface WireReadFileResult { + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + language_id?: string; + line_count?: number; + is_binary: boolean; +} + +interface WireSearchFilesResult { + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + match_positions: number[]; + }>; + truncated: boolean; +} + +interface WireGrepFilesResult { + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + files_scanned: number; + truncated: boolean; + elapsed_ms: number; +} + +interface WireGitStatusResult { + branch: string; + ahead: number; + behind: number; + entries: Record; +} + +// --------------------------------------------------------------------------- +// DaemonKimiWebApi +// --------------------------------------------------------------------------- + +export class DaemonKimiWebApi implements KimiWebApi { + private readonly http: DaemonHttpClient; + private readonly config: KimiApiConfig; + + constructor(config: KimiApiConfig) { + this.config = config; + this.http = new DaemonHttpClient(config.daemonHttpUrl); + } + + // ------------------------------------------------------------------------- + // Health / Meta + // ------------------------------------------------------------------------- + + async getHealth(): Promise<{ status: 'ok'; uptimeSec: number }> { + // Real daemon returns { ok: true }; the older shape was { status, uptime_sec }. + const data = await this.http.get>('/healthz'); + return { status: 'ok', uptimeSec: data.uptime_sec ?? 0 }; + } + + async getMeta(): Promise<{ + daemonVersion: string; + serverId: string; + startedAt: string; + capabilities: Record; + }> { + const data = await this.http.get('/meta'); + return { + daemonVersion: data.daemon_version, + serverId: data.server_id, + startedAt: data.started_at, + capabilities: data.capabilities, + }; + } + + // ------------------------------------------------------------------------- + // Sessions + // ------------------------------------------------------------------------- + + async listSessions( + input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + status: input?.status ? toWireSessionStatus(input.status) : undefined, + // PRESUMED — daemon supports ?workspace_id= once the registry ships; it + // ignores unknown query params until then, so this is safe to always send. + workspace_id: input?.workspaceId, + }; + const data = await this.http.get>('/sessions', query); + return { + items: data.items.map(toAppSession), + hasMore: data.has_more, + }; + } + + async createSession(input: { + title?: string; + cwd?: string; + model?: string; + workspaceId?: string; + }): Promise { + // The real daemon requires `metadata` to be an object (rejects a missing + // metadata with 40001), so always send it — with cwd when provided. + const body: Record = { + metadata: input.cwd !== undefined ? { cwd: input.cwd } : {}, + }; + // PRESUMED — daemon resolves cwd from workspace_id once the registry ships. + // We ALSO send metadata.cwd (above) as the fallback so today's daemon, which + // only understands cwd, still creates the session in the right folder. + if (input.workspaceId !== undefined) body['workspace_id'] = input.workspaceId; + if (input.title !== undefined) body['title'] = input.title; + if (input.model !== undefined) body['agent_config'] = { model: input.model }; + const data = await this.http.post('/sessions', body); + return toAppSession(data); + } + + async updateSession( + sessionId: string, + input: { title?: string; cwd?: string; model?: string }, + ): Promise { + const body: Record = {}; + if (input.title !== undefined) body['title'] = input.title; + if (input.cwd !== undefined) body['metadata'] = { cwd: input.cwd }; + if (input.model !== undefined) body['agent_config'] = { model: input.model }; + const data = await this.http.patch( + `/sessions/${encodeURIComponent(sessionId)}`, + body, + ); + return toAppSession(data); + } + + async deleteSession(sessionId: string): Promise<{ deleted: true }> { + const data = await this.http.delete( + `/sessions/${encodeURIComponent(sessionId)}`, + ); + return data; + } + + // ------------------------------------------------------------------------- + // Messages + // ------------------------------------------------------------------------- + + async listMessages( + sessionId: string, + input?: PageRequest & { role?: AppMessageRole }, + ): Promise> { + const query: Record = { + before_id: input?.beforeId, + after_id: input?.afterId, + page_size: input?.pageSize, + role: input?.role, + }; + const data = await this.http.get>( + `/sessions/${encodeURIComponent(sessionId)}/messages`, + query, + ); + return { + items: data.items.map(toAppMessage), + hasMore: data.has_more, + }; + } + + // ------------------------------------------------------------------------- + // Prompt + // ------------------------------------------------------------------------- + + async submitPrompt( + sessionId: string, + input: PromptSubmission, + ): Promise { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts`, + toWirePromptSubmission(input), + ); + return { + promptId: data.prompt_id, + userMessageId: data.user_message_id, + }; + } + + async abortPrompt( + sessionId: string, + promptId: string, + ): Promise<{ aborted: boolean; atSeq?: number }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/prompts/${encodeURIComponent(promptId)}:abort`, + undefined, + { allowCodes: [40903] }, + ); + // data.aborted is false when 40903 (prompt already completed) — that's correct + return { aborted: data.aborted, atSeq: data.at_seq }; + } + + // ------------------------------------------------------------------------- + // Approval / Question + // ------------------------------------------------------------------------- + + async respondApproval( + sessionId: string, + approvalId: string, + response: ApprovalResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(approvalId)}`, + toWireApprovalResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async respondQuestion( + sessionId: string, + questionId: string, + response: QuestionResponse, + ): Promise<{ resolved: true; resolvedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`, + toWireQuestionResponse(response), + ); + return { resolved: data.resolved, resolvedAt: data.resolved_at }; + } + + async dismissQuestion( + sessionId: string, + questionId: string, + ): Promise<{ dismissed: true; dismissedAt: string }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}:dismiss`, + undefined, + { allowCodes: [40909] }, + ); + // 40909 means question.dismissed — that's the success path per spec + return { dismissed: true, dismissedAt: data.dismissed_at }; + } + + // ------------------------------------------------------------------------- + // Tasks + // ------------------------------------------------------------------------- + + async listTasks(sessionId: string, status?: AppTaskStatus): Promise { + const query: Record = { + status: status, + }; + const data = await this.http.get<{ items: WireBackgroundTask[] }>( + `/sessions/${encodeURIComponent(sessionId)}/tasks`, + query, + ); + return data.items.map(toAppTask); + } + + async getTask( + sessionId: string, + taskId: string, + input?: { withOutput?: boolean; outputBytes?: number }, + ): Promise { + const query: Record = { + with_output: input?.withOutput, + output_bytes: input?.outputBytes, + }; + const data = await this.http.get( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}`, + query, + ); + return toAppTask(data); + } + + async cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }> { + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/tasks/${encodeURIComponent(taskId)}:cancel`, + ); + return data; + } + + // ------------------------------------------------------------------------- + // File System + // ------------------------------------------------------------------------- + + async listDirectory( + sessionId: string, + input: { path?: string; depth?: number; includeGitStatus?: boolean }, + ): Promise<{ + items: FsEntry[]; + childrenByPath?: Record; + truncated: boolean; + }> { + const body: Record = {}; + if (input.path !== undefined) body['path'] = input.path; + if (input.depth !== undefined) body['depth'] = input.depth; + if (input.includeGitStatus !== undefined) body['include_git_status'] = input.includeGitStatus; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:list`, + body, + ); + const childrenByPath = data.children_by_path + ? Object.fromEntries( + Object.entries(data.children_by_path).map(([k, v]) => [k, v.map(toAppFsEntry)]), + ) + : undefined; + return { + items: data.items.map(toAppFsEntry), + childrenByPath, + truncated: data.truncated, + }; + } + + async readFile( + sessionId: string, + input: { path: string; offset?: number; length?: number }, + ): Promise<{ + path: string; + content: string; + encoding: 'utf-8' | 'base64'; + size: number; + truncated: boolean; + etag: string; + mime: string; + languageId?: string; + lineCount?: number; + isBinary: boolean; + }> { + const body: Record = { path: input.path }; + if (input.offset !== undefined) body['offset'] = input.offset; + if (input.length !== undefined) body['length'] = input.length; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:read`, + body, + ); + return { + path: data.path, + content: data.content, + encoding: data.encoding, + size: data.size, + truncated: data.truncated, + etag: data.etag, + mime: data.mime, + languageId: data.language_id, + lineCount: data.line_count, + isBinary: data.is_binary, + }; + } + + async searchFiles( + sessionId: string, + input: { query: string; limit?: number }, + ): Promise<{ + items: Array<{ + path: string; + name: string; + kind: 'file' | 'directory' | 'symlink'; + score: number; + matchPositions: number[]; + }>; + truncated: boolean; + }> { + const body: Record = { query: input.query }; + if (input.limit !== undefined) body['limit'] = input.limit; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:search`, + body, + ); + return { + items: data.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + score: item.score, + matchPositions: item.match_positions, + })), + truncated: data.truncated, + }; + } + + async grepFiles( + sessionId: string, + input: { pattern: string; regex?: boolean; caseSensitive?: boolean }, + ): Promise<{ + files: Array<{ + path: string; + matches: Array<{ + line: number; + col: number; + text: string; + before: string[]; + after: string[]; + }>; + }>; + filesScanned: number; + truncated: boolean; + elapsedMs: number; + }> { + const body: Record = { pattern: input.pattern }; + if (input.regex !== undefined) body['regex'] = input.regex; + if (input.caseSensitive !== undefined) body['case_sensitive'] = input.caseSensitive; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:grep`, + body, + ); + return { + files: data.files, + filesScanned: data.files_scanned, + truncated: data.truncated, + elapsedMs: data.elapsed_ms, + }; + } + + async getGitStatus( + sessionId: string, + paths?: string[], + ): Promise<{ branch: string; ahead: number; behind: number; entries: Record }> { + const body: Record = {}; + if (paths !== undefined) body['paths'] = paths; + const data = await this.http.post( + `/sessions/${encodeURIComponent(sessionId)}/fs:git_status`, + body, + ); + return { + branch: data.branch, + ahead: data.ahead, + behind: data.behind, + entries: data.entries, + }; + } + + // ------------------------------------------------------------------------- + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + // ------------------------------------------------------------------------- + + /** + * List the registered workspaces. + * PRESUMED — GET /api/v1/workspaces. On 404/empty/error this returns [] and + * the composable DERIVES workspaces from the current sessions' cwds. So the + * switcher + grouping work immediately off existing sessions until the daemon + * ships the registry. + */ + async listWorkspaces(): Promise { + try { + const data = await this.http.get>('/workspaces'); + return (data.items ?? []).map(toAppWorkspace); + } catch { + return []; + } + } + + /** + * Register a workspace by folder path. + * PRESUMED — POST /api/v1/workspaces { root, name? }. On error this throws so + * the composable can fall back to a locally-derived workspace from the path. + */ + async addWorkspace(input: { root: string; name?: string }): Promise { + const body: Record = { root: input.root }; + if (input.name !== undefined) body['name'] = input.name; + const data = await this.http.post('/workspaces', body); + return toAppWorkspace(data); + } + + /** + * Browse directories under `path` (defaults to $HOME on the daemon). + * PRESUMED — GET /api/v1/fs:browse?path=. On error returns an empty result so + * the picker degrades to paste-path + recentRoots. + */ + async browseFs(path?: string): Promise { + try { + const data = await this.http.get('/fs:browse', { path }); + return { + path: data.path, + parent: data.parent, + entries: (data.entries ?? []).map((e) => ({ + name: e.name, + path: e.path, + isDir: e.is_dir, + isGitRepo: e.is_git_repo, + branch: e.branch, + })), + }; + } catch { + return { path: path ?? '', parent: null, entries: [] }; + } + } + + /** + * Get the picker start directory + recently-used roots. + * PRESUMED — GET /api/v1/fs:home. On error returns empty defaults. + */ + async getFsHome(): Promise<{ home: string; recentRoots: string[] }> { + try { + const data = await this.http.get('/fs:home'); + return { home: data.home, recentRoots: data.recent_roots ?? [] }; + } catch { + return { home: '', recentRoots: [] }; + } + } + + // ------------------------------------------------------------------------- + // Models + Providers + // PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. + // ------------------------------------------------------------------------- + + async listModels(): Promise { + // PRESUMED endpoint: GET /v1/models → { items: WireModel[] } + const data = await this.http.get<{ items: WireModel[] }>('/models'); + return data.items.map(toAppModel); + } + + async listProviders(): Promise { + // PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] } + const data = await this.http.get<{ items: WireProvider[] }>('/providers'); + return data.items.map(toAppProvider); + } + + async addProvider(input: { + type: string; + apiKey?: string; + baseUrl?: string; + defaultModel?: string; + }): Promise { + // PRESUMED endpoint: POST /v1/providers → WireProvider + const body: Record = { type: input.type }; + if (input.apiKey !== undefined) body['api_key'] = input.apiKey; + if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl; + if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel; + const data = await this.http.post('/providers', body); + return toAppProvider(data); + } + + async deleteProvider(id: string): Promise<{ deleted: true }> { + // PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true } + return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`); + } + + async refreshProvider(id: string): Promise { + // PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider + const data = await this.http.post( + `/providers/${encodeURIComponent(id)}:refresh`, + ); + return toAppProvider(data); + } + + // ------------------------------------------------------------------------- + // Auth — REAL endpoints + // ------------------------------------------------------------------------- + + async getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }> { + const data = await this.http.get('/auth'); + return { + ready: data.ready, + providersCount: data.providers_count, + defaultModel: data.default_model, + managedProvider: data.managed_provider + ? { status: data.managed_provider.status } + : null, + }; + } + + async startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }> { + const data = await this.http.post('/oauth/login', {}); + return { + flowId: data.flow_id, + provider: data.provider, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + userCode: data.user_code, + expiresIn: data.expires_in, + interval: data.interval, + status: data.status, + expiresAt: data.expires_at, + }; + } + + async pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null> { + // data may be null if no flow is active + const data = await this.http.get('/oauth/login'); + if (!data) return null; + return { + flowId: data.flow_id, + status: data.status, + resolvedAt: data.resolved_at, + }; + } + + async cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }> { + const data = await this.http.delete('/oauth/login'); + return { cancelled: data.cancelled, status: data.status }; + } + + async logout(): Promise<{ loggedOut: boolean }> { + const data = await this.http.post('/oauth/logout', {}); + return { loggedOut: data.logged_out }; + } + + // ------------------------------------------------------------------------- + // File upload + // ------------------------------------------------------------------------- + + async uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }> { + const formData = new FormData(); + formData.append('file', input.file, input.name ?? (input.file instanceof File ? input.file.name : 'upload')); + if (input.name !== undefined) { + formData.append('name', input.name); + } + const data = await this.http.postForm('/files', formData); + return { + id: data.id, + name: data.name, + mediaType: data.media_type, + size: data.size, + }; + } + + // ------------------------------------------------------------------------- + // WebSocket events + // ------------------------------------------------------------------------- + + connectEvents(handlers: KimiEventHandlers): KimiEventConnection { + const wsUrl = buildWsUrl(this.config.daemonHttpUrl, this.config.clientId); + + // Per-session projector for raw agent-core events. + // Keyed by session_id; reset when a session is re-subscribed or resynced. + const projector = createAgentProjector(); + + const socket = new DaemonEventSocket(wsUrl, this.config.clientId, { + // ----------------------------------------------------------------------- + // Projected "event.*" frames — existing path (kept working for stub / spec) + // ----------------------------------------------------------------------- + onWireEvent: (wireEvent: WireEvent) => { + const sessionId = wireEventSessionId(wireEvent); + const seq = wireEventSeq(wireEvent); + const appEvent = toAppEvent(wireEvent); + + // Route history_compacted to onResync so client can reload messages + if (appEvent.type === 'historyCompacted') { + handlers.onResync(appEvent.sessionId, appEvent.beforeSeq); + // Still dispatch the event to onEvent so the reducer can update lastSeqBySession + } + + // Deliver the AppEvent together with wire-level seq/session so the + // reducer can advance lastSeqBySession[sessionId] = seq. + handlers.onEvent(appEvent, { sessionId, seq }); + }, + + // ----------------------------------------------------------------------- + // Raw agent-core frames — client-side projection path (real daemon) + // ----------------------------------------------------------------------- + onRawAgentEvent: (frame) => { + const { type, seq, session_id: sessionId, payload } = frame; + const appEvents = projector.project(type, payload, sessionId); + for (const appEvent of appEvents) { + // Auto-compaction: the projector can't see the wire seq, so it emits + // historyCompacted with beforeSeq:0. Route it to onResync using the + // real frame.seq to reload /messages, mirroring the protocol path. + if (appEvent.type === 'historyCompacted') { + handlers.onResync(sessionId, seq); + } + handlers.onEvent(appEvent, { sessionId, seq }); + } + }, + + onResync: (sessionId: string, currentSeq: number) => { + // Reset per-session projector state on resync + projector.reset(sessionId); + handlers.onResync(sessionId, currentSeq); + }, + + onConnectionState: (connected: boolean) => { + handlers.onConnectionChange(connected); + }, + + onError: (code: number, msg: string, fatal: boolean) => { + handlers.onError(code, msg, fatal); + }, + }); + + socket.connect(); + + return { + subscribe(sessionId: string, lastSeq?: number): void { + // Reset projector state when (re-)subscribing a session + projector.reset(sessionId); + socket.subscribe(sessionId, lastSeq ?? 0); + }, + unsubscribe(sessionId: string): void { + socket.unsubscribe(sessionId); + }, + bindNextPromptId(sessionId: string, promptId: string): void { + // Wire the real daemon prompt_id into the projector so turn.started + // uses it instead of a synthetic ulid('pr_'). Without this, the + // synthetic id propagates to session.currentPromptId and the REST + // :abort endpoint never matches the daemon's real prompt_id. + projector.bindNextPromptId(sessionId, promptId); + }, + abort(sessionId: string, promptId: string): void { + socket.abort(sessionId, promptId); + }, + close(): void { + socket.close(); + }, + }; + } +} diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts new file mode 100644 index 000000000..88fd8c999 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -0,0 +1,370 @@ +// apps/kimi-web/src/api/daemon/eventReducer.ts +// Pure TypeScript state reducer for KimiClient. +// Operates on plain TS state — no Vue reactivity here. +// The reducer consumes AppEvent (camelCase), produced by toAppEvent() in mappers.ts. +// +// No-op-but-known events (tool.*, assistant streaming, assistant.completed) +// are mapped to { type: 'unknown', raw: { _noop: true, ... } } by mappers.ts. +// The reducer detects `_noop: true` and silently advances lastSeqBySession +// without pushing a warning. + +import type { + AppApprovalRequest, + AppEvent, + AppMessage, + AppMessageContent, + AppQuestionRequest, + AppSession, + AppTask, +} from '../types'; +import { i18n } from '../../i18n'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +export interface KimiClientState { + sessions: AppSession[]; + activeSessionId?: string; + messagesBySession: Record; + approvalsBySession: Record; + questionsBySession: Record; + tasksBySession: Record; + lastSeqBySession: Record; + warnings: string[]; +} + +export function createInitialState(): KimiClientState { + return { + sessions: [], + activeSessionId: undefined, + messagesBySession: {}, + approvalsBySession: {}, + questionsBySession: {}, + tasksBySession: {}, + lastSeqBySession: {}, + warnings: [], + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cloneState(s: KimiClientState): KimiClientState { + return { + ...s, + sessions: [...s.sessions], + messagesBySession: { ...s.messagesBySession }, + approvalsBySession: { ...s.approvalsBySession }, + questionsBySession: { ...s.questionsBySession }, + tasksBySession: { ...s.tasksBySession }, + lastSeqBySession: { ...s.lastSeqBySession }, + warnings: [...s.warnings], + }; +} + +function advanceSeq(state: KimiClientState, sessionId: string | undefined, seq: number | undefined): void { + if (sessionId !== undefined && seq !== undefined && seq > 0) { + const prev = state.lastSeqBySession[sessionId] ?? 0; + if (seq > prev) { + state.lastSeqBySession[sessionId] = seq; + } + } +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +/** + * Apply a single AppEvent to the state, returning a new state object. + * The event carries `_wireSeq` and `_wireSessionId` as hidden extras when + * produced by the client wrapper, but the reducer only depends on the + * AppEvent.type discriminant. + * + * Extra metadata attached by the caller: + * meta.sessionId — wire session_id for lastSeqBySession update + * meta.seq — wire seq for lastSeqBySession update + */ +export interface EventMeta { + sessionId: string; + seq: number; +} + +export function reduceAppEvent( + state: KimiClientState, + event: AppEvent, + meta: EventMeta, +): KimiClientState { + const next = cloneState(state); + + // Always advance lastSeqBySession for every event that carries seq info. + advanceSeq(next, meta.sessionId, meta.seq); + + switch (event.type) { + // ------------------------------------------------------------------------- + case 'sessionCreated': { + const exists = next.sessions.some((s) => s.id === event.session.id); + if (!exists) { + next.sessions = [event.session, ...next.sessions]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUpdated': { + next.sessions = next.sessions.map((s) => + s.id === event.session.id ? event.session : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionDeleted': { + const id = event.sessionId; + next.sessions = next.sessions.filter((s) => s.id !== id); + delete next.messagesBySession[id]; + delete next.tasksBySession[id]; + delete next.approvalsBySession[id]; + delete next.questionsBySession[id]; + delete next.lastSeqBySession[id]; + if (next.activeSessionId === id) { + next.activeSessionId = undefined; + } + break; + } + + // ------------------------------------------------------------------------- + case 'sessionStatusChanged': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + return { + ...s, + status: event.status, + currentPromptId: event.currentPromptId, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionMetaUpdated': { + // Lightweight title patch — the daemon's auto-generated title (or a title + // changed by another client) arrives via session.meta.updated. We patch + // only the title field; the full session object stays as-is. + next.sessions = next.sessions.map((s) => + s.id === event.sessionId ? { ...s, title: event.title } : s, + ); + break; + } + + // ------------------------------------------------------------------------- + case 'sessionUsageUpdated': { + next.sessions = next.sessions.map((s) => { + if (s.id !== event.sessionId) return s; + // The live model name (from agent.status.updated) rides along with usage. + // Only overwrite model when a non-empty one is supplied. + const model = event.model && event.model.length > 0 ? event.model : s.model; + return { ...s, usage: event.usage, model }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'historyCompacted': { + // Only advance lastSeqBySession; actual reload is triggered by client wrapper + // when it sees this event type (before_seq is in event.beforeSeq). + // The advanceSeq at top already handled seq update. + break; + } + + // ------------------------------------------------------------------------- + case 'messageCreated': { + const sid = event.message.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + const exists = msgs.some((m) => m.id === event.message.id); + if (!exists) { + next.messagesBySession[sid] = [...msgs, event.message]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'messageUpdated': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + return { ...m, content: event.content }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'assistantDelta': { + const sid = event.sessionId; + const msgs = next.messagesBySession[sid] ?? []; + next.messagesBySession[sid] = msgs.map((m) => { + if (m.id !== event.messageId) return m; + const content = [...m.content]; + const idx = event.contentIndex; + // Ensure the slot exists + while (content.length <= idx) { + content.push({ type: 'text', text: '' }); + } + const existing = content[idx]!; + let patched: AppMessageContent; + if (event.delta.text !== undefined) { + if (existing.type === 'text') { + patched = { type: 'text', text: existing.text + event.delta.text }; + } else { + patched = { type: 'text', text: event.delta.text }; + } + } else if (event.delta.thinking !== undefined) { + if (existing.type === 'thinking') { + patched = { + type: 'thinking', + thinking: existing.thinking + event.delta.thinking, + signature: existing.signature, + }; + } else { + patched = { type: 'thinking', thinking: event.delta.thinking }; + } + } else { + patched = existing; + } + content[idx] = patched; + return { ...m, content }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'approvalRequested': { + const sid = event.sessionId; + const list = next.approvalsBySession[sid] ?? []; + const exists = list.some((a) => a.approvalId === event.approval.approvalId); + if (!exists) { + next.approvalsBySession[sid] = [...list, event.approval]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'approvalResolved': + case 'approvalExpired': { + const sid = event.sessionId; + const aid = event.approvalId; + const list = next.approvalsBySession[sid] ?? []; + next.approvalsBySession[sid] = list.filter((a) => a.approvalId !== aid); + break; + } + + // ------------------------------------------------------------------------- + case 'questionRequested': { + const sid = event.sessionId; + const list = next.questionsBySession[sid] ?? []; + const exists = list.some((q) => q.questionId === event.question.questionId); + if (!exists) { + next.questionsBySession[sid] = [...list, event.question]; + } + break; + } + + // ------------------------------------------------------------------------- + case 'questionAnswered': + case 'questionDismissed': + case 'questionExpired': { + const sid = event.sessionId; + const qid = event.questionId; + const list = next.questionsBySession[sid] ?? []; + next.questionsBySession[sid] = list.filter((q) => q.questionId !== qid); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCreated': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + const idx = list.findIndex((t) => t.id === event.task.id); + if (idx === -1) { + next.tasksBySession[sid] = [...list, event.task]; + } else { + const patched = [...list]; + patched[idx] = event.task; + next.tasksBySession[sid] = patched; + } + break; + } + + // ------------------------------------------------------------------------- + case 'taskProgress': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + return { + ...t, + outputLines: [...(t.outputLines ?? []), event.outputChunk], + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'taskCompleted': { + const sid = event.sessionId; + const list = next.tasksBySession[sid] ?? []; + next.tasksBySession[sid] = list.map((t) => { + if (t.id !== event.taskId) return t; + return { + ...t, + status: event.status, + outputPreview: event.outputPreview, + outputBytes: event.outputBytes, + }; + }); + break; + } + + // ------------------------------------------------------------------------- + case 'unknown': { + // Distinguish no-op known events (sentinel _noop) from agent errors/warnings + // and truly unknown events. + const raw = event.raw as { + _noop?: boolean; + _agentError?: boolean; + _agentWarning?: boolean; + code?: string; + message?: string; + type?: string; + } | null; + if (raw && raw._noop === true) { + // No-op streaming/tool event — seq already advanced, nothing else to do + } else if (raw && (raw._agentError || raw._agentWarning)) { + // Surface the agent's real error/warning message (e.g. a 403 from the + // model provider) instead of a useless "Unhandled event". + const label = raw._agentError + ? i18n.global.t('warnings.errorLabel') + : i18n.global.t('warnings.noteLabel'); + const msg = raw.message ?? raw.code ?? 'agent error'; + next.warnings = [...next.warnings, `${label}: ${msg}`]; + } else { + // Truly unknown — push a warning + const wireType = raw?.type ?? '(unknown)'; + next.warnings = [...next.warnings, `Unhandled event: ${wireType}`]; + } + break; + } + + default: { + // TypeScript exhaustiveness guard — should not reach here + const _exhaustive: never = event; + void _exhaustive; + break; + } + } + + return next; +} diff --git a/apps/kimi-web/src/api/daemon/http.ts b/apps/kimi-web/src/api/daemon/http.ts new file mode 100644 index 000000000..c04fa466a --- /dev/null +++ b/apps/kimi-web/src/api/daemon/http.ts @@ -0,0 +1,118 @@ +// apps/kimi-web/src/api/daemon/http.ts +// DaemonHttpClient — REST transport with envelope unwrap and allowCodes support. + +import { buildRestUrl } from '../config'; +import { DaemonApiError, DaemonNetworkError } from '../errors'; +import type { WireEnvelope } from './wire'; + +export class DaemonHttpClient { + constructor(private readonly origin: string) {} + + async get(path: string, query?: Record): Promise { + return this.request('GET', path, undefined, query); + } + + async post(path: string, body?: unknown, opts?: { allowCodes?: number[] }): Promise { + return this.request('POST', path, body, undefined, opts?.allowCodes); + } + + /** Send multipart/form-data (FormData). Does NOT set Content-Type — browser sets it with boundary. */ + async postForm(path: string, formData: FormData): Promise { + const url = buildRestUrl(this.origin, path); + const headers: Record = { + 'X-Request-Id': globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2), + }; + let response: Response; + try { + response = await fetch(url, { method: 'POST', headers, body: formData }); + } catch (err) { + throw new DaemonNetworkError(`Network error calling POST ${path}`, err); + } + let envelope: WireEnvelope; + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + throw new DaemonNetworkError(`Failed to parse JSON response from POST ${path}`, err); + } + if (envelope.code !== 0) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + return envelope.data as T; + } + + async patch(path: string, body: unknown): Promise { + return this.request('PATCH', path, body); + } + + async delete(path: string): Promise { + return this.request('DELETE', path); + } + + private async request( + method: string, + path: string, + body?: unknown, + query?: Record, + allowCodes: number[] = [], + ): Promise { + // Build URL, appending query string (omit undefined values) + let url = buildRestUrl(this.origin, path); + if (query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.set(key, String(value)); + } + } + const qs = params.toString(); + if (qs) url = `${url}?${qs}`; + } + + // Build headers + const headers: Record = { + 'X-Request-Id': globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2), + }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json; charset=utf-8'; + } + + // Execute fetch + let response: Response; + try { + response = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (err) { + throw new DaemonNetworkError(`Network error calling ${method} ${path}`, err); + } + + // Parse envelope + let envelope: WireEnvelope; + try { + envelope = (await response.json()) as WireEnvelope; + } catch (err) { + throw new DaemonNetworkError(`Failed to parse JSON response from ${method} ${path}`, err); + } + + // Unwrap: code 0 = success; allowed non-zero = return data; else throw + if (envelope.code !== 0 && !allowCodes.includes(envelope.code)) { + throw new DaemonApiError({ + code: envelope.code, + msg: envelope.msg, + requestId: envelope.request_id, + details: envelope.details, + }); + } + + // For both code=0 and allowed non-zero codes, return the data field. + // Callers that pass allowCodes handle the null/non-null data themselves. + return envelope.data as T; + } +} diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts new file mode 100644 index 000000000..37c44a963 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -0,0 +1,606 @@ +// apps/kimi-web/src/api/daemon/mappers.ts +// wire→app and app→wire mapper functions. +// All snake_case ↔ camelCase conversion happens ONLY here. + +import type { + AppApprovalRequest, + AppEvent, + AppModel, + AppProvider, + FsEntry, + AppMessage, + AppMessageContent, + AppMessageRole, + AppQuestionRequest, + AppSession, + AppSessionStatus, + AppSessionUsage, + AppTask, + AppTaskStatus, + AppWorkspace, + ApprovalResponse, + ImageSource, + PromptSubmission, + QuestionAnswer, + QuestionItem, + QuestionOption, + QuestionResponse, +} from '../types'; + +import type { + WireApprovalRequest, + WireApprovalResponse, + WireBackgroundTask, + WireFsEntry, + WireImageSource, + WireMessage, + WireMessageContent, + WireModel, + WirePromptSubmission, + WireProvider, + WireQuestionAnswer, + WireQuestionItem, + WireQuestionOption, + WireQuestionRequest, + WireQuestionResponse, + WireSession, + WireSessionStatus, + WireSessionUsage, + WireWorkspace, + WireEvent, +} from './wire'; + +// --------------------------------------------------------------------------- +// Session mappers +// --------------------------------------------------------------------------- + +export function toAppSessionUsage(wire: WireSessionUsage): AppSessionUsage { + return { + inputTokens: wire.input_tokens, + outputTokens: wire.output_tokens, + cacheReadTokens: wire.cache_read_tokens, + cacheCreationTokens: wire.cache_creation_tokens, + totalCostUsd: wire.total_cost_usd, + contextTokens: wire.context_tokens, + contextLimit: wire.context_limit, + turnCount: wire.turn_count, + }; +} + +export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus { + switch (wire) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaiting_approval': return 'awaitingApproval'; + case 'awaiting_question': return 'awaitingQuestion'; + case 'aborted': return 'aborted'; + } +} + +export function toWireSessionStatus(status: AppSessionStatus): WireSessionStatus { + switch (status) { + case 'idle': return 'idle'; + case 'running': return 'running'; + case 'awaitingApproval': return 'awaiting_approval'; + case 'awaitingQuestion': return 'awaiting_question'; + case 'aborted': return 'aborted'; + } +} + +export function toAppSession(wire: WireSession): AppSession { + return { + id: wire.id, + title: wire.title, + createdAt: wire.created_at, + updatedAt: wire.updated_at, + status: toAppSessionStatus(wire.status), + currentPromptId: wire.current_prompt_id, + cwd: wire.metadata.cwd, + model: wire.agent_config.model, + usage: toAppSessionUsage(wire.usage), + messageCount: wire.message_count, + lastSeq: wire.last_seq, + workspaceId: wire.workspace_id, + }; +} + +export function toAppWorkspace(wire: WireWorkspace): AppWorkspace { + return { + id: wire.id, + root: wire.root, + name: wire.name, + isGitRepo: wire.is_git_repo, + branch: wire.branch ?? undefined, + lastOpenedAt: wire.last_opened_at, + sessionCount: wire.session_count, + }; +} + +// --------------------------------------------------------------------------- +// Message mappers +// --------------------------------------------------------------------------- + +function toAppImageSource(src: WireImageSource): ImageSource { + if (src.kind === 'base64') { + return { kind: 'base64', mediaType: src.media_type, data: src.data }; + } + if (src.kind === 'file') { + return { kind: 'file', fileId: src.file_id }; + } + return { kind: 'url', url: src.url }; +} + +function toAppMessageContent(wire: WireMessageContent): AppMessageContent { + switch (wire.type) { + case 'text': + return { type: 'text', text: wire.text }; + case 'tool_use': + return { + type: 'toolUse', + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + input: wire.input, + }; + case 'tool_result': + return { + type: 'toolResult', + toolCallId: wire.tool_call_id, + output: wire.output, + isError: wire.is_error, + }; + case 'image': + return { + type: 'image', + source: toAppImageSource(wire.source), + }; + case 'file': + return { + type: 'file', + fileId: wire.file_id, + name: wire.name, + mediaType: wire.media_type, + size: wire.size, + }; + case 'thinking': + return { + type: 'thinking', + thinking: wire.thinking, + signature: wire.signature, + }; + default: { + // Unknown content type — pass raw through + return { type: 'unknown', raw: wire }; + } + } +} + +export function toAppMessage(wire: WireMessage): AppMessage { + return { + id: wire.id, + sessionId: wire.session_id, + role: wire.role as AppMessageRole, + content: wire.content.map(toAppMessageContent), + createdAt: wire.created_at, + promptId: wire.prompt_id, + parentMessageId: wire.parent_message_id, + metadata: wire.metadata, + }; +} + +// --------------------------------------------------------------------------- +// Prompt mappers +// --------------------------------------------------------------------------- + +function toWireMessageContent(app: AppMessageContent): WireMessageContent { + switch (app.type) { + case 'text': + return { type: 'text', text: app.text }; + case 'toolUse': + return { + type: 'tool_use', + tool_call_id: app.toolCallId, + tool_name: app.toolName, + input: app.input, + }; + case 'toolResult': + return { + type: 'tool_result', + tool_call_id: app.toolCallId, + output: app.output, + is_error: app.isError, + }; + case 'image': { + const src = app.source; + let wireSrc: WireImageSource; + if (src.kind === 'base64') { + wireSrc = { kind: 'base64', media_type: src.mediaType, data: src.data }; + } else if (src.kind === 'file') { + wireSrc = { kind: 'file', file_id: src.fileId }; + } else { + wireSrc = { kind: 'url', url: src.url }; + } + return { type: 'image', source: wireSrc }; + } + case 'file': + return { + type: 'file', + file_id: app.fileId, + name: app.name, + media_type: app.mediaType, + size: app.size, + }; + case 'thinking': + return { type: 'thinking', thinking: app.thinking, signature: app.signature }; + case 'unknown': + // Best-effort: pass raw back. May not be a valid WireMessageContent. + return app.raw as WireMessageContent; + } +} + +export function toWirePromptSubmission(input: PromptSubmission): WirePromptSubmission { + return { + content: input.content.map(toWireMessageContent), + metadata: input.metadata, + model: input.model, + thinking: input.thinking, + permission_mode: input.permissionMode, + plan_mode: input.planMode, + }; +} + +// --------------------------------------------------------------------------- +// Approval mappers +// --------------------------------------------------------------------------- + +export function toWireApprovalResponse(input: ApprovalResponse): WireApprovalResponse { + return { + decision: input.decision, + scope: input.scope, + feedback: input.feedback, + selected_label: input.selectedLabel, + }; +} + +export function toAppApprovalRequest(wire: WireApprovalRequest): AppApprovalRequest { + return { + approvalId: wire.approval_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + toolName: wire.tool_name, + action: wire.action, + display: wire.display, + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Question mappers +// --------------------------------------------------------------------------- + +function toAppQuestionOption(wire: WireQuestionOption): QuestionOption { + return { + id: wire.id, + label: wire.label, + description: wire.description, + }; +} + +function toAppQuestionItem(wire: WireQuestionItem): QuestionItem { + return { + id: wire.id, + question: wire.question, + header: wire.header, + body: wire.body, + options: wire.options.map(toAppQuestionOption), + multiSelect: wire.multi_select, + allowOther: wire.allow_other, + otherLabel: wire.other_label, + otherDescription: wire.other_description, + }; +} + +export function toAppQuestionRequest(wire: WireQuestionRequest): AppQuestionRequest { + return { + questionId: wire.question_id, + sessionId: wire.session_id, + turnId: wire.turn_id, + toolCallId: wire.tool_call_id, + questions: wire.questions.map(toAppQuestionItem), + expiresAt: wire.expires_at, + createdAt: wire.created_at, + }; +} + +function toWireQuestionAnswer(app: QuestionAnswer): WireQuestionAnswer { + switch (app.kind) { + case 'single': + return { kind: 'single', option_id: app.optionId }; + case 'multi': + return { kind: 'multi', option_ids: app.optionIds }; + case 'other': + return { kind: 'other', text: app.text }; + case 'multiWithOther': + return { kind: 'multi_with_other', option_ids: app.optionIds, other_text: app.otherText }; + case 'skipped': + return { kind: 'skipped' }; + } +} + +export function toWireQuestionResponse(input: QuestionResponse): WireQuestionResponse { + const wireAnswers: Record = {}; + for (const [questionId, answer] of Object.entries(input.answers)) { + wireAnswers[questionId] = toWireQuestionAnswer(answer); + } + return { + answers: wireAnswers, + method: input.method, + note: input.note, + }; +} + +// --------------------------------------------------------------------------- +// Task mapper +// --------------------------------------------------------------------------- + +export function toAppTask(wire: WireBackgroundTask): AppTask { + return { + id: wire.id, + sessionId: wire.session_id, + kind: wire.kind, + description: wire.description, + status: wire.status as AppTaskStatus, + createdAt: wire.created_at, + startedAt: wire.started_at, + completedAt: wire.completed_at, + outputPreview: wire.output_preview, + outputBytes: wire.output_bytes, + // outputLines starts undefined; populated by eventReducer via task.progress events + }; +} + +// --------------------------------------------------------------------------- +// FsEntry mapper +// --------------------------------------------------------------------------- + +export function toAppFsEntry(wire: WireFsEntry): FsEntry { + return { + path: wire.path, + name: wire.name, + kind: wire.kind, + size: wire.size, + modifiedAt: wire.modified_at, + etag: wire.etag, + mime: wire.mime, + languageId: wire.language_id, + isBinary: wire.is_binary, + isSymlinkTo: wire.is_symlink_to, + gitStatus: wire.git_status, + childCount: wire.child_count, + }; +} + +// --------------------------------------------------------------------------- +// WireEvent → AppEvent +// --------------------------------------------------------------------------- + +/** + * Map a WireEvent to an AppEvent. + * + * Decision: reducer consumes AppEvent. + * - Visible events are fully mapped to their camelCase AppEvent variant. + * - No-op-but-known streaming/tool events (tool.*, assistant.tool_use_*, + * assistant.completed) are folded to { type: 'unknown', raw } so the reducer + * can advance lastSeqBySession without emitting warnings. + * We use a dedicated sentinel raw: { _noop: true } so Task 7 reducer can + * distinguish real unknowns (push warning) from no-op knowns (silent advance). + * - Truly unknown events are also { type: 'unknown', raw } but raw._noop is absent. + */ +export function toAppEvent(wire: WireEvent): AppEvent { + // TypeScript cannot narrow the WireEvent union through specific `case` arms + // because the catch-all `WireEventUnknown` member has `type: string` (broad) + // and `payload: unknown`, which prevents discriminated-union narrowing. + // We cast to `any` once here; individual cases are still logically type-safe + // because the union member types document the actual payload shapes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = wire as any; + switch ((wire as { type: string }).type) { + // ----- Session lifecycle ----- + case 'event.session.created': + return { type: 'sessionCreated', session: toAppSession(w.payload.session) }; + + case 'event.session.updated': + return { + type: 'sessionUpdated', + session: toAppSession(w.payload.session), + changedFields: w.payload.changed_fields, + }; + + case 'event.session.deleted': + return { type: 'sessionDeleted', sessionId: w.session_id }; + + case 'event.session.status_changed': + return { + type: 'sessionStatusChanged', + sessionId: w.session_id, + status: toAppSessionStatus(w.payload.status), + previousStatus: toAppSessionStatus(w.payload.previous_status), + currentPromptId: w.payload.current_prompt_id, + }; + + case 'event.session.usage_updated': + return { + type: 'sessionUsageUpdated', + sessionId: w.session_id, + usage: toAppSessionUsage(w.payload.usage), + }; + + case 'event.session.history_compacted': + return { + type: 'historyCompacted', + sessionId: w.session_id, + beforeSeq: w.payload.before_seq, + reason: w.payload.reason, + summaryMessageId: w.payload.summary_message_id, + }; + + // ----- Message lifecycle ----- + case 'event.message.created': + return { type: 'messageCreated', message: toAppMessage(w.payload.message) }; + + case 'event.message.updated': + return { + type: 'messageUpdated', + sessionId: w.session_id, + messageId: w.payload.message_id, + content: w.payload.content.map(toAppMessageContent), + status: w.payload.status, + }; + + // ----- Assistant streaming ----- + case 'event.assistant.delta': + return { + type: 'assistantDelta', + sessionId: w.session_id, + messageId: w.payload.message_id, + contentIndex: w.payload.content_index, + delta: w.payload.delta, + }; + + // No-op streaming events — advance seq silently + case 'event.assistant.tool_use_started': + case 'event.assistant.tool_use_delta': + case 'event.assistant.tool_use_completed': + case 'event.assistant.completed': + case 'event.tool.started': + case 'event.tool.output': + case 'event.tool.progress': + case 'event.tool.completed': + return { type: 'unknown', raw: { _noop: true, _wireType: w.type } }; + + // ----- Approval ----- + case 'event.approval.requested': + return { + type: 'approvalRequested', + sessionId: w.session_id, + approval: toAppApprovalRequest(w.payload), + }; + + case 'event.approval.resolved': + return { + type: 'approvalResolved', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + decision: w.payload.decision, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.approval.expired': + return { + type: 'approvalExpired', + sessionId: w.session_id, + approvalId: w.payload.approval_id, + }; + + // ----- Question ----- + case 'event.question.requested': + return { + type: 'questionRequested', + sessionId: w.session_id, + question: toAppQuestionRequest(w.payload), + }; + + case 'event.question.answered': + return { + type: 'questionAnswered', + sessionId: w.session_id, + questionId: w.payload.question_id, + resolvedAt: w.payload.resolved_at, + }; + + case 'event.question.dismissed': + return { + type: 'questionDismissed', + sessionId: w.session_id, + questionId: w.payload.question_id, + dismissedAt: w.payload.dismissed_at, + }; + + case 'event.question.expired': + return { + type: 'questionExpired', + sessionId: w.session_id, + questionId: w.payload.question_id, + }; + + // ----- Background tasks ----- + case 'event.task.created': + return { + type: 'taskCreated', + sessionId: w.session_id, + task: toAppTask(w.payload.task), + }; + + case 'event.task.progress': + return { + type: 'taskProgress', + sessionId: w.session_id, + taskId: w.payload.task_id, + outputChunk: w.payload.output_chunk, + stream: w.payload.stream, + }; + + case 'event.task.completed': + return { + type: 'taskCompleted', + sessionId: w.session_id, + taskId: w.payload.task_id, + status: w.payload.status as AppTaskStatus, + outputPreview: w.payload.output_preview, + outputBytes: w.payload.output_bytes, + }; + + default: { + // Truly unknown event — record warning + return { type: 'unknown', raw: wire }; + } + } +} + +// --------------------------------------------------------------------------- +// Model + Provider mappers +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export function toAppModel(wire: WireModel): AppModel { + return { + id: wire.model, + provider: wire.provider, + model: wire.model, + displayName: wire.display_name, + maxContextSize: wire.max_context_size, + capabilities: wire.capabilities, + }; +} + +export function toAppProvider(wire: WireProvider): AppProvider { + return { + id: wire.id, + type: wire.type, + baseUrl: wire.base_url, + defaultModel: wire.default_model, + hasApiKey: wire.has_api_key, + status: wire.status, + models: wire.models, + }; +} + +// Helper to extract sessionId from a WireEvent (needed by reducer for lastSeq update) +export function wireEventSessionId(wire: WireEvent): string { + return wire.session_id; +} + +export function wireEventSeq(wire: WireEvent): number { + return wire.seq; +} diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts new file mode 100644 index 000000000..d77a2f96a --- /dev/null +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -0,0 +1,650 @@ +// apps/kimi-web/src/api/daemon/wire.ts +// Daemon wire DTOs — ALL fields stay snake_case as they appear on the wire. +// No camelCase conversions here; that is mappers.ts's job. + +// --------------------------------------------------------------------------- +// Envelope & Page +// --------------------------------------------------------------------------- + +export interface WireEnvelope { + code: number; + msg: string; + data: T | null; + request_id: string; + details?: unknown; +} + +export interface WirePage { + items: T[]; + has_more: boolean; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type WireSessionStatus = + | 'idle' + | 'running' + | 'awaiting_approval' + | 'awaiting_question' + | 'aborted'; + +export interface WireSessionUsage { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + total_cost_usd: number; + context_tokens: number; + context_limit: number; + turn_count: number; +} + +export interface WireSessionUsageDelta { + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_creation_tokens: number; + cost_usd: number; +} + +export interface WirePermissionRule { + id: string; + tool_name: string; + matcher?: { + kind: 'command_prefix' | 'path_glob' | 'exact_input' | 'always'; + value?: string; + }; + decision: 'approved'; + created_at: string; + created_by: 'user' | 'agent'; +} + +export interface WireSession { + id: string; + title: string; + created_at: string; + updated_at: string; + status: WireSessionStatus; + current_prompt_id?: string; + // PRESUMED — daemon adds this once it ships the workspace registry; until then + // it is absent and the client maps sessions by metadata.cwd === workspace.root. + workspace_id?: string; + metadata: { + cwd: string; + [key: string]: unknown; + }; + agent_config: { + model: string; + system_prompt?: string; + tools?: string[]; + mcp_servers?: string[]; + }; + usage: WireSessionUsage; + permission_rules: WirePermissionRule[]; + message_count: number; + last_seq: number; +} + +// --------------------------------------------------------------------------- +// Workspace + daemon folder browser wire DTOs +// PRESUMED — not in the live daemon yet; isolated here, swap when backend ships. +// --------------------------------------------------------------------------- + +export interface WireWorkspace { + id: string; + root: string; + name: string; + is_git_repo: boolean; + branch: string | null; + last_opened_at?: string; + session_count: number; +} + +export interface WireFsBrowseEntry { + name: string; + path: string; + is_dir: boolean; + is_git_repo: boolean; + branch?: string; +} + +export interface WireFsBrowseResult { + path: string; + parent: string | null; + entries: WireFsBrowseEntry[]; +} + +export interface WireFsHomeResult { + home: string; + recent_roots: string[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type WireMessageContent = + | { type: 'text'; text: string } + | { type: 'tool_use'; tool_call_id: string; tool_name: string; input: unknown } + | { type: 'tool_result'; tool_call_id: string; output: unknown; is_error?: boolean } + | { type: 'image'; source: WireImageSource } + | { type: 'file'; file_id: string; name: string; media_type: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string }; + +export type WireImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; media_type: string; data: string } + | { kind: 'file'; file_id: string }; + +export interface WireMessage { + id: string; + session_id: string; + role: 'user' | 'assistant' | 'tool' | 'system'; + content: WireMessageContent[]; + created_at: string; + prompt_id?: string; + parent_message_id?: string; + metadata?: Record; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export interface WirePromptSubmission { + content: WireMessageContent[]; + metadata?: Record; + model?: string; + thinking?: string; + permission_mode?: string; + plan_mode?: boolean; +} + +export interface WirePromptSubmitResult { + prompt_id: string; + user_message_id: string; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export interface WireApprovalRequest { + approval_id: string; + session_id: string; + turn_id?: number; + tool_call_id: string; + tool_name: string; + action: string; + display: unknown; // ToolInputDisplay — 12 discriminated kinds; client falls back to generic + expires_at: string; + created_at: string; +} + +export interface WireApprovalResponse { + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface WireQuestionOption { + id: string; + label: string; + description?: string; +} + +export interface WireQuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: WireQuestionOption[]; + multi_select?: boolean; + allow_other?: boolean; + other_label?: string; + other_description?: string; +} + +export interface WireQuestionRequest { + question_id: string; + session_id: string; + turn_id?: number; + tool_call_id?: string; + questions: WireQuestionItem[]; + expires_at: string; + created_at: string; +} + +export type WireQuestionAnswer = + | { kind: 'single'; option_id: string } + | { kind: 'multi'; option_ids: string[] } + | { kind: 'other'; text: string } + | { kind: 'multi_with_other'; option_ids: string[]; other_text: string } + | { kind: 'skipped' }; + +export interface WireQuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type WireTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface WireBackgroundTask { + id: string; + session_id: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: WireTaskStatus; + created_at: string; + started_at?: string; + completed_at?: string; + output_preview?: string; + output_bytes?: number; +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type WireFsKind = 'file' | 'directory' | 'symlink'; + +export interface WireFsEntry { + path: string; + name: string; + kind: WireFsKind; + size?: number; + modified_at: string; + etag?: string; + mime?: string; + language_id?: string; + is_binary?: boolean; + is_symlink_to?: string; + git_status?: string; + child_count?: number; +} + +// --------------------------------------------------------------------------- +// Model + Provider wire DTOs +// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface WireModel { + provider: string; + model: string; + display_name?: string; + max_context_size: number; + capabilities?: string[]; +} + +export interface WireProvider { + id: string; + type: string; + base_url?: string; + default_model?: string; + has_api_key: boolean; + status: 'connected' | 'error' | 'unconfigured'; + models?: string[]; +} + +// --------------------------------------------------------------------------- +// Auth wire DTOs — REAL endpoints (GET /api/v1/auth, POST/GET/DELETE /api/v1/oauth/login, POST /api/v1/oauth/logout) +// --------------------------------------------------------------------------- + +export interface WireManagedProvider { + status: string; + [key: string]: unknown; +} + +export interface WireAuthResult { + ready: boolean; + providers_count: number; + default_model: string | null; + managed_provider: WireManagedProvider | null; +} + +export interface WireOAuthLoginStartResult { + flow_id: string; + provider: string; + verification_uri: string; + verification_uri_complete: string; + user_code: string; + expires_in: number; + interval: number; + status: 'pending'; + expires_at: string; +} + +export interface WireOAuthLoginPollResult { + flow_id: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolved_at?: string; +} + +export interface WireOAuthCancelResult { + cancelled: boolean; + status: string; +} + +export interface WireLogoutResult { + logged_out: boolean; +} + +// --------------------------------------------------------------------------- +// File upload wire DTOs +// --------------------------------------------------------------------------- + +export interface WireFileMeta { + id: string; + name: string; + media_type: string; + size: number; + created_at: string; + expires_at?: string; +} + +// --------------------------------------------------------------------------- +// WS Server frames (S→C) +// --------------------------------------------------------------------------- + +/** All typed server-to-client WS frames */ +export type WireServerFrame = + | WireServerHello + | WireAck + | WirePing + | WireResyncRequired + | WireErrorFrame + | WireEvent; + +export interface WireServerHello { + type: 'server_hello'; + timestamp: string; + payload: { + server_id: string; + heartbeat_ms: number; + max_event_buffer_size: number; + capabilities: { + event_batching: boolean; + compression: boolean; + }; + }; +} + +export interface WireAck { + type: 'ack'; + id: string; + code: number; + msg: string; + payload: unknown; +} + +export interface WirePing { + type: 'ping'; + timestamp: string; + payload: { nonce: string }; +} + +export interface WireResyncRequired { + type: 'resync_required'; + timestamp: string; + payload: { + session_id: string; + reason: 'buffer_overflow' | 'session_recreated'; + current_seq: number; + }; +} + +export interface WireErrorFrame { + type: 'error'; + timestamp: string; + payload: { + code: number; + msg: string; + fatal: boolean; + request_id?: string; + details?: unknown; + }; +} + +// --------------------------------------------------------------------------- +// WS Client control messages (C→S) +// --------------------------------------------------------------------------- + +export type WireClientControl = + | WireClientHello + | WireSubscribe + | WireUnsubscribe + | WireAbort + | WirePong; + +export interface WireClientHello { + type: 'client_hello'; + id: string; + payload: { + client_id: string; + subscriptions: string[]; + last_seq_by_session?: Record; + }; +} + +export interface WireSubscribe { + type: 'subscribe'; + id: string; + payload: { + session_ids: string[]; + last_seq_by_session?: Record; + }; +} + +export interface WireUnsubscribe { + type: 'unsubscribe'; + id: string; + payload: { session_ids: string[] }; +} + +export interface WireAbort { + type: 'abort'; + id: string; + payload: { + session_id: string; + prompt_id: string; + }; +} + +export interface WirePong { + type: 'pong'; + payload: { nonce: string }; +} + +// --------------------------------------------------------------------------- +// WS Events (S→C) — all type: "event.*" +// --------------------------------------------------------------------------- + +/** Base shape for all WS event frames */ +interface WireEventBase { + type: T; + seq: number; + session_id: string; + timestamp: string; + payload: P; +} + +// Session lifecycle +type WireEventSessionCreated = WireEventBase<'event.session.created', { session: WireSession }>; +type WireEventSessionUpdated = WireEventBase<'event.session.updated', { session: WireSession; changed_fields: string[] }>; +type WireEventSessionDeleted = WireEventBase<'event.session.deleted', { session_id: string }>; +type WireEventSessionStatusChanged = WireEventBase<'event.session.status_changed', { + status: WireSessionStatus; + previous_status: WireSessionStatus; + current_prompt_id?: string; +}>; +type WireEventSessionUsageUpdated = WireEventBase<'event.session.usage_updated', { + usage: WireSessionUsage; + delta: WireSessionUsageDelta; +}>; +type WireEventSessionHistoryCompacted = WireEventBase<'event.session.history_compacted', { + before_seq: number; + reason: 'auto_compact' | 'manual_compact' | 'history_rewrite'; + summary_message_id?: string; +}>; + +// Message lifecycle +type WireEventMessageCreated = WireEventBase<'event.message.created', { message: WireMessage }>; +type WireEventMessageUpdated = WireEventBase<'event.message.updated', { + message_id: string; + content: WireMessageContent[]; + status: 'pending' | 'completed' | 'error'; +}>; + +// Assistant streaming +type WireEventAssistantDelta = WireEventBase<'event.assistant.delta', { + message_id: string; + content_index: number; + delta: { text?: string; thinking?: string }; +}>; +// No-op-but-known streaming events (advance lastSeq, no UI change) +type WireEventAssistantToolUseStarted = WireEventBase<'event.assistant.tool_use_started', { + message_id: string; + tool_call_id: string; + tool_name: string; + content_index: number; +}>; +type WireEventAssistantToolUseDelta = WireEventBase<'event.assistant.tool_use_delta', { + message_id: string; + tool_call_id: string; + input_delta: string; +}>; +type WireEventAssistantToolUseCompleted = WireEventBase<'event.assistant.tool_use_completed', { + message_id: string; + tool_call_id: string; + input: unknown; +}>; +type WireEventAssistantCompleted = WireEventBase<'event.assistant.completed', { + message_id: string; + finish_reason: 'stop' | 'tool_use' | 'length' | 'cancelled' | 'error'; +}>; + +// Tool execution (no-op-but-known) +type WireEventToolStarted = WireEventBase<'event.tool.started', { + tool_call_id: string; + tool_name: string; + input: unknown; + parent_message_id: string; +}>; +type WireEventToolOutput = WireEventBase<'event.tool.output', { + tool_call_id: string; + chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventToolProgress = WireEventBase<'event.tool.progress', { + tool_call_id: string; + progress: number; + message?: string; +}>; +type WireEventToolCompleted = WireEventBase<'event.tool.completed', { + tool_call_id: string; + output: unknown; + is_error: boolean; + duration_ms: number; +}>; + +// Approval +type WireEventApprovalRequested = WireEventBase<'event.approval.requested', WireApprovalRequest>; +type WireEventApprovalResolved = WireEventBase<'event.approval.resolved', { + approval_id: string; + decision: 'approved' | 'rejected' | 'cancelled'; + scope?: 'session'; + feedback?: string; + selected_label?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventApprovalExpired = WireEventBase<'event.approval.expired', { approval_id: string }>; + +// Question +type WireEventQuestionRequested = WireEventBase<'event.question.requested', WireQuestionRequest>; +type WireEventQuestionAnswered = WireEventBase<'event.question.answered', { + question_id: string; + answers: Record; + method?: string; + note?: string; + resolved_by: string; + resolved_at: string; +}>; +type WireEventQuestionDismissed = WireEventBase<'event.question.dismissed', { + question_id: string; + dismissed_by: string; + dismissed_at: string; +}>; +type WireEventQuestionExpired = WireEventBase<'event.question.expired', { question_id: string }>; + +// Background tasks +type WireEventTaskCreated = WireEventBase<'event.task.created', { task: WireBackgroundTask }>; +type WireEventTaskProgress = WireEventBase<'event.task.progress', { + task_id: string; + output_chunk: string; + stream: 'stdout' | 'stderr'; +}>; +type WireEventTaskCompleted = WireEventBase<'event.task.completed', { + task_id: string; + status: WireTaskStatus; + output_preview?: string; + output_bytes?: number; +}>; + +/** Catch-all for unrecognised event frames — keeps lastSeq advancing without warnings */ +type WireEventUnknown = { type: string; seq: number; session_id: string; timestamp: string; payload: unknown }; + +/** + * Union of all WS event frames the client will process. + * Visible events (UI updates) + no-op-but-known events (lastSeq only). + * The catch-all at the end handles future server events gracefully. + */ +export type WireEvent = + // Session lifecycle + | WireEventSessionCreated + | WireEventSessionUpdated + | WireEventSessionDeleted + | WireEventSessionStatusChanged + | WireEventSessionUsageUpdated + | WireEventSessionHistoryCompacted + // Message lifecycle + | WireEventMessageCreated + | WireEventMessageUpdated + // Assistant streaming + | WireEventAssistantDelta + | WireEventAssistantToolUseStarted + | WireEventAssistantToolUseDelta + | WireEventAssistantToolUseCompleted + | WireEventAssistantCompleted + // Tool execution + | WireEventToolStarted + | WireEventToolOutput + | WireEventToolProgress + | WireEventToolCompleted + // Approval + | WireEventApprovalRequested + | WireEventApprovalResolved + | WireEventApprovalExpired + // Question + | WireEventQuestionRequested + | WireEventQuestionAnswered + | WireEventQuestionDismissed + | WireEventQuestionExpired + // Background tasks + | WireEventTaskCreated + | WireEventTaskProgress + | WireEventTaskCompleted + // Unknown / future events + | WireEventUnknown; diff --git a/apps/kimi-web/src/api/daemon/ws.ts b/apps/kimi-web/src/api/daemon/ws.ts new file mode 100644 index 000000000..26a62d989 --- /dev/null +++ b/apps/kimi-web/src/api/daemon/ws.ts @@ -0,0 +1,286 @@ +// apps/kimi-web/src/api/daemon/ws.ts +// DaemonEventSocket — browser WebSocket client for the daemon WS protocol. +// Handles: server_hello / client_hello handshake, subscribe/unsubscribe, +// ping/pong heartbeat, resync_required, error frames, event.* dispatch. + +import { classifyFrame } from './agentEventProjector'; +import type { WireEvent, WireServerFrame } from './wire'; + +// --------------------------------------------------------------------------- +// Handler interface +// --------------------------------------------------------------------------- + +export interface DaemonEventSocketHandlers { + /** Called for every event.* frame received */ + onWireEvent(event: WireEvent): void; + /** + * Called for raw agent-core frames (type does NOT start with "event." and + * is not a control frame). The full parsed frame object is passed so the + * caller can extract type / seq / session_id / timestamp / payload. + */ + onRawAgentEvent?(frame: { type: string; seq: number; session_id: string; timestamp: string; payload: unknown }): void; + /** Called when server says client is out of sync for a session */ + onResync(sessionId: string, currentSeq: number): void; + /** Called when the WS connection opens or closes */ + onConnectionState(connected: boolean): void; + /** Called on error frames or JSON parse failures */ + onError(code: number, msg: string, fatal: boolean): void; +} + +// --------------------------------------------------------------------------- +// DaemonEventSocket +// --------------------------------------------------------------------------- + +interface PendingSubscription { + sessionId: string; + lastSeq: number; +} + +export class DaemonEventSocket { + private ws: WebSocket | null = null; + private connected = false; + private closed = false; + + /** subscriptions we manage: sessionId → last known seq */ + private readonly subscriptions = new Map(); + + /** subscriptions queued while not yet connected */ + private readonly pendingSubscriptions: PendingSubscription[] = []; + + private msgSeq = 0; + + constructor( + private readonly wsUrl: string, + private readonly clientId: string, + private readonly handlers: DaemonEventSocketHandlers, + ) {} + + /** Open the WebSocket connection. Safe to call once. */ + connect(): void { + if (this.ws !== null || this.closed) return; + + const ws = new WebSocket(this.wsUrl); + this.ws = ws; + + ws.onopen = () => { + // Don't mark as connected yet — wait for server_hello + }; + + ws.onmessage = (ev: MessageEvent) => { + try { + const frame = JSON.parse(String(ev.data)) as WireServerFrame; + this.handleFrame(frame); + } catch (err) { + this.handlers.onError(0, `Failed to parse WS frame: ${String(err)}`, false); + } + }; + + ws.onerror = () => { + // The error details are not exposed by the browser WS API; the close + // event with a reason code follows immediately. + this.handlers.onError(0, 'WebSocket error', false); + }; + + ws.onclose = () => { + this.connected = false; + this.ws = null; + this.handlers.onConnectionState(false); + }; + } + + /** + * Subscribe to events for a session. + * If connected, sends immediately; otherwise queues until after server_hello. + */ + subscribe(sessionId: string, lastSeq = 0): void { + this.subscriptions.set(sessionId, lastSeq); + + if (this.connected) { + this.sendSubscribe([sessionId], { [sessionId]: lastSeq }); + } else { + // Remove any earlier pending entry for this session, then enqueue + const idx = this.pendingSubscriptions.findIndex((p) => p.sessionId === sessionId); + if (idx !== -1) this.pendingSubscriptions.splice(idx, 1); + this.pendingSubscriptions.push({ sessionId, lastSeq }); + } + } + + /** Unsubscribe from a session's events. */ + unsubscribe(sessionId: string): void { + this.subscriptions.delete(sessionId); + if (this.connected && this.ws) { + this.send({ + type: 'unsubscribe', + id: this.nextId(), + payload: { session_ids: [sessionId] }, + }); + } + } + + /** + * Send a WS abort control message for a prompt. + * (The REST :abort endpoint is the primary path; this is the WS path per spec.) + */ + abort(sessionId: string, promptId: string): void { + if (!this.connected || !this.ws) return; + this.send({ + type: 'abort', + id: this.nextId(), + payload: { session_id: sessionId, prompt_id: promptId }, + }); + } + + /** Close the socket. Stops reconnect attempts. */ + close(): void { + this.closed = true; + this.connected = false; + if (this.ws) { + this.ws.close(1000); + this.ws = null; + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private handleFrame(rawFrame: WireServerFrame): void { + // WireServerFrame union contains WireAck (payload: unknown) which prevents + // TypeScript from narrowing .payload in each case arm. Cast once here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const frame = rawFrame as any; + switch ((rawFrame as { type: string }).type) { + case 'server_hello': + this.onServerHello(); + break; + + case 'ping': + this.send({ type: 'pong', payload: { nonce: frame.payload.nonce } }); + break; + + case 'resync_required': + this.handlers.onResync(frame.payload.session_id, frame.payload.current_seq); + break; + + case 'error': { + // A session-scoped error (has top-level session_id) is a real agent-core + // 'error' event — e.g. a 403 from the model provider — whose message + // must surface in the conversation. A connection-level control error + // (no session_id) goes to onError. + const sid = (frame as { session_id?: unknown }).session_id; + if (typeof sid === 'string' && this.handlers.onRawAgentEvent) { + this.handlers.onRawAgentEvent({ + type: 'error', + seq: frame.seq, + session_id: sid, + timestamp: frame.timestamp, + payload: frame.payload, + }); + } else { + this.handlers.onError(frame.payload.code, frame.payload.msg, frame.payload.fatal); + } + break; + } + + case 'ack': + // ack frames are fire-and-forget for now (no request tracking) + break; + + default: { + // Classify the frame into protocol vs agent-core. Robust to all three + // shapes: raw agent-core, "event."-prefixed agent-core, and genuine + // projected "event.*" protocol events. See classifyFrame() for rules. + const type = (frame as { type: string }).type; + const decision = classifyFrame(type, (frame as { payload?: unknown }).payload); + + if (decision.route === 'protocol') { + // Genuine projected protocol event → existing toAppEvent() path. + this.handlers.onWireEvent(frame as unknown as WireEvent); + break; + } + + if (decision.route === 'agent') { + // Raw (or prefix-stripped) agent-core event → client-side projector. + // We pass the prefix-stripped agentType so the projector matches its + // raw case arms regardless of whether the wire frame carried "event.". + if ( + this.handlers.onRawAgentEvent && + typeof (frame as { session_id?: unknown }).session_id === 'string' + ) { + const f = frame as { + seq: number; + session_id: string; + timestamp: string; + payload: unknown; + }; + this.handlers.onRawAgentEvent({ + type: decision.agentType, + seq: f.seq, + session_id: f.session_id, + timestamp: f.timestamp, + payload: f.payload, + }); + } + break; + } + + // decision.route === 'ignore' (control-shaped or unroutable) → drop. + break; + } + } + } + + private onServerHello(): void { + this.connected = true; + this.handlers.onConnectionState(true); + + // Build the initial subscription list from current subscriptions + pending + const allSessionIds = Array.from(this.subscriptions.keys()); + // Drain pending: merge into subscriptions map (pending overrides if seq differs) + for (const p of this.pendingSubscriptions) { + this.subscriptions.set(p.sessionId, p.lastSeq); + if (!allSessionIds.includes(p.sessionId)) allSessionIds.push(p.sessionId); + } + this.pendingSubscriptions.length = 0; + + // Build last_seq_by_session from subscriptions + const lastSeqBySession: Record = {}; + for (const [sid, seq] of this.subscriptions.entries()) { + lastSeqBySession[sid] = seq; + } + + this.send({ + type: 'client_hello', + id: this.nextId(), + payload: { + client_id: this.clientId, + subscriptions: allSessionIds, + last_seq_by_session: lastSeqBySession, + }, + }); + } + + private sendSubscribe(sessionIds: string[], lastSeqBySession: Record): void { + this.send({ + type: 'subscribe', + id: this.nextId(), + payload: { + session_ids: sessionIds, + last_seq_by_session: lastSeqBySession, + }, + }); + } + + private send(msg: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(msg)); + } catch { + // Ignore send errors (socket closing races) + } + } + + private nextId(): string { + return `c_${++this.msgSeq}`; + } +} diff --git a/apps/kimi-web/src/api/errors.ts b/apps/kimi-web/src/api/errors.ts new file mode 100644 index 000000000..f5db40cfb --- /dev/null +++ b/apps/kimi-web/src/api/errors.ts @@ -0,0 +1,30 @@ +// apps/kimi-web/src/api/errors.ts +// DaemonApiError, DaemonNetworkError, and type guard. + +export class DaemonApiError extends Error { + readonly code: number; + readonly requestId: string; + readonly details: unknown; + + constructor(input: { code: number; msg: string; requestId: string; details?: unknown }) { + super(input.msg); + this.name = 'DaemonApiError'; + this.code = input.code; + this.requestId = input.requestId; + this.details = input.details; + } +} + +export class DaemonNetworkError extends Error { + readonly cause: unknown; + + constructor(message: string, cause: unknown) { + super(message); + this.name = 'DaemonNetworkError'; + this.cause = cause; + } +} + +export function isDaemonApiError(error: unknown): error is DaemonApiError { + return error instanceof DaemonApiError; +} diff --git a/apps/kimi-web/src/api/index.ts b/apps/kimi-web/src/api/index.ts new file mode 100644 index 000000000..3e2dffa2e --- /dev/null +++ b/apps/kimi-web/src/api/index.ts @@ -0,0 +1,13 @@ +// apps/kimi-web/src/api/index.ts +// Singleton factory for the KimiWebApi daemon client. + +import { readKimiApiConfig } from './config'; +import type { KimiWebApi } from './types'; +import { DaemonKimiWebApi } from './daemon/client'; + +let singleton: KimiWebApi | undefined; + +export function getKimiWebApi(): KimiWebApi { + singleton ??= new DaemonKimiWebApi(readKimiApiConfig()); + return singleton; +} diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts new file mode 100644 index 000000000..20bf38e7c --- /dev/null +++ b/apps/kimi-web/src/api/types.ts @@ -0,0 +1,419 @@ +// apps/kimi-web/src/api/types.ts +// App-facing camelCase model + KimiWebApi interface. +// No daemon wire details here — Vue components consume only these types. + +// --------------------------------------------------------------------------- +// Pagination +// --------------------------------------------------------------------------- + +export interface Page { + items: T[]; + hasMore: boolean; +} + +export interface PageRequest { + beforeId?: string; + afterId?: string; + pageSize?: number; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export type AppSessionStatus = + | 'idle' + | 'running' + | 'awaitingApproval' + | 'awaitingQuestion' + | 'aborted'; + +export interface AppSessionUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + totalCostUsd: number; + contextTokens: number; + contextLimit: number; + turnCount: number; +} + +export interface AppSession { + id: string; + title: string; + createdAt: string; + updatedAt: string; + status: AppSessionStatus; + currentPromptId?: string; + cwd: string; + model: string; + usage: AppSessionUsage; + messageCount: number; + lastSeq: number; + /** + * The workspace this session belongs to. Present once the daemon ships the + * workspace registry (returns `workspace_id` on Session). Until then it is + * undefined and the composable maps sessions to workspaces by cwd === root. + */ + workspaceId?: string; +} + +// --------------------------------------------------------------------------- +// Workspace — a real folder the client organizes sessions by. +// 1 Workspace : N Sessions. A session inherits the workspace's root as its cwd. +// --------------------------------------------------------------------------- + +export interface AppWorkspace { + /** Stable id. In fallback mode (derived from session cwds) this IS the root. */ + id: string; + /** Absolute path to the project root. */ + root: string; + /** Display name — defaults to basename(root), may be renamed on the daemon. */ + name: string; + /** Whether root is inside a git repository. */ + isGitRepo: boolean; + /** Current branch, when known. */ + branch?: string; + /** ISO timestamp of when this workspace was last opened. */ + lastOpenedAt?: string; + /** Number of sessions belonging to this workspace. */ + sessionCount: number; +} + +/** One directory entry from the daemon folder browser (fs:browse). */ +export interface FsBrowseEntry { + name: string; + path: string; + isDir: boolean; + isGitRepo: boolean; + branch?: string; +} + +export interface FsBrowseResult { + path: string; + parent: string | null; + entries: FsBrowseEntry[]; +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +export type AppMessageRole = 'user' | 'assistant' | 'tool' | 'system'; + +export type AppMessageContent = + | { type: 'text'; text: string } + | { type: 'toolUse'; toolCallId: string; toolName: string; input: unknown } + | { type: 'toolResult'; toolCallId: string; output: unknown; isError?: boolean } + | { type: 'image'; source: ImageSource } + | { type: 'file'; fileId: string; name: string; mediaType: string; size: number } + | { type: 'thinking'; thinking: string; signature?: string } + | { type: 'unknown'; raw: unknown }; + +export type ImageSource = + | { kind: 'url'; url: string } + | { kind: 'base64'; mediaType: string; data: string } + | { kind: 'file'; fileId: string }; + +export interface AppMessage { + id: string; + sessionId: string; + role: AppMessageRole; + content: AppMessageContent[]; + createdAt: string; + promptId?: string; + parentMessageId?: string; + metadata?: Record; +} + +// --------------------------------------------------------------------------- +// Prompt +// --------------------------------------------------------------------------- + +export type ThinkingLevel = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + +export interface PromptSubmission { + content: AppMessageContent[]; + metadata?: Record; + /** The daemon requires these on every prompt (per-prompt, not session-level). */ + model?: string; + thinking?: ThinkingLevel; + permissionMode?: 'manual' | 'auto' | 'yolo'; + planMode?: boolean; +} + +export interface PromptSubmitResult { + promptId: string; + userMessageId: string; +} + +// --------------------------------------------------------------------------- +// Approval +// --------------------------------------------------------------------------- + +export type ApprovalDecision = 'approved' | 'rejected' | 'cancelled'; + +export interface ApprovalResponse { + decision: ApprovalDecision; + scope?: 'session'; + feedback?: string; + selectedLabel?: string; +} + +export interface AppApprovalRequest { + approvalId: string; + sessionId: string; + turnId?: number; + toolCallId: string; + toolName: string; + action: string; + display: unknown; // ToolInputDisplay — Web renders what it knows, falls back to generic + expiresAt: string; + createdAt: string; +} + +// --------------------------------------------------------------------------- +// Question +// --------------------------------------------------------------------------- + +export interface QuestionOption { + id: string; + label: string; + description?: string; +} + +export interface QuestionItem { + id: string; + question: string; + header?: string; + body?: string; + options: QuestionOption[]; + multiSelect?: boolean; + allowOther?: boolean; + otherLabel?: string; + otherDescription?: string; +} + +export interface AppQuestionRequest { + questionId: string; + sessionId: string; + turnId?: number; + toolCallId?: string; + questions: QuestionItem[]; + expiresAt: string; + createdAt: string; +} + +export type QuestionAnswer = + | { kind: 'single'; optionId: string } + | { kind: 'multi'; optionIds: string[] } + | { kind: 'other'; text: string } + | { kind: 'multiWithOther'; optionIds: string[]; otherText: string } + | { kind: 'skipped' }; + +export interface QuestionResponse { + answers: Record; + method?: 'enter' | 'space' | 'number_key' | 'click'; + note?: string; +} + +// --------------------------------------------------------------------------- +// Background Task +// --------------------------------------------------------------------------- + +export type AppTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface AppTask { + id: string; + sessionId: string; + kind: 'subagent' | 'bash' | 'tool'; + description: string; + status: AppTaskStatus; + createdAt: string; + startedAt?: string; + completedAt?: string; + outputPreview?: string; + outputBytes?: number; + outputLines?: string[]; // accumulated by eventReducer from task.progress chunks +} + +// --------------------------------------------------------------------------- +// File System +// --------------------------------------------------------------------------- + +export type FsKind = 'file' | 'directory' | 'symlink'; + +export interface FsEntry { + path: string; + name: string; + kind: FsKind; + size?: number; + modifiedAt: string; + etag?: string; + mime?: string; + languageId?: string; + isBinary?: boolean; + isSymlinkTo?: string; + gitStatus?: string; + childCount?: number; +} + +// --------------------------------------------------------------------------- +// Events (app-facing, camelCase) +// --------------------------------------------------------------------------- + +export type AppEvent = + | { type: 'sessionCreated'; session: AppSession } + | { type: 'sessionUpdated'; session: AppSession; changedFields: string[] } + | { type: 'sessionDeleted'; sessionId: string } + | { type: 'sessionStatusChanged'; sessionId: string; status: AppSessionStatus; previousStatus: AppSessionStatus; currentPromptId?: string } + | { type: 'sessionMetaUpdated'; sessionId: string; title: string } + | { type: 'sessionUsageUpdated'; sessionId: string; usage: AppSessionUsage; model?: string } + | { type: 'historyCompacted'; sessionId: string; beforeSeq: number; reason: string; summaryMessageId?: string } + | { type: 'messageCreated'; message: AppMessage } + | { type: 'messageUpdated'; sessionId: string; messageId: string; content: AppMessageContent[]; status: 'pending' | 'completed' | 'error' } + | { type: 'assistantDelta'; sessionId: string; messageId: string; contentIndex: number; delta: { text?: string; thinking?: string } } + | { type: 'approvalRequested'; sessionId: string; approval: AppApprovalRequest } + | { type: 'approvalResolved'; sessionId: string; approvalId: string; decision: ApprovalDecision; resolvedAt: string } + | { type: 'approvalExpired'; sessionId: string; approvalId: string } + | { type: 'questionRequested'; sessionId: string; question: AppQuestionRequest } + | { type: 'questionAnswered'; sessionId: string; questionId: string; resolvedAt: string } + | { type: 'questionDismissed'; sessionId: string; questionId: string; dismissedAt: string } + | { type: 'questionExpired'; sessionId: string; questionId: string } + | { type: 'taskCreated'; sessionId: string; task: AppTask } + | { type: 'taskProgress'; sessionId: string; taskId: string; outputChunk: string; stream: 'stdout' | 'stderr' } + | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + | { type: 'unknown'; raw: unknown }; + +// --------------------------------------------------------------------------- +// WebSocket connection helpers +// --------------------------------------------------------------------------- + +export interface KimiEventHandlers { + onEvent(event: AppEvent, meta: { sessionId: string; seq: number }): void; + onResync(sessionId: string, currentSeq: number): void; + onError(code: number, msg: string, fatal: boolean): void; + onConnectionChange(connected: boolean): void; +} + +export interface KimiEventConnection { + subscribe(sessionId: string, lastSeq?: number): void; + unsubscribe(sessionId: string): void; + /** + * Bind the real daemon prompt_id to the next turn for a session, so the + * client-side projector stops synthesizing a random promptId on turn.started. + * Call right after submitPrompt() returns. + */ + bindNextPromptId(sessionId: string, promptId: string): void; + abort(sessionId: string, promptId: string): void; + close(): void; +} + +// --------------------------------------------------------------------------- +// Model + Provider (app-facing, camelCase) +// PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. +// --------------------------------------------------------------------------- + +export interface AppModel { + /** Unique identifier for this model (the string passed to PATCH session agent_config.model) */ + id: string; + /** Provider id this model belongs to */ + provider: string; + /** Raw model name (e.g. "moonshot-v1-128k") */ + model: string; + /** Optional human-readable display name */ + displayName?: string; + /** Maximum context size in tokens */ + maxContextSize: number; + /** Optional capability tags (e.g. ["vision", "thinking"]) */ + capabilities?: string[]; +} + +export interface AppProvider { + /** Provider id */ + id: string; + /** Provider type (e.g. "moonshot", "anthropic", "openai", "custom") */ + type: string; + /** Optional custom base URL */ + baseUrl?: string; + /** Optional default model alias */ + defaultModel?: string; + /** Whether an API key is stored for this provider */ + hasApiKey: boolean; + /** Provider connectivity status */ + status: 'connected' | 'error' | 'unconfigured'; + /** Model ids available from this provider */ + models?: string[]; +} + +// --------------------------------------------------------------------------- +// KimiWebApi — the app-facing interface +// --------------------------------------------------------------------------- + +export interface KimiWebApi { + getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>; + getMeta(): Promise<{ daemonVersion: string; serverId: string; startedAt: string; capabilities: Record }>; + listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string }): Promise>; + createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise; + updateSession(sessionId: string, input: { title?: string; cwd?: string; model?: string }): Promise; + deleteSession(sessionId: string): Promise<{ deleted: true }>; + listMessages(sessionId: string, input?: PageRequest & { role?: AppMessageRole }): Promise>; + submitPrompt(sessionId: string, input: PromptSubmission): Promise; + abortPrompt(sessionId: string, promptId: string): Promise<{ aborted: boolean; atSeq?: number }>; + respondApproval(sessionId: string, approvalId: string, response: ApprovalResponse): Promise<{ resolved: true; resolvedAt: string }>; + respondQuestion(sessionId: string, questionId: string, response: QuestionResponse): Promise<{ resolved: true; resolvedAt: string }>; + dismissQuestion(sessionId: string, questionId: string): Promise<{ dismissed: true; dismissedAt: string }>; + listTasks(sessionId: string, status?: AppTaskStatus): Promise; + getTask(sessionId: string, taskId: string, input?: { withOutput?: boolean; outputBytes?: number }): Promise; + cancelTask(sessionId: string, taskId: string): Promise<{ cancelled: true }>; + listDirectory(sessionId: string, input: { path?: string; depth?: number; includeGitStatus?: boolean }): Promise<{ items: FsEntry[]; childrenByPath?: Record; truncated: boolean }>; + readFile(sessionId: string, input: { path: string; offset?: number; length?: number }): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; size: number; truncated: boolean; etag: string; mime: string; languageId?: string; lineCount?: number; isBinary: boolean }>; + searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>; + grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>; + getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record }>; + connectEvents(handlers: KimiEventHandlers): KimiEventConnection; + + // Workspaces + daemon folder browser + // PRESUMED — falls back until the daemon ships /workspaces, /fs:browse, /fs:home. + listWorkspaces(): Promise; + addWorkspace(input: { root: string; name?: string }): Promise; + browseFs(path?: string): Promise; + getFsHome(): Promise<{ home: string; recentRoots: string[] }>; + + // PRESUMED — not in current daemon docs; isolated in adapter, swap when backend defines them. + listModels(): Promise; + listProviders(): Promise; + addProvider(input: { type: string; apiKey?: string; baseUrl?: string; defaultModel?: string }): Promise; + deleteProvider(id: string): Promise<{ deleted: true }>; + refreshProvider(id: string): Promise; + + // File upload + uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>; + + // Auth — REAL endpoints + getAuth(): Promise<{ + ready: boolean; + providersCount: number; + defaultModel: string | null; + managedProvider: { status: string } | null; + }>; + startOAuthLogin(): Promise<{ + flowId: string; + provider: string; + verificationUri: string; + verificationUriComplete: string; + userCode: string; + expiresIn: number; + interval: number; + status: 'pending'; + expiresAt: string; + }>; + pollOAuthLogin(): Promise<{ + flowId: string; + status: 'pending' | 'authenticated' | 'expired' | 'cancelled'; + resolvedAt?: string; + } | null>; + cancelOAuthLogin(): Promise<{ cancelled: boolean; status: string }>; + logout(): Promise<{ loggedOut: boolean }>; +} diff --git a/apps/kimi-web/src/components/AddWorkspaceDialog.vue b/apps/kimi-web/src/components/AddWorkspaceDialog.vue new file mode 100644 index 000000000..050ef1dc8 --- /dev/null +++ b/apps/kimi-web/src/components/AddWorkspaceDialog.vue @@ -0,0 +1,491 @@ + + + + + + + + + + + diff --git a/apps/kimi-web/src/components/ApprovalCard.vue b/apps/kimi-web/src/components/ApprovalCard.vue new file mode 100644 index 000000000..b4c3b8c56 --- /dev/null +++ b/apps/kimi-web/src/components/ApprovalCard.vue @@ -0,0 +1,369 @@ + + + +