mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(server)!: rename daemon to server with service management
- rename workspace package `@moonshot-ai/daemon` to `@moonshot-ai/server`\n- rename `@moonshot-ai/daemon-e2e` to `@moonshot-ai/server-e2e`\n- replace `kimi daemon` and `kimi web` with `kimi server run`\n- keep `kimi web` as alias for `kimi server run --open`\n- add `kimi server install/uninstall/start/stop/restart/status`\n for launchd (darwin), systemd (linux), and schtasks (win32)\n- update dev scripts, kimi-web stub, documentation, and service naming checks\n\nBREAKING CHANGE: the `kimi daemon` command and `@moonshot-ai/daemon`\npackage are removed; use `kimi server` and `@moonshot-ai/server`.
This commit is contained in:
parent
fca2abe4b1
commit
9770f22c93
180 changed files with 4305 additions and 2352 deletions
|
|
@ -35,6 +35,8 @@
|
|||
"type": "module",
|
||||
"imports": {
|
||||
"#/tui/theme": "./src/tui/theme/index.ts",
|
||||
"#/cli/sub/server": "./src/cli/sub/server/index.ts",
|
||||
"#/cli/sub/server/*": "./src/cli/sub/server/*.ts",
|
||||
"#/*": [
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
|
|
@ -57,8 +59,8 @@
|
|||
"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 --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon --foreground",
|
||||
"dev:daemon:restart": "node scripts/dev-daemon-restart.mjs",
|
||||
"dev:server": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts server run",
|
||||
"dev:server:restart": "node scripts/dev-server-restart.mjs",
|
||||
"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",
|
||||
|
|
@ -82,7 +84,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@moonshot-ai/acp-adapter": "workspace:^",
|
||||
"@moonshot-ai/daemon": "workspace:^",
|
||||
"@moonshot-ai/server": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-sdk": "workspace:^",
|
||||
"@moonshot-ai/kimi-telemetry": "workspace:^",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
#!/usr/bin/env node
|
||||
// Press-Enter-to-restart wrapper for the daemon. No file watcher.
|
||||
// Press-Enter-to-restart wrapper for the local server. No file watcher.
|
||||
//
|
||||
// Spawns `tsx ./src/main.ts daemon …extraArgs` once, then on each newline read
|
||||
// from stdin SIGTERMs the child and respawns after it has cleanly exited.
|
||||
// SIGTERM triggers the daemon's own `shutdown()` (apps/kimi-code/src/cli/sub/daemon.ts)
|
||||
// which releases the port lock + closes WS conns before exit, so a fresh
|
||||
// start can re-acquire 7878 without a stale-lock fight.
|
||||
// Spawns `tsx ./src/main.ts server run …extraArgs` once, then on each newline
|
||||
// read from stdin SIGTERMs the child and respawns after it has cleanly exited.
|
||||
// SIGTERM triggers the server's own `shutdown()` handler
|
||||
// (apps/kimi-code/src/cli/sub/server/run.ts) which releases the port lock and
|
||||
// closes WS conns before exit, so a fresh start can re-acquire 7878 without a
|
||||
// stale-lock fight.
|
||||
//
|
||||
// CLI args after `--` (or any extras) are passed straight through, so:
|
||||
// pnpm dev:daemon:restart -- --host 0.0.0.0 --port 7878 --log-level debug
|
||||
// is equivalent to the dev:daemon script with that arg list, but with the
|
||||
// restart loop on top.
|
||||
// pnpm dev:server:restart -- --host 0.0.0.0 --port 7878 --log-level debug
|
||||
// is equivalent to `pnpm dev:server` with that arg list, but with the restart
|
||||
// loop on top.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
|
@ -30,7 +31,8 @@ const tsxArgs = [
|
|||
'--import',
|
||||
'../../build/register-raw-text-loader.mjs',
|
||||
'./src/main.ts',
|
||||
'daemon',
|
||||
'server',
|
||||
'run',
|
||||
...cliArgs,
|
||||
];
|
||||
|
||||
|
|
@ -40,16 +42,16 @@ let shuttingDown = false;
|
|||
let killTimer = null;
|
||||
|
||||
function start() {
|
||||
console.error('[dev:daemon:restart] starting daemon…');
|
||||
console.error('[dev:server:restart] starting server…');
|
||||
child = spawn(tsxBin, tsxArgs, {
|
||||
cwd: APP_ROOT,
|
||||
env: process.env,
|
||||
// Daemon does not read stdin; keep ours free for the Enter trigger.
|
||||
// Server does not read stdin; keep ours free for the Enter trigger.
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`[dev:daemon:restart] spawn error: ${err.message}`);
|
||||
console.error(`[dev:server:restart] spawn error: ${err.message}`);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
|
|
@ -68,11 +70,11 @@ function start() {
|
|||
start();
|
||||
return;
|
||||
}
|
||||
// Daemon died on its own (port conflict, runtime error, etc.). Stay alive
|
||||
// Server died on its own (port conflict, runtime error, etc.). Stay alive
|
||||
// so the user can fix the issue and press Enter to retry.
|
||||
const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
|
||||
console.error(
|
||||
`[dev:daemon:restart] daemon exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
|
||||
`[dev:server:restart] server exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
|
||||
);
|
||||
void prev; // silence unused warning
|
||||
});
|
||||
|
|
@ -87,13 +89,13 @@ function restart() {
|
|||
}
|
||||
if (restarting) return; // debounce — multiple Enters during shutdown collapse
|
||||
restarting = true;
|
||||
console.error('[dev:daemon:restart] restarting…');
|
||||
console.error('[dev:server:restart] restarting…');
|
||||
child.kill('SIGTERM');
|
||||
// Safety net: if the child ignores SIGTERM, force-kill after 5s so the
|
||||
// restart loop doesn't wedge.
|
||||
killTimer = setTimeout(() => {
|
||||
if (child !== null && child.exitCode === null && child.signalCode === null) {
|
||||
console.error('[dev:daemon:restart] SIGTERM timed out, sending SIGKILL');
|
||||
console.error('[dev:server:restart] SIGTERM timed out, sending SIGKILL');
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
|
@ -113,7 +115,7 @@ const onShutdownSignal = (signal) => {
|
|||
shuttingDown = true;
|
||||
if (child !== null) {
|
||||
child.kill(signal);
|
||||
// Give the daemon a moment to flush logs / release the lock.
|
||||
// Give the server a moment to flush logs / release the lock.
|
||||
setTimeout(() => process.exit(0), 1000).unref();
|
||||
} else {
|
||||
process.exit(0);
|
||||
|
|
@ -5,11 +5,10 @@ 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';
|
||||
import { registerWebCommand } from './sub/web';
|
||||
import { registerServerCommand } from './sub/server';
|
||||
|
||||
export type MainCommandHandler = (opts: CLIOptions) => void;
|
||||
export type MigrateCommandHandler = () => void;
|
||||
|
|
@ -80,8 +79,7 @@ export function createProgram(
|
|||
registerExportCommand(program);
|
||||
registerProviderCommand(program);
|
||||
registerAcpCommand(program);
|
||||
registerDaemonCommand(program);
|
||||
registerWebCommand(program);
|
||||
registerServerCommand(program);
|
||||
registerLoginCommand(program);
|
||||
registerDoctorCommand(program);
|
||||
registerMigrateCommand(program, onMigrate);
|
||||
|
|
|
|||
|
|
@ -1,535 +0,0 @@
|
|||
/**
|
||||
* `kimi daemon` sub-command.
|
||||
*
|
||||
* Default mode starts the daemon as a detached background process and exits
|
||||
* after the daemon becomes healthy. `--foreground` keeps the daemon attached
|
||||
* to the current terminal.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
DaemonLockedError,
|
||||
DEFAULT_LOCK_PATH,
|
||||
startDaemon,
|
||||
type DaemonLogLevel,
|
||||
} from '@moonshot-ai/daemon';
|
||||
import { resolveKimiHome } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../version';
|
||||
|
||||
export const DEFAULT_DAEMON_HOST = '127.0.0.1';
|
||||
export const DEFAULT_DAEMON_PORT = 7878;
|
||||
export const DEFAULT_DAEMON_ORIGIN = daemonOrigin(DEFAULT_DAEMON_HOST, DEFAULT_DAEMON_PORT);
|
||||
const DEFAULT_LOG_LEVEL: DaemonLogLevel = 'info';
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
const VALID_LOG_LEVELS: readonly DaemonLogLevel[] = [
|
||||
'fatal',
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'debug',
|
||||
'trace',
|
||||
'silent',
|
||||
];
|
||||
|
||||
export interface DaemonCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
logLevel?: string;
|
||||
debugEndpoints?: boolean;
|
||||
foreground?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedDaemonOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: DaemonLogLevel;
|
||||
debugEndpoints: boolean;
|
||||
/** Stop a running managed daemon and start a fresh one with this CLI build. */
|
||||
restart: boolean;
|
||||
}
|
||||
|
||||
export type EnsureDaemonResult =
|
||||
| {
|
||||
status: 'already-running';
|
||||
origin: string;
|
||||
pid?: number;
|
||||
logPath?: string;
|
||||
}
|
||||
| {
|
||||
status: 'started';
|
||||
origin: string;
|
||||
pid: number;
|
||||
logPath: string;
|
||||
};
|
||||
|
||||
export type DaemonStartupReporter = (message: string) => void;
|
||||
|
||||
export interface EnsureDaemonRunningDeps {
|
||||
isDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean>;
|
||||
waitForDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean>;
|
||||
readLiveDaemonLock(): DaemonLockContents | undefined;
|
||||
startDaemonBackground(options: ParsedDaemonOptions): { pid: number; logPath: string };
|
||||
daemonLogPath(): string;
|
||||
/** Identity of the CLI build that is executing right now. */
|
||||
currentBuild(): BuildIdentity;
|
||||
/** SIGTERM the daemon and wait for the pid to exit. Resolves false on timeout/EPERM. */
|
||||
stopDaemon(pid: number): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** What identifies "the same build": the host version PLUS the entry path —
|
||||
two pkg.pr.new installs of the same base version live at different paths. */
|
||||
export interface BuildIdentity {
|
||||
version: string;
|
||||
entry: string | undefined;
|
||||
}
|
||||
|
||||
export interface DaemonCommandDeps {
|
||||
ensureDaemonRunning(
|
||||
options: ParsedDaemonOptions,
|
||||
report?: DaemonStartupReporter,
|
||||
): Promise<EnsureDaemonResult>;
|
||||
startDaemonForeground(options: ParsedDaemonOptions): Promise<void>;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
export function registerDaemonCommand(parent: Command): void {
|
||||
parent
|
||||
.command('daemon')
|
||||
.description('Run the local kimi-code daemon (REST + WebSocket).')
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host for the daemon (default ${DEFAULT_DAEMON_HOST})`,
|
||||
DEFAULT_DAEMON_HOST,
|
||||
)
|
||||
.option(
|
||||
'--port <port>',
|
||||
`Bind port for the daemon (default ${DEFAULT_DAEMON_PORT})`,
|
||||
String(DEFAULT_DAEMON_PORT),
|
||||
)
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
)
|
||||
.option(
|
||||
'--foreground',
|
||||
'Run the daemon in the foreground instead of starting a background daemon.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--restart',
|
||||
'Stop the running daemon (if this CLI manages it) and start a fresh one with this build.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--debug-endpoints',
|
||||
'Mount /api/v1/debug/* routes for test introspection (per-session shadow + dispatch log). OFF by default; production callers leave this unset.',
|
||||
false,
|
||||
)
|
||||
.action(async (opts: DaemonCliOptions) => {
|
||||
try {
|
||||
await handleDaemonCommand(opts);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDaemonCommand(
|
||||
opts: DaemonCliOptions,
|
||||
deps: DaemonCommandDeps = DEFAULT_DAEMON_COMMAND_DEPS,
|
||||
): Promise<void> {
|
||||
const parsed = parseDaemonOptions(opts);
|
||||
if (opts.foreground === true) {
|
||||
try {
|
||||
await deps.startDaemonForeground(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonLockedError) {
|
||||
deps.stdout.write(formatAlreadyRunning(error.existing.port, error.existing.pid));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Trace lines are diagnostics → stderr; only the final result lines
|
||||
// ("Kimi daemon started/already running …") belong on stdout.
|
||||
const result = await deps.ensureDaemonRunning(parsed, (message) => {
|
||||
writeDaemonStartupStatus(deps.stderr, message);
|
||||
});
|
||||
if (result.status === 'already-running') {
|
||||
deps.stdout.write(
|
||||
`Kimi daemon already running at ${result.origin}${formatPid(result.pid)}.\n`,
|
||||
);
|
||||
if (result.logPath !== undefined) {
|
||||
deps.stdout.write(`Logs: ${result.logPath}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.stdout.write(
|
||||
`Kimi daemon started in background at ${result.origin} (pid ${result.pid}).\n`,
|
||||
);
|
||||
deps.stdout.write(`Logs: ${result.logPath}\n`);
|
||||
}
|
||||
|
||||
export function parseDaemonOptions(opts: DaemonCliOptions): ParsedDaemonOptions {
|
||||
return {
|
||||
host: opts.host ?? DEFAULT_DAEMON_HOST,
|
||||
port: parsePort(opts.port, '--port', DEFAULT_DAEMON_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel),
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
restart: opts.restart === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePort(raw: string | undefined, label: string, fallback: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
||||
throw new Error(`error: invalid ${label} value: ${raw}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
throw new Error(
|
||||
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureDaemonRunning(
|
||||
options: ParsedDaemonOptions,
|
||||
deps: EnsureDaemonRunningDeps = DEFAULT_ENSURE_DAEMON_RUNNING_DEPS,
|
||||
report: DaemonStartupReporter = () => {},
|
||||
): Promise<EnsureDaemonResult> {
|
||||
const origin = daemonOrigin(options.host, options.port);
|
||||
report(`checking requested daemon at ${origin}`);
|
||||
if (await deps.isDaemonHealthy(origin, 1000)) {
|
||||
const lock = deps.readLiveDaemonLock();
|
||||
// Only a lock recorded for THIS port describes the daemon we just probed —
|
||||
// a foreign healthy process on the port (or a daemon on another port
|
||||
// holding the lock) is not ours to manage, so use it as-is.
|
||||
const ownedLock = lock !== undefined && lock.port === options.port ? lock : undefined;
|
||||
const reuse = (): EnsureDaemonResult => ({
|
||||
status: 'already-running',
|
||||
origin,
|
||||
pid: ownedLock?.pid,
|
||||
logPath: deps.daemonLogPath(),
|
||||
});
|
||||
if (ownedLock === undefined) {
|
||||
if (options.restart) {
|
||||
report(`--restart: daemon at ${origin} is not managed by this CLI (no matching lock); using it as-is`);
|
||||
}
|
||||
report(`requested daemon is healthy; using ${origin}`);
|
||||
return reuse();
|
||||
}
|
||||
if (!options.restart) {
|
||||
// A daemon from another build keeps serving its OWN bundled web UI/API —
|
||||
// surface the mismatch so the user knows the running daemon lags the CLI
|
||||
// they just invoked, but never replace it without an explicit --restart.
|
||||
if (!lockMatchesBuild(ownedLock, deps.currentBuild())) {
|
||||
report(describeBuildMismatch(ownedLock, deps.currentBuild()));
|
||||
}
|
||||
report(`requested daemon is healthy; using ${origin}`);
|
||||
return reuse();
|
||||
}
|
||||
report(`--restart: stopping daemon (pid ${ownedLock.pid})`);
|
||||
if (!(await deps.stopDaemon(ownedLock.pid))) {
|
||||
report(`could not stop daemon (pid ${ownedLock.pid}); using ${origin} as-is`);
|
||||
return reuse();
|
||||
}
|
||||
report(`stopped daemon (pid ${ownedLock.pid})`);
|
||||
} else {
|
||||
report('requested daemon is not healthy');
|
||||
report(`checking daemon lock at ${DEFAULT_LOCK_PATH}`);
|
||||
const lock = deps.readLiveDaemonLock();
|
||||
if (lock !== undefined) {
|
||||
report(
|
||||
`found live daemon lock (pid ${lock.pid}, port ${lock.port}, started ${lock.started_at})`,
|
||||
);
|
||||
const lockOrigin = daemonOrigin(options.host, lock.port);
|
||||
report(`checking locked daemon at ${lockOrigin}`);
|
||||
if (await deps.waitForDaemonHealthy(lockOrigin, 5000)) {
|
||||
const reuseLocked = (): EnsureDaemonResult => ({
|
||||
status: 'already-running',
|
||||
origin: lockOrigin,
|
||||
pid: lock.pid,
|
||||
logPath: deps.daemonLogPath(),
|
||||
});
|
||||
if (!options.restart) {
|
||||
if (!lockMatchesBuild(lock, deps.currentBuild())) {
|
||||
report(describeBuildMismatch(lock, deps.currentBuild()));
|
||||
}
|
||||
report(`locked daemon is healthy; reusing ${lockOrigin}`);
|
||||
return reuseLocked();
|
||||
}
|
||||
report(`--restart: stopping daemon (pid ${lock.pid})`);
|
||||
if (!(await deps.stopDaemon(lock.pid))) {
|
||||
report(`could not stop daemon (pid ${lock.pid}); using ${lockOrigin} as-is`);
|
||||
return reuseLocked();
|
||||
}
|
||||
report(`stopped daemon (pid ${lock.pid})`);
|
||||
} else {
|
||||
report(`locked daemon did not become healthy at ${lockOrigin}`);
|
||||
}
|
||||
} else {
|
||||
report('no live daemon lock found');
|
||||
}
|
||||
}
|
||||
|
||||
report(`starting daemon in background at ${origin}`);
|
||||
const started = deps.startDaemonBackground(options);
|
||||
const ready = await deps.waitForDaemonHealthy(origin, 15_000);
|
||||
if (!ready) {
|
||||
report(`daemon did not become healthy at ${origin}`);
|
||||
throw new Error(
|
||||
`Kimi daemon did not become healthy at ${origin}. Check logs: ${started.logPath}`,
|
||||
);
|
||||
}
|
||||
report(`daemon is healthy at ${origin}`);
|
||||
return {
|
||||
status: 'started',
|
||||
origin,
|
||||
pid: started.pid,
|
||||
logPath: started.logPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startDaemonForeground(options: ParsedDaemonOptions): Promise<void> {
|
||||
const version = getVersion();
|
||||
const running = await startDaemon({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
webAssetsDir: daemonWebAssetsDir(),
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
const handleSignal = (signal: NodeJS.Signals): void => {
|
||||
void shutdown(signal);
|
||||
};
|
||||
process.once('SIGINT', handleSignal);
|
||||
process.once('SIGTERM', handleSignal);
|
||||
}
|
||||
|
||||
export function daemonOrigin(host: string, port: number): string {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
function startDaemonBackground(options: ParsedDaemonOptions): { pid: number; logPath: string } {
|
||||
const entry = process.argv[1];
|
||||
if (entry === undefined || entry.length === 0) {
|
||||
throw new Error('Cannot start daemon: current CLI entry path is unavailable.');
|
||||
}
|
||||
|
||||
const logPath = daemonLogPath();
|
||||
mkdirSync(dirname(logPath), { recursive: true });
|
||||
const logFd = openSync(logPath, 'a');
|
||||
const args = [
|
||||
...process.execArgv,
|
||||
entry,
|
||||
'daemon',
|
||||
'--foreground',
|
||||
'--host',
|
||||
options.host,
|
||||
'--port',
|
||||
String(options.port),
|
||||
'--log-level',
|
||||
options.logLevel,
|
||||
];
|
||||
if (options.debugEndpoints) {
|
||||
args.push('--debug-endpoints');
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
return { pid: child.pid ?? -1, logPath };
|
||||
} finally {
|
||||
closeSync(logFd);
|
||||
}
|
||||
}
|
||||
|
||||
function daemonLogPath(): string {
|
||||
return join(resolveKimiHome(), 'daemon', 'daemon.log');
|
||||
}
|
||||
|
||||
function daemonWebAssetsDir(): string {
|
||||
return join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
async function waitForDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await isDaemonHealthy(origin, 500)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
} while (Date.now() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isDaemonHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${origin}/api/v1/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = (await response.json()) as { code?: unknown };
|
||||
return body.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DaemonLockContents {
|
||||
pid: number;
|
||||
started_at: string;
|
||||
port: number;
|
||||
/** Host CLI version that started the daemon (absent in locks from older builds). */
|
||||
host_version?: string;
|
||||
/** CLI entry path that spawned the daemon (absent in locks from older builds). */
|
||||
entry?: string;
|
||||
}
|
||||
|
||||
/** True when the locked daemon was started by the SAME build as this CLI.
|
||||
Locks from older builds lack the identity fields → mismatch → restart. */
|
||||
function lockMatchesBuild(lock: DaemonLockContents, build: BuildIdentity): boolean {
|
||||
return lock.host_version === build.version && lock.entry === build.entry;
|
||||
}
|
||||
|
||||
function describeBuildMismatch(lock: DaemonLockContents, build: BuildIdentity): string {
|
||||
const running = lock.host_version ?? 'unknown build';
|
||||
if (running !== build.version) {
|
||||
return `running daemon is a different build (running ${running}, current ${build.version}); rerun with --restart to replace it`;
|
||||
}
|
||||
return `running daemon is a different install of ${running} (${lock.entry ?? 'unknown entry'}); rerun with --restart to replace it`;
|
||||
}
|
||||
|
||||
function currentBuild(): BuildIdentity {
|
||||
return { version: getVersion(), entry: process.argv[1] };
|
||||
}
|
||||
|
||||
/** SIGTERM the daemon and wait (≤5s) for the pid to exit. The daemon's signal
|
||||
handler closes the server and releases the lock file, so a fresh background
|
||||
start can take over cleanly. */
|
||||
async function stopDaemonProcess(pid: number): Promise<boolean> {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
// ESRCH: already gone — that's a successful stop. EPERM etc.: not ours to kill.
|
||||
return (error as NodeJS.ErrnoException).code === 'ESRCH';
|
||||
}
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
if (!pidAlive(pid)) return true;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readLiveDaemonLock(): DaemonLockContents | undefined {
|
||||
if (!existsSync(DEFAULT_LOCK_PATH)) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(DEFAULT_LOCK_PATH, 'utf-8')) as unknown;
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as DaemonLockContents).pid !== 'number' ||
|
||||
typeof (parsed as DaemonLockContents).port !== 'number' ||
|
||||
typeof (parsed as DaemonLockContents).started_at !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const lock = parsed as DaemonLockContents;
|
||||
return pidAlive(lock.pid) ? lock : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlreadyRunning(port: number, pid: number): string {
|
||||
return `Kimi daemon already running at ${daemonOrigin(DEFAULT_DAEMON_HOST, port)} (pid ${pid}).\n`;
|
||||
}
|
||||
|
||||
function formatPid(pid: number | undefined): string {
|
||||
return pid === undefined ? '' : ` (pid ${pid})`;
|
||||
}
|
||||
|
||||
function writeDaemonStartupStatus(
|
||||
stream: Pick<NodeJS.WriteStream, 'write'>,
|
||||
message: string,
|
||||
): void {
|
||||
stream.write(`Daemon startup: ${message}\n`);
|
||||
}
|
||||
|
||||
const DEFAULT_DAEMON_COMMAND_DEPS: DaemonCommandDeps = {
|
||||
ensureDaemonRunning: (options, report) =>
|
||||
ensureDaemonRunning(options, DEFAULT_ENSURE_DAEMON_RUNNING_DEPS, report),
|
||||
startDaemonForeground,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
||||
const DEFAULT_ENSURE_DAEMON_RUNNING_DEPS: EnsureDaemonRunningDeps = {
|
||||
isDaemonHealthy,
|
||||
waitForDaemonHealthy,
|
||||
readLiveDaemonLock,
|
||||
startDaemonBackground,
|
||||
daemonLogPath,
|
||||
currentBuild,
|
||||
stopDaemon: stopDaemonProcess,
|
||||
};
|
||||
31
apps/kimi-code/src/cli/sub/server/index.ts
Normal file
31
apps/kimi-code/src/cli/sub/server/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* `kimi server` parent command. Mounts:
|
||||
* - `server run` (foreground)
|
||||
* - `server install/uninstall/start/stop/restart/status` (OS service)
|
||||
*
|
||||
* The top-level `kimi web` alias is registered separately via
|
||||
* `registerWebAliasCommand` so it stays at the program root.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { addLifecycleCommands } from './lifecycle';
|
||||
import { buildRunCommand } from './run';
|
||||
import { registerWebAliasCommand } from './web-alias';
|
||||
|
||||
export function registerServerCommand(program: Command): void {
|
||||
const server = program
|
||||
.command('server')
|
||||
.description('Run, install, and manage the local Kimi server (REST + WebSocket + web UI).');
|
||||
|
||||
buildRunCommand(
|
||||
server.command('run').description('Run the Kimi server in the foreground.'),
|
||||
{ defaultOpen: false },
|
||||
);
|
||||
|
||||
addLifecycleCommands(server);
|
||||
|
||||
registerWebAliasCommand(program);
|
||||
}
|
||||
|
||||
export { registerWebAliasCommand };
|
||||
189
apps/kimi-code/src/cli/sub/server/lifecycle.ts
Normal file
189
apps/kimi-code/src/cli/sub/server/lifecycle.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* `kimi server install/uninstall/start/stop/restart/status`.
|
||||
*
|
||||
* Phase 2 lands the CLI shape; the lifecycle calls into the platform service
|
||||
* manager from `@moonshot-ai/server`, which is filled in by Phase 3+.
|
||||
*
|
||||
* The Commander wiring here mirrors `addGatewayServiceCommands` from
|
||||
* `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import {
|
||||
ServiceUnsupportedError,
|
||||
resolveServiceManager,
|
||||
type InstallArgs,
|
||||
type ServiceManager,
|
||||
type ServiceStatus,
|
||||
} from '@moonshot-ai/server';
|
||||
|
||||
import {
|
||||
DEFAULT_LOG_LEVEL,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
parseLogLevel,
|
||||
parsePort,
|
||||
VALID_LOG_LEVELS,
|
||||
} from './shared';
|
||||
|
||||
export interface InstallCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
logLevel?: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonCliOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface LifecycleCommandDeps {
|
||||
resolveManager(): ServiceManager;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: LifecycleCommandDeps = {
|
||||
resolveManager: resolveServiceManager,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
||||
/** Mount install/uninstall/start/stop/restart/status under a parent command. */
|
||||
export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void {
|
||||
parent
|
||||
.command('install')
|
||||
.description('Install the Kimi server as an OS-managed service (launchd/systemd/schtasks).')
|
||||
.option('--host <host>', `Bind host (default ${DEFAULT_SERVER_HOST})`, DEFAULT_SERVER_HOST)
|
||||
.option('--port <port>', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT))
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
)
|
||||
.option('--force', 'Reinstall and overwrite if already installed', false)
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: InstallCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const args: InstallArgs = {
|
||||
host: opts.host ?? DEFAULT_SERVER_HOST,
|
||||
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel),
|
||||
force: opts.force === true,
|
||||
};
|
||||
const result = await mgr.install(args);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'install',
|
||||
status: result.status,
|
||||
plistPath: result.plistPath,
|
||||
unitPath: result.unitPath,
|
||||
taskName: result.taskName,
|
||||
message: result.message,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command('uninstall')
|
||||
.description('Uninstall the Kimi server service.')
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const result = await mgr.uninstall();
|
||||
return { ok: result.ok, action: 'uninstall', message: result.message };
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command('start')
|
||||
.description('Start the Kimi server service.')
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const result = await mgr.start();
|
||||
return { ok: result.ok, action: 'start', message: result.message };
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command('stop')
|
||||
.description('Stop the Kimi server service.')
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const result = await mgr.stop();
|
||||
return { ok: result.ok, action: 'stop', message: result.message };
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command('restart')
|
||||
.description('Restart the Kimi server service.')
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const result = await mgr.restart();
|
||||
return { ok: result.ok, action: 'restart', message: result.message };
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command('status')
|
||||
.description('Show Kimi server service status and connectivity.')
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const status: ServiceStatus = await mgr.status();
|
||||
return { ok: true, action: 'status', ...status };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runLifecycle(
|
||||
deps: LifecycleCommandDeps,
|
||||
json: boolean,
|
||||
body: (mgr: ServiceManager) => Promise<Record<string, unknown>>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mgr = deps.resolveManager();
|
||||
const result = await body(mgr);
|
||||
if (json) {
|
||||
deps.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
return;
|
||||
}
|
||||
deps.stdout.write(formatHuman(result));
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceUnsupportedError) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
action: 'unsupported',
|
||||
platform: error.platform,
|
||||
message: error.message,
|
||||
};
|
||||
if (json) {
|
||||
deps.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
} else {
|
||||
deps.stderr.write(`${error.message}\n`);
|
||||
}
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
if (json) {
|
||||
deps.stdout.write(
|
||||
`${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`,
|
||||
);
|
||||
} else {
|
||||
deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function formatHuman(result: Record<string, unknown>): string {
|
||||
const action = String(result['action'] ?? 'action');
|
||||
const message = result['message'] !== undefined ? `: ${String(result['message'])}` : '';
|
||||
return `${action}${message}\n`;
|
||||
}
|
||||
156
apps/kimi-code/src/cli/sub/server/run.ts
Normal file
156
apps/kimi-code/src/cli/sub/server/run.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* `kimi server run` — runs the local server in the foreground.
|
||||
*
|
||||
* Background ("daemonized") operation is not handled here. Use
|
||||
* `kimi server install` + `kimi server start` to register the server as an
|
||||
* OS-managed service (launchd / systemd / schtasks) instead.
|
||||
*
|
||||
* `kimi web` is an alias of this command with `--open` defaulted to `true`,
|
||||
* registered in `./web-alias.ts`.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { ServerLockedError, startServer } from '@moonshot-ai/server';
|
||||
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
|
||||
import {
|
||||
DEFAULT_LOG_LEVEL,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
parseServerOptions,
|
||||
serverOrigin,
|
||||
VALID_LOG_LEVELS,
|
||||
type ParsedServerOptions,
|
||||
type ServerCliOptions,
|
||||
} from './shared';
|
||||
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
|
||||
export interface RunCliOptions extends ServerCliOptions {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface RunCommandDeps {
|
||||
startServerForeground(options: ParsedServerOptions): Promise<{ origin: string }>;
|
||||
openUrl(url: string): void;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */
|
||||
export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command {
|
||||
return cmd
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host (default ${DEFAULT_SERVER_HOST})`,
|
||||
DEFAULT_SERVER_HOST,
|
||||
)
|
||||
.option(
|
||||
'--port <port>',
|
||||
`Bind port (default ${DEFAULT_SERVER_PORT})`,
|
||||
String(DEFAULT_SERVER_PORT),
|
||||
)
|
||||
.option(
|
||||
'--log-level <level>',
|
||||
`Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
)
|
||||
.option(
|
||||
'--debug-endpoints',
|
||||
'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
options.defaultOpen ? '--no-open' : '--open',
|
||||
options.defaultOpen
|
||||
? 'Do not open the web UI in the default browser.'
|
||||
: 'Open the web UI in the default browser once the server is healthy.',
|
||||
options.defaultOpen,
|
||||
)
|
||||
.action(async (opts: RunCliOptions) => {
|
||||
try {
|
||||
await handleRunCommand(opts);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleRunCommand(
|
||||
opts: RunCliOptions,
|
||||
deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS,
|
||||
): Promise<void> {
|
||||
const parsed = parseServerOptions(opts);
|
||||
let outcome: { origin: string };
|
||||
try {
|
||||
outcome = await deps.startServerForeground(parsed);
|
||||
} catch (error) {
|
||||
if (error instanceof ServerLockedError) {
|
||||
deps.stdout.write(formatAlreadyRunning(error.existing.port, error.existing.pid));
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
deps.stdout.write(`Kimi server: ${outcome.origin}\n`);
|
||||
if (opts.open === true) {
|
||||
deps.openUrl(outcome.origin);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServerForeground(
|
||||
options: ParsedServerOptions,
|
||||
): Promise<{ origin: string }> {
|
||||
const version = getVersion();
|
||||
const running = await startServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
logLevel: options.logLevel,
|
||||
debugEndpoints: options.debugEndpoints,
|
||||
webAssetsDir: serverWebAssetsDir(),
|
||||
coreProcessOptions: {
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
running.logger.info({ signal }, 'server shutting down');
|
||||
try {
|
||||
await running.close();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
running.logger.error(
|
||||
{ err: error instanceof Error ? error : new Error(String(error)) },
|
||||
'server shutdown error',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
const handleSignal = (signal: NodeJS.Signals): void => {
|
||||
void shutdown(signal);
|
||||
};
|
||||
process.once('SIGINT', handleSignal);
|
||||
process.once('SIGTERM', handleSignal);
|
||||
|
||||
return { origin: serverOrigin(options.host, options.port) };
|
||||
}
|
||||
|
||||
function serverWebAssetsDir(): string {
|
||||
return join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
function formatAlreadyRunning(port: number, pid: number): string {
|
||||
return `Kimi server already running at ${serverOrigin(DEFAULT_SERVER_HOST, port)} (pid ${pid}).\n`;
|
||||
}
|
||||
|
||||
const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = {
|
||||
startServerForeground,
|
||||
openUrl: defaultOpenUrl,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
145
apps/kimi-code/src/cli/sub/server/shared.ts
Normal file
145
apps/kimi-code/src/cli/sub/server/shared.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Shared helpers for `kimi server …` subcommands.
|
||||
*
|
||||
* Owns the default host/port, option parsers, and health/readiness probes that
|
||||
* `run`, `web`, and `status` all use.
|
||||
*/
|
||||
|
||||
import type { ServerLogLevel } from '@moonshot-ai/server';
|
||||
|
||||
export const DEFAULT_SERVER_HOST = '127.0.0.1';
|
||||
export const DEFAULT_SERVER_PORT = 7878;
|
||||
export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
|
||||
export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info';
|
||||
|
||||
export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [
|
||||
'fatal',
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'debug',
|
||||
'trace',
|
||||
'silent',
|
||||
];
|
||||
|
||||
export interface ParsedServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: ServerLogLevel;
|
||||
debugEndpoints: boolean;
|
||||
}
|
||||
|
||||
export interface ServerCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
logLevel?: string;
|
||||
debugEndpoints?: boolean;
|
||||
}
|
||||
|
||||
export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions {
|
||||
return {
|
||||
host: opts.host ?? DEFAULT_SERVER_HOST,
|
||||
port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT),
|
||||
logLevel: parseLogLevel(opts.logLevel),
|
||||
debugEndpoints: opts.debugEndpoints === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePort(raw: string | undefined, label: string, fallback: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 65535) {
|
||||
throw new Error(`error: invalid ${label} value: ${raw}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function parseLogLevel(raw: string | undefined): ServerLogLevel {
|
||||
if (raw === undefined) return DEFAULT_LOG_LEVEL;
|
||||
if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) {
|
||||
return raw as ServerLogLevel;
|
||||
}
|
||||
throw new Error(
|
||||
`error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
export function serverOrigin(host: string, port: number): string {
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */
|
||||
export function normalizeServerOrigin(value: string): string {
|
||||
const url = new URL(value);
|
||||
url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */
|
||||
export async function isServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${origin}/api/v1/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = (await response.json()) as { code?: unknown };
|
||||
return body.code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */
|
||||
export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await isServerHealthy(origin, 500)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
} while (Date.now() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe `/` and confirm the bundled web UI is being served.
|
||||
*
|
||||
* A different build that runs on the same port serves its own bundle — opening
|
||||
* a browser at that origin lands on stale code. Catching that here lets the
|
||||
* caller surface a clear "stop the running server" message instead of silently
|
||||
* handing the user the wrong UI.
|
||||
*/
|
||||
export async function ensureServerWebReady(origin: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${origin}/`, {
|
||||
headers: { accept: 'text/html' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.text();
|
||||
if (!body.includes('<div id="app"')) {
|
||||
throw new Error('missing app root');
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? ` (${error.message})` : '';
|
||||
throw new Error(
|
||||
`Server at ${origin} does not serve the Kimi web UI${reason}. Stop the existing server and rerun \`kimi server run\`.`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
19
apps/kimi-code/src/cli/sub/server/web-alias.ts
Normal file
19
apps/kimi-code/src/cli/sub/server/web-alias.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* `kimi web` — thin alias for `kimi server run --open`.
|
||||
*
|
||||
* Lives at the top level of the program (not under `server`) so users can keep
|
||||
* typing the short form. Identical to `run` except `--open` defaults to true.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { buildRunCommand } from './run';
|
||||
|
||||
export function registerWebAliasCommand(program: Command): void {
|
||||
buildRunCommand(
|
||||
program
|
||||
.command('web')
|
||||
.description('Open the Kimi web UI (alias of `kimi server run --open`).'),
|
||||
{ defaultOpen: true },
|
||||
);
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/**
|
||||
* `kimi web` sub-command.
|
||||
*
|
||||
* Opens the daemon-hosted Kimi Code web UI. By default it ensures the local
|
||||
* daemon is running in the background first. When `--daemon-host` is provided,
|
||||
* that daemon is treated as the complete web/API host and is opened directly.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
ensureDaemonRunning,
|
||||
parsePort,
|
||||
type DaemonStartupReporter,
|
||||
type EnsureDaemonResult,
|
||||
} from './daemon';
|
||||
|
||||
export interface WebCliOptions {
|
||||
host?: string;
|
||||
port?: string;
|
||||
daemonHost?: string;
|
||||
open?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
export interface WebCommandDeps {
|
||||
ensureDaemonRunning(options: {
|
||||
host: string;
|
||||
port: number;
|
||||
logLevel: 'info';
|
||||
debugEndpoints: false;
|
||||
restart: boolean;
|
||||
}, report?: DaemonStartupReporter): Promise<EnsureDaemonResult>;
|
||||
ensureDaemonWebReady(origin: string): Promise<void>;
|
||||
openUrl(url: string): void;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
export function registerWebCommand(parent: Command): void {
|
||||
parent
|
||||
.command('web')
|
||||
.description('Open the daemon-hosted Kimi Code web UI.')
|
||||
.option(
|
||||
'--host <host>',
|
||||
`Bind host when starting the local daemon (default ${DEFAULT_DAEMON_HOST})`,
|
||||
)
|
||||
.option(
|
||||
'--port <port>',
|
||||
`Port when starting the local daemon (default ${DEFAULT_DAEMON_PORT})`,
|
||||
)
|
||||
.option(
|
||||
'--daemon-host <url>',
|
||||
`Daemon URL to open instead of starting the local daemon (default ${DEFAULT_DAEMON_ORIGIN})`,
|
||||
)
|
||||
.option(
|
||||
'--restart',
|
||||
'Stop the running daemon (if this CLI manages it) and start a fresh one with this build.',
|
||||
false,
|
||||
)
|
||||
.option('--no-open', 'Do not open the web UI in the default browser.')
|
||||
.action(async (opts: WebCliOptions) => {
|
||||
try {
|
||||
await handleWebCommand(opts);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleWebCommand(
|
||||
opts: WebCliOptions,
|
||||
deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS,
|
||||
): Promise<void> {
|
||||
let webOrigin: string;
|
||||
if (opts.daemonHost !== undefined) {
|
||||
webOrigin = normalizeDaemonOrigin(opts.daemonHost);
|
||||
} else {
|
||||
const host = opts.host ?? DEFAULT_DAEMON_HOST;
|
||||
const port = parsePort(opts.port, '--port', DEFAULT_DAEMON_PORT);
|
||||
const daemon = await deps.ensureDaemonRunning(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
restart: opts.restart === true,
|
||||
},
|
||||
(message) => {
|
||||
// Diagnostics go to stderr: scripts capture `Kimi web: <origin>` from
|
||||
// stdout and must not see trace lines interleaved on the same stream.
|
||||
deps.stderr.write(`Daemon startup: ${message}\n`);
|
||||
},
|
||||
);
|
||||
webOrigin = daemon.origin;
|
||||
if (daemon.status === 'started') {
|
||||
deps.stdout.write(
|
||||
`Kimi daemon started in background at ${daemon.origin} (pid ${daemon.pid}).\n`,
|
||||
);
|
||||
deps.stdout.write(`Logs: ${daemon.logPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await deps.ensureDaemonWebReady(webOrigin);
|
||||
deps.stdout.write(`Kimi web: ${webOrigin}\n`);
|
||||
|
||||
if (opts.open !== false) {
|
||||
deps.openUrl(webOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDaemonOrigin(value: string): string {
|
||||
const url = new URL(value);
|
||||
url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function ensureDaemonWebReady(origin: string): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${origin}/`, {
|
||||
headers: { accept: 'text/html' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.text();
|
||||
if (!body.includes('<div id="app"')) {
|
||||
throw new Error('missing app root');
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? ` (${error.message})` : '';
|
||||
throw new Error(
|
||||
`Daemon at ${origin} does not serve the Kimi web UI${reason}. Stop the existing daemon and rerun \`kimi web\`, or use --daemon-host to open a compatible daemon.`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = {
|
||||
ensureDaemonRunning: (options, report) => ensureDaemonRunning(options, undefined, report),
|
||||
ensureDaemonWebReady,
|
||||
openUrl: defaultOpenUrl,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
handleDaemonCommand,
|
||||
ensureDaemonRunning,
|
||||
registerDaemonCommand,
|
||||
type DaemonCommandDeps,
|
||||
type EnsureDaemonRunningDeps,
|
||||
} from '#/cli/sub/daemon';
|
||||
|
||||
function makeDeps(overrides: Partial<DaemonCommandDeps> = {}): {
|
||||
deps: DaemonCommandDeps;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
return {
|
||||
deps: {
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'started' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 1234,
|
||||
logPath: '/tmp/kimi-daemon.log',
|
||||
})),
|
||||
startDaemonForeground: vi.fn(async () => undefined),
|
||||
stdout: { write: (chunk) => stdout.push(String(chunk)) > 0 },
|
||||
stderr: { write: (chunk) => stderr.push(String(chunk)) > 0 },
|
||||
...overrides,
|
||||
},
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('kimi daemon', () => {
|
||||
it('registers daemon as a subcommand with a foreground option', () => {
|
||||
const program = new Command('kimi');
|
||||
registerDaemonCommand(program);
|
||||
|
||||
const daemon = program.commands.find((cmd) => cmd.name() === 'daemon');
|
||||
expect(daemon).toBeDefined();
|
||||
expect(daemon?.options.some((option) => option.long === '--foreground')).toBe(true);
|
||||
});
|
||||
|
||||
it('starts the daemon in the background by default', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleDaemonCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith(
|
||||
{
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(deps.startDaemonForeground).not.toHaveBeenCalled();
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain('Kimi daemon started in background');
|
||||
expect(stdout.join('')).toContain('/tmp/kimi-daemon.log');
|
||||
});
|
||||
|
||||
it('does not start another background daemon when one is already running', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps({
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 4321,
|
||||
})),
|
||||
});
|
||||
|
||||
await handleDaemonCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledTimes(1);
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith(
|
||||
{
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(deps.startDaemonForeground).not.toHaveBeenCalled();
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain('Kimi daemon already running');
|
||||
expect(stdout.join('')).toContain('4321');
|
||||
});
|
||||
|
||||
it('keeps the daemon in the foreground when --foreground is set', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleDaemonCommand({ foreground: true, port: '7879', logLevel: 'debug' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.startDaemonForeground).toHaveBeenCalledWith({
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: 7879,
|
||||
logLevel: 'debug',
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
});
|
||||
expect(stdout.join('')).toBe('');
|
||||
expect(stderr.join('')).toBe('');
|
||||
});
|
||||
|
||||
it('does not treat a live lock as running until health check succeeds', async () => {
|
||||
const parsedOptions = {
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info' as const,
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
};
|
||||
const deps: EnsureDaemonRunningDeps = {
|
||||
isDaemonHealthy: vi.fn(async () => false),
|
||||
waitForDaemonHealthy: vi.fn(async () => false),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 8787,
|
||||
})),
|
||||
startDaemonBackground: vi.fn(() => ({
|
||||
pid: 9876,
|
||||
logPath: '/tmp/kimi-daemon.log',
|
||||
})),
|
||||
daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'),
|
||||
currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/cli/main.mjs' })),
|
||||
stopDaemon: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
await expect(ensureDaemonRunning(parsedOptions, deps)).rejects.toThrow(
|
||||
'Kimi daemon did not become healthy',
|
||||
);
|
||||
|
||||
expect(deps.waitForDaemonHealthy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'http://127.0.0.1:8787',
|
||||
5000,
|
||||
);
|
||||
expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsedOptions);
|
||||
expect(deps.waitForDaemonHealthy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports when a live daemon lock redirects to another daemon port', async () => {
|
||||
const parsedOptions = {
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: 7999,
|
||||
logLevel: 'info' as const,
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
};
|
||||
const status: string[] = [];
|
||||
const deps: EnsureDaemonRunningDeps = {
|
||||
isDaemonHealthy: vi.fn(async () => false),
|
||||
waitForDaemonHealthy: vi.fn(async () => true),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 7880,
|
||||
host_version: '1.0.0',
|
||||
entry: '/cli/main.mjs',
|
||||
})),
|
||||
startDaemonBackground: vi.fn(() => ({
|
||||
pid: 9876,
|
||||
logPath: '/tmp/kimi-daemon.log',
|
||||
})),
|
||||
daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'),
|
||||
currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/cli/main.mjs' })),
|
||||
stopDaemon: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsedOptions, deps, (message) => status.push(message)),
|
||||
).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: 'http://127.0.0.1:7880',
|
||||
pid: 4321,
|
||||
});
|
||||
|
||||
expect(status).toEqual([
|
||||
'checking requested daemon at http://127.0.0.1:7999',
|
||||
'requested daemon is not healthy',
|
||||
expect.stringContaining('checking daemon lock at '),
|
||||
'found live daemon lock (pid 4321, port 7880, started 2026-06-10T00:00:00.000Z)',
|
||||
'checking locked daemon at http://127.0.0.1:7880',
|
||||
'locked daemon is healthy; reusing http://127.0.0.1:7880',
|
||||
]);
|
||||
expect(deps.startDaemonBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function makeEnsureDeps(overrides: Partial<EnsureDaemonRunningDeps> = {}): EnsureDaemonRunningDeps {
|
||||
return {
|
||||
isDaemonHealthy: vi.fn(async () => true),
|
||||
waitForDaemonHealthy: vi.fn(async () => true),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
host_version: '1.0.0',
|
||||
entry: '/old/main.mjs',
|
||||
})),
|
||||
startDaemonBackground: vi.fn(() => ({ pid: 9876, logPath: '/tmp/kimi-daemon.log' })),
|
||||
daemonLogPath: vi.fn(() => '/tmp/kimi-daemon.log'),
|
||||
currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/old/main.mjs' })),
|
||||
stopDaemon: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedDefaults = {
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info' as const,
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
};
|
||||
|
||||
it('reuses a healthy daemon that was started by the same build', async () => {
|
||||
const deps = makeEnsureDeps();
|
||||
|
||||
await expect(ensureDaemonRunning(parsedDefaults, deps)).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 4321,
|
||||
});
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
expect(deps.startDaemonBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns but reuses a build-mismatched daemon without --restart', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps({
|
||||
currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 4321,
|
||||
});
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
expect(deps.startDaemonBackground).not.toHaveBeenCalled();
|
||||
expect(status).toContain(
|
||||
'running daemon is a different build (running 1.0.0, current 2.0.0); rerun with --restart to replace it',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about a different install of the same version without --restart', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps({
|
||||
currentBuild: vi.fn(() => ({ version: '1.0.0', entry: '/new/main.mjs' })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({ status: 'already-running' });
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
expect(status).toContain(
|
||||
'running daemon is a different install of 1.0.0 (/old/main.mjs); rerun with --restart to replace it',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about a lock without build identity (older daemon) without --restart', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps({
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
})),
|
||||
currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({ status: 'already-running' });
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
expect(status).toContain(
|
||||
'running daemon is a different build (running unknown build, current 2.0.0); rerun with --restart to replace it',
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the running daemon and starts fresh with --restart (even on the same build)', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps();
|
||||
const parsed = { ...parsedDefaults, restart: true };
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsed, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({ status: 'started', origin: DEFAULT_DAEMON_ORIGIN, pid: 9876 });
|
||||
|
||||
expect(deps.stopDaemon).toHaveBeenCalledWith(4321);
|
||||
expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsed);
|
||||
expect(status).toContain('--restart: stopping daemon (pid 4321)');
|
||||
expect(status).toContain('stopped daemon (pid 4321)');
|
||||
});
|
||||
|
||||
it('keeps using the running daemon when --restart cannot stop it', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps({
|
||||
stopDaemon: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning({ ...parsedDefaults, restart: true }, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 4321,
|
||||
});
|
||||
expect(deps.startDaemonBackground).not.toHaveBeenCalled();
|
||||
expect(status).toContain(
|
||||
`could not stop daemon (pid 4321); using ${DEFAULT_DAEMON_ORIGIN} as-is`,
|
||||
);
|
||||
});
|
||||
|
||||
it('never stops a daemon whose lock does not match the requested port', async () => {
|
||||
// e.g. a stub daemon occupying the port while the lock belongs to a daemon
|
||||
// on another port — --restart must not SIGTERM the unrelated lock pid.
|
||||
const deps = makeEnsureDeps({
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 9999,
|
||||
host_version: '1.0.0',
|
||||
entry: '/old/main.mjs',
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning({ ...parsedDefaults, restart: true }, deps),
|
||||
).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: undefined,
|
||||
});
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restarts a daemon found via the lock on another port with --restart', async () => {
|
||||
const deps = makeEnsureDeps({
|
||||
isDaemonHealthy: vi.fn(async () => false),
|
||||
waitForDaemonHealthy: vi.fn(async () => true),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 9999,
|
||||
host_version: '1.0.0',
|
||||
entry: '/old/main.mjs',
|
||||
})),
|
||||
});
|
||||
const parsed = { ...parsedDefaults, restart: true };
|
||||
|
||||
await expect(ensureDaemonRunning(parsed, deps)).resolves.toMatchObject({
|
||||
status: 'started',
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
});
|
||||
expect(deps.stopDaemon).toHaveBeenCalledWith(4321);
|
||||
expect(deps.startDaemonBackground).toHaveBeenCalledWith(parsed);
|
||||
});
|
||||
|
||||
it('warns but reuses a build-mismatched daemon on another port without --restart', async () => {
|
||||
const status: string[] = [];
|
||||
const deps = makeEnsureDeps({
|
||||
isDaemonHealthy: vi.fn(async () => false),
|
||||
waitForDaemonHealthy: vi.fn(async () => true),
|
||||
readLiveDaemonLock: vi.fn(() => ({
|
||||
pid: 4321,
|
||||
started_at: '2026-06-10T00:00:00.000Z',
|
||||
port: 9999,
|
||||
host_version: '1.0.0',
|
||||
entry: '/old/main.mjs',
|
||||
})),
|
||||
currentBuild: vi.fn(() => ({ version: '2.0.0', entry: '/new/main.mjs' })),
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureDaemonRunning(parsedDefaults, deps, (m) => status.push(m)),
|
||||
).resolves.toMatchObject({
|
||||
status: 'already-running',
|
||||
origin: 'http://127.0.0.1:9999',
|
||||
pid: 4321,
|
||||
});
|
||||
expect(deps.stopDaemon).not.toHaveBeenCalled();
|
||||
expect(status).toContain(
|
||||
'running daemon is a different build (running 1.0.0, current 2.0.0); rerun with --restart to replace it',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -289,7 +289,7 @@ describe('CLI options parsing', () => {
|
|||
'export',
|
||||
'provider',
|
||||
'acp',
|
||||
'daemon',
|
||||
'server',
|
||||
'web',
|
||||
'login',
|
||||
'doctor',
|
||||
|
|
|
|||
114
apps/kimi-code/test/cli/server/server.test.ts
Normal file
114
apps/kimi-code/test/cli/server/server.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Tests for `kimi server run` and `kimi web` Commander wiring.
|
||||
*
|
||||
* These tests don't actually start the server — they verify the parsed shape
|
||||
* (option flags, --open default) and that the `web` alias defers to the same
|
||||
* underlying handler with `defaultOpen` flipped to true.
|
||||
*
|
||||
* Foreground startup behavior is exercised end-to-end in `server-e2e/`.
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerServerCommand } from '#/cli/sub/server';
|
||||
|
||||
function makeProgram(): Command {
|
||||
// `commander` exitOverride avoids killing the test runner when --help/error fires.
|
||||
const program = new Command('kimi').exitOverride();
|
||||
registerServerCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe('kimi server', () => {
|
||||
it('registers `server` with all six lifecycle subcommands plus `run`', () => {
|
||||
const program = makeProgram();
|
||||
const server = program.commands.find((c) => c.name() === 'server');
|
||||
expect(server).toBeDefined();
|
||||
const subs = server?.commands.map((c) => c.name()).toSorted();
|
||||
expect(subs).toEqual(['install', 'restart', 'run', 'start', 'status', 'stop', 'uninstall']);
|
||||
});
|
||||
|
||||
it('`server run` exposes --host, --port, --log-level, --debug-endpoints, --open', () => {
|
||||
const program = makeProgram();
|
||||
const run = program.commands
|
||||
.find((c) => c.name() === 'server')
|
||||
?.commands.find((c) => c.name() === 'run');
|
||||
expect(run).toBeDefined();
|
||||
const longs = run!.options.map((o) => o.long).filter(Boolean);
|
||||
expect(longs).toContain('--host');
|
||||
expect(longs).toContain('--port');
|
||||
expect(longs).toContain('--log-level');
|
||||
expect(longs).toContain('--debug-endpoints');
|
||||
// run defaults to NOT opening the browser → option is the positive --open
|
||||
expect(longs).toContain('--open');
|
||||
});
|
||||
|
||||
it('`server install` exposes --host, --port, --log-level, --force, --json', () => {
|
||||
const program = makeProgram();
|
||||
const install = program.commands
|
||||
.find((c) => c.name() === 'server')
|
||||
?.commands.find((c) => c.name() === 'install');
|
||||
expect(install).toBeDefined();
|
||||
const longs = install!.options.map((o) => o.long).filter(Boolean);
|
||||
expect(longs).toContain('--host');
|
||||
expect(longs).toContain('--port');
|
||||
expect(longs).toContain('--log-level');
|
||||
expect(longs).toContain('--force');
|
||||
expect(longs).toContain('--json');
|
||||
});
|
||||
|
||||
it('the top-level `kimi web` alias is registered and defaults to opening the browser', () => {
|
||||
const program = makeProgram();
|
||||
const web = program.commands.find((c) => c.name() === 'web');
|
||||
expect(web).toBeDefined();
|
||||
const longs = web!.options.map((o) => o.long).filter(Boolean);
|
||||
// web defaults to opening → the option is the negative form --no-open
|
||||
expect(longs).toContain('--no-open');
|
||||
expect(longs).toContain('--host');
|
||||
expect(longs).toContain('--port');
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => {
|
||||
it('the dispatcher returns a friendly error manager for unknown platforms', async () => {
|
||||
// darwin / linux / win32 have real backends (launchd / systemd / schtasks).
|
||||
// The remaining platforms fall through to the stub that throws
|
||||
// `ServiceUnsupportedError` — pin that contract so a future addition
|
||||
// (freebsd, etc.) needs a deliberate decision instead of silently working.
|
||||
const { resolveServiceManager, ServiceUnsupportedError } = await import('@moonshot-ai/server');
|
||||
const mgr = resolveServiceManager('freebsd');
|
||||
await expect(
|
||||
mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }),
|
||||
).rejects.toBeInstanceOf(ServiceUnsupportedError);
|
||||
await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server` does not register a legacy `daemon` command', () => {
|
||||
it('hard-deletes the old name', () => {
|
||||
const program = makeProgram();
|
||||
const daemon = program.commands.find((c) => c.name() === 'daemon');
|
||||
expect(daemon).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared parsers stay strict', () => {
|
||||
it('rejects out-of-range --port', async () => {
|
||||
const { parsePort } = await import('#/cli/sub/server/shared');
|
||||
expect(() => parsePort('99999', '--port', 7878)).toThrow(/invalid --port/);
|
||||
expect(() => parsePort('-1', '--port', 7878)).toThrow(/invalid --port/);
|
||||
expect(parsePort(undefined, '--port', 7878)).toBe(7878);
|
||||
expect(parsePort('8080', '--port', 7878)).toBe(8080);
|
||||
});
|
||||
|
||||
it('rejects unknown --log-level values', async () => {
|
||||
const { parseLogLevel } = await import('#/cli/sub/server/shared');
|
||||
expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/);
|
||||
expect(parseLogLevel(undefined)).toBe('info');
|
||||
expect(parseLogLevel('debug')).toBe('debug');
|
||||
});
|
||||
});
|
||||
|
||||
// Silence vi import for cases where the file is built before tests reference vi.
|
||||
void vi;
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import { Command } from 'commander';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_DAEMON_HOST,
|
||||
DEFAULT_DAEMON_ORIGIN,
|
||||
DEFAULT_DAEMON_PORT,
|
||||
} from '#/cli/sub/daemon';
|
||||
import {
|
||||
handleWebCommand,
|
||||
registerWebCommand,
|
||||
type WebCommandDeps,
|
||||
} from '#/cli/sub/web';
|
||||
|
||||
function makeDeps(overrides: Partial<WebCommandDeps> = {}): {
|
||||
deps: WebCommandDeps;
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
} {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
return {
|
||||
deps: {
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: DEFAULT_DAEMON_ORIGIN,
|
||||
pid: 1234,
|
||||
})),
|
||||
ensureDaemonWebReady: vi.fn(async () => undefined),
|
||||
openUrl: vi.fn(),
|
||||
stdout: { write: (chunk) => stdout.push(String(chunk)) > 0 },
|
||||
stderr: { write: (chunk) => stderr.push(String(chunk)) > 0 },
|
||||
...overrides,
|
||||
},
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
}
|
||||
|
||||
describe('kimi web', () => {
|
||||
it('registers web as a subcommand', () => {
|
||||
const program = new Command('kimi');
|
||||
registerWebCommand(program);
|
||||
|
||||
const web = program.commands.find((cmd) => cmd.name() === 'web');
|
||||
expect(web).toBeDefined();
|
||||
expect(web?.options.some((option) => option.long === '--daemon-host')).toBe(true);
|
||||
});
|
||||
|
||||
it('ensures the local daemon and opens the daemon-hosted web UI by default', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps();
|
||||
|
||||
await handleWebCommand({}, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith(
|
||||
{
|
||||
host: DEFAULT_DAEMON_HOST,
|
||||
port: DEFAULT_DAEMON_PORT,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(deps.openUrl).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(stderr.join('')).toBe('');
|
||||
expect(stdout.join('')).toContain(`Kimi web: ${DEFAULT_DAEMON_ORIGIN}`);
|
||||
});
|
||||
|
||||
it('opens the provided daemon host directly without starting local services', async () => {
|
||||
const { deps } = makeDeps();
|
||||
|
||||
await handleWebCommand({ daemonHost: 'http://192.168.97.91:7878' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://192.168.97.91:7878');
|
||||
expect(deps.openUrl).toHaveBeenCalledWith('http://192.168.97.91:7878');
|
||||
});
|
||||
|
||||
it('uses --port as the local daemon start port when --daemon-host is absent', async () => {
|
||||
const { deps } = makeDeps({
|
||||
ensureDaemonRunning: vi.fn(async () => ({
|
||||
status: 'already-running' as const,
|
||||
origin: 'http://127.0.0.1:8899',
|
||||
pid: 1234,
|
||||
})),
|
||||
});
|
||||
|
||||
await handleWebCommand({ host: '0.0.0.0', port: '8899' }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith(
|
||||
{
|
||||
host: '0.0.0.0',
|
||||
port: 8899,
|
||||
logLevel: 'info',
|
||||
debugEndpoints: false,
|
||||
restart: false,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://127.0.0.1:8899');
|
||||
expect(deps.openUrl).toHaveBeenCalledWith('http://127.0.0.1:8899');
|
||||
});
|
||||
|
||||
it('forwards --restart to the daemon resolver', async () => {
|
||||
const { deps } = makeDeps();
|
||||
|
||||
await handleWebCommand({ restart: true }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ restart: true }),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('prints daemon startup trace to stderr, keeping stdout machine-readable', async () => {
|
||||
const { deps, stdout, stderr } = makeDeps({
|
||||
ensureDaemonRunning: vi.fn(async (_options, report) => {
|
||||
report?.('checking requested daemon at http://127.0.0.1:7999');
|
||||
report?.('found live daemon lock (pid 4321, port 7880, started 2026-06-10T00:00:00.000Z)');
|
||||
report?.('locked daemon is healthy; reusing http://127.0.0.1:7880');
|
||||
return {
|
||||
status: 'already-running' as const,
|
||||
origin: 'http://127.0.0.1:7880',
|
||||
pid: 4321,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await handleWebCommand({ port: '7999', open: false }, deps);
|
||||
|
||||
expect(stderr.join('')).toContain(
|
||||
'Daemon startup: checking requested daemon at http://127.0.0.1:7999',
|
||||
);
|
||||
expect(stderr.join('')).toContain(
|
||||
'Daemon startup: found live daemon lock (pid 4321, port 7880, started 2026-06-10T00:00:00.000Z)',
|
||||
);
|
||||
expect(stderr.join('')).toContain(
|
||||
'Daemon startup: locked daemon is healthy; reusing http://127.0.0.1:7880',
|
||||
);
|
||||
// stdout carries ONLY the machine-readable result line.
|
||||
expect(stdout.join('')).toBe('Kimi web: http://127.0.0.1:7880\n');
|
||||
});
|
||||
|
||||
it('respects --no-open while still printing the daemon-hosted web URL', async () => {
|
||||
const { deps, stdout } = makeDeps();
|
||||
|
||||
await handleWebCommand({ daemonHost: 'http://example.test:9000/api/v1/', open: false }, deps);
|
||||
|
||||
expect(deps.ensureDaemonRunning).not.toHaveBeenCalled();
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith('http://example.test:9000');
|
||||
expect(deps.openUrl).not.toHaveBeenCalled();
|
||||
expect(stdout.join('')).toContain('Kimi web: http://example.test:9000');
|
||||
});
|
||||
|
||||
it('does not open a daemon that does not serve the web UI', async () => {
|
||||
const { deps, stdout } = makeDeps({
|
||||
ensureDaemonWebReady: vi.fn(async () => {
|
||||
throw new Error('Daemon at http://127.0.0.1:7878 does not serve the Kimi web UI.');
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(handleWebCommand({}, deps)).rejects.toThrow('does not serve the Kimi web UI');
|
||||
|
||||
expect(deps.ensureDaemonWebReady).toHaveBeenCalledWith(DEFAULT_DAEMON_ORIGIN);
|
||||
expect(deps.openUrl).not.toHaveBeenCalled();
|
||||
expect(stdout.join('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
# Kimi Web
|
||||
|
||||
A browser client for Kimi Code — a peer to the TUI (`apps/kimi-code`) that talks
|
||||
to a local **daemon** over REST + WebSocket. Vue 3 + Vite + TypeScript.
|
||||
to a local **server** over REST + WebSocket. Vue 3 + Vite + TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1) Against a REAL daemon (the daemon must be running and reachable)
|
||||
WEB_PORT=5197 KIMI_DAEMON_URL=http://192.168.97.91:7878 pnpm -C apps/kimi-web run dev
|
||||
# 1) Against a REAL server (the server must be running and reachable)
|
||||
WEB_PORT=5197 KIMI_SERVER_URL=http://192.168.97.91:7878 pnpm -C apps/kimi-web run dev
|
||||
# …or from the repo root: pnpm dev:web (uses the defaults below)
|
||||
|
||||
# 2) Offline / no daemon — a stub that fakes the daemon API + event stream
|
||||
# 2) Offline / no server — a stub that fakes the server API + event stream
|
||||
pnpm -C apps/kimi-web run dev:stub # then run dev in another shell
|
||||
|
||||
# checks
|
||||
|
|
@ -21,18 +21,18 @@ pnpm -C apps/kimi-web run test # vitest
|
|||
pnpm -C apps/kimi-web run build # vite build
|
||||
```
|
||||
|
||||
### How it connects to the daemon
|
||||
### How it connects to the server
|
||||
|
||||
The browser cannot reach the daemon cross-origin (no CORS), so Vite **same-origin
|
||||
proxies** `/api/v1` (HTTP + WS) to the daemon (`vite.config.ts`):
|
||||
The browser cannot reach the server cross-origin (no CORS), so Vite **same-origin
|
||||
proxies** `/api/v1` (HTTP + WS) to the server (`vite.config.ts`):
|
||||
|
||||
| env var | default | meaning |
|
||||
| ----------------- | ------------------------ | ---------------------------------------- |
|
||||
| `WEB_PORT` | `5175` | port the dev server listens on |
|
||||
| `KIMI_DAEMON_URL` | `http://127.0.0.1:7878` | where `/api/v1` (and `/api/v1/ws`) is forwarded |
|
||||
| `KIMI_SERVER_URL` | `http://127.0.0.1:7878` | where `/api/v1` (and `/api/v1/ws`) is forwarded |
|
||||
|
||||
> Behind a corporate HTTP proxy, also set `NO_PROXY=<daemon-host>` (e.g.
|
||||
> `NO_PROXY=192.168.97.91`) so the proxy forward reaches the daemon directly.
|
||||
> Behind a corporate HTTP proxy, also set `NO_PROXY=<server-host>` (e.g.
|
||||
> `NO_PROXY=192.168.97.91`) so the proxy forward reaches the server directly.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ A strict one-direction data flow; components never touch the network or the
|
|||
reducer — they consume computed view props and call actions.
|
||||
|
||||
```
|
||||
daemon (REST + WS)
|
||||
server (REST + WS)
|
||||
└─ src/api/daemon/client.ts REST adapter (envelope → AppX types)
|
||||
└─ src/api/daemon/ws.ts WS frames → classify → projector/reducer
|
||||
└─ agentEventProjector.ts RAW agent-core events → AppEvent[]
|
||||
|
|
@ -52,9 +52,12 @@ daemon (REST + WS)
|
|||
└─ src/components/*.vue render props, emit intents (no api access)
|
||||
```
|
||||
|
||||
> The directory name `src/api/daemon/` is historical and kept to minimise
|
||||
> diff churn; conceptually it is the **server** adapter.
|
||||
|
||||
- **Adapter** (`src/api/`): wire types are snake_case; `AppX` types are camelCase.
|
||||
`config.ts` builds `/api/v1` URLs.
|
||||
- **Event projector** (`agentEventProjector.ts`): the daemon streams **raw
|
||||
- **Event projector** (`agentEventProjector.ts`): the server streams **raw
|
||||
agent-core events** (no `event.` prefix). `classifyFrame` routes raw vs
|
||||
protocol (`event.*`) frames; the projector converts them to `AppEvent`s.
|
||||
- **i18n** (`src/i18n/`): vue-i18n, en/zh, per-namespace flat camelCase keys.
|
||||
|
|
@ -63,9 +66,9 @@ daemon (REST + WS)
|
|||
|
||||
---
|
||||
|
||||
## Daemon contract — non-obvious notes
|
||||
## Server contract — non-obvious notes
|
||||
|
||||
The daemon's wire protocol has a few things that will bite you if forgotten:
|
||||
The server's wire protocol has a few things that will bite you if forgotten:
|
||||
|
||||
- **Envelope:** every response is `{ code, msg, data, request_id }` and the HTTP
|
||||
status is **always 200** — check `code` (0 = ok), not the status.
|
||||
|
|
@ -74,7 +77,7 @@ The daemon's wire protocol has a few things that will bite you if forgotten:
|
|||
from settings (model ← session/`default_model`, thinking/permission/plan ← the
|
||||
StatusLine controls). Sending only `{ content }` → `40001 model …`.
|
||||
- **Creating a session needs a *registered* workspace.** `workspace_id` must be a
|
||||
`wd_<slug>_<hash>` id that exists in the daemon's registry. Sessions get one
|
||||
`wd_<slug>_<hash>` id that exists in the server's registry. Sessions get one
|
||||
auto-assigned by cwd, but it isn't *registered* until you `POST /workspaces
|
||||
{ root }` (idempotent). The web registers on demand before `createSession`
|
||||
(otherwise: `workspace not found: wd_…`).
|
||||
|
|
@ -85,12 +88,12 @@ The daemon's wire protocol has a few things that will bite you if forgotten:
|
|||
|
||||
---
|
||||
|
||||
## What's still missing / blocked on the daemon
|
||||
## What's still missing / blocked on the server
|
||||
|
||||
See **`docs/main-flow-gaps.md`** (the main-flow gap audit) and
|
||||
**`docs/backend-workspace-session-asks.md`** (the endpoint asks for the backend).
|
||||
|
||||
Daemon endpoints that are **not live yet** (probed; the web degrades gracefully):
|
||||
Server endpoints that are **not live yet** (probed; the web degrades gracefully):
|
||||
|
||||
- `/sessions/{id}:compact`, `:fork`, `:steer`, `/undo` → `400` (no such action)
|
||||
- line-by-line `diff` → `404`
|
||||
|
|
@ -112,4 +115,4 @@ Living under `docs/` (design rationale + plans):
|
|||
- `docs/dual-sidebar-exploration.html` — sidebar layout options (Variant B shipped)
|
||||
- `docs/kimi-web-final-form.html` — target-state UI mockup
|
||||
- `docs/main-flow-gaps.md` — feature gap audit (what to build next)
|
||||
- `docs/backend-workspace-session-asks.md` — endpoints the daemon still needs
|
||||
- `docs/backend-workspace-session-asks.md` — endpoints the server still needs
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// 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
|
||||
// When the real daemon ships, point VITE_KIMI_SERVER_HTTP_URL at it instead and
|
||||
// stop running this.
|
||||
//
|
||||
// node dev/stub-daemon.mjs # listens on 127.0.0.1:7878
|
||||
|
|
@ -1010,7 +1010,7 @@ const server = http.createServer((req, res) => {
|
|||
}
|
||||
if (stripped === '/meta' || path === '/meta') {
|
||||
return res.end(ok({
|
||||
daemon_version: '0.0.0-stub',
|
||||
server_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,
|
||||
|
|
|
|||
|
|
@ -4,34 +4,34 @@
|
|||
const CLIENT_ID_KEY = 'kimi-web.client-id';
|
||||
|
||||
export interface KimiApiConfig {
|
||||
daemonHttpUrl: string;
|
||||
serverHttpUrl: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function readKimiApiConfig(): KimiApiConfig {
|
||||
return {
|
||||
daemonHttpUrl: normalizeDaemonOrigin(import.meta.env.VITE_KIMI_DAEMON_HTTP_URL),
|
||||
serverHttpUrl: normalizeServerOrigin(import.meta.env.VITE_KIMI_SERVER_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
|
||||
// and /v1/ws to the server (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 {
|
||||
// - prod: `kimi web` serves this built SPA from the server itself, so the
|
||||
// server's origin already is the API origin.
|
||||
// Set VITE_KIMI_SERVER_HTTP_URL to connect directly to an absolute server
|
||||
// origin instead (that path does require the server to send CORS headers).
|
||||
function defaultServerOrigin(): 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();
|
||||
export function normalizeServerOrigin(value: string | undefined): string {
|
||||
const raw = value && value.trim() ? value : defaultServerOrigin();
|
||||
const url = new URL(raw);
|
||||
url.pathname = url.pathname.replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
||||
url.search = '';
|
||||
|
|
@ -45,16 +45,16 @@ function shortOrigin(origin: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Address of the REAL daemon the client is connected to, shown in the status bar.
|
||||
* Always the actual daemon — never the dev-proxy URL — since that's the thing
|
||||
* Address of the REAL server the client is connected to, shown in the status bar.
|
||||
* Always the actual server — never the dev-proxy URL — since that's the thing
|
||||
* worth knowing at a glance. Cases:
|
||||
* - VITE_KIMI_DAEMON_HTTP_URL set → that absolute daemon origin (direct mode).
|
||||
* - dev (same-origin proxy) → the proxy's upstream target (the real daemon).
|
||||
* - prod (daemon serves the SPA) → the page origin (it IS the daemon).
|
||||
* - VITE_KIMI_SERVER_HTTP_URL set → that absolute server origin (direct mode).
|
||||
* - dev (same-origin proxy) → the proxy's upstream target (the real server).
|
||||
* - prod (server serves the SPA) → the page origin (it IS the server).
|
||||
*/
|
||||
export function daemonEndpointLabel(): string {
|
||||
const direct = import.meta.env.VITE_KIMI_DAEMON_HTTP_URL;
|
||||
if (direct && direct.trim()) return shortOrigin(normalizeDaemonOrigin(direct));
|
||||
export function serverEndpointLabel(): string {
|
||||
const direct = import.meta.env.VITE_KIMI_SERVER_HTTP_URL;
|
||||
if (direct && direct.trim()) return shortOrigin(normalizeServerOrigin(direct));
|
||||
|
||||
const proxy =
|
||||
typeof __KIMI_DEV_PROXY_TARGET__ !== 'undefined' ? __KIMI_DEV_PROXY_TARGET__ : '';
|
||||
|
|
@ -65,7 +65,7 @@ export function daemonEndpointLabel(): string {
|
|||
return shortOrigin(origin);
|
||||
}
|
||||
|
||||
// The real daemon serves everything (incl. healthz + ws) under the /api/v1 prefix.
|
||||
// The real server 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}`}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ interface WireHealth {
|
|||
}
|
||||
|
||||
interface WireMeta {
|
||||
daemon_version: string;
|
||||
server_version: string;
|
||||
server_id: string;
|
||||
started_at: string;
|
||||
capabilities: Record<string, boolean>;
|
||||
|
|
@ -185,7 +185,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
|
||||
constructor(config: KimiApiConfig) {
|
||||
this.config = config;
|
||||
this.http = new DaemonHttpClient(config.daemonHttpUrl);
|
||||
this.http = new DaemonHttpClient(config.serverHttpUrl);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -199,14 +199,14 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
}
|
||||
|
||||
async getMeta(): Promise<{
|
||||
daemonVersion: string;
|
||||
serverVersion: string;
|
||||
serverId: string;
|
||||
startedAt: string;
|
||||
capabilities: Record<string, boolean>;
|
||||
}> {
|
||||
const data = await this.http.get<WireMeta>('/meta');
|
||||
return {
|
||||
daemonVersion: data.daemon_version,
|
||||
serverVersion: data.server_version,
|
||||
serverId: data.server_id,
|
||||
startedAt: data.started_at,
|
||||
capabilities: data.capabilities,
|
||||
|
|
@ -698,7 +698,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
getFileDownloadUrl(sessionId: string, path: string): string {
|
||||
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
|
||||
return buildRestUrl(
|
||||
this.config.daemonHttpUrl,
|
||||
this.config.serverHttpUrl,
|
||||
`/sessions/${encodeURIComponent(sessionId)}/fs/${encodedPath}:download`,
|
||||
);
|
||||
}
|
||||
|
|
@ -939,7 +939,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
}
|
||||
|
||||
getFileUrl(fileId: string): string {
|
||||
return buildRestUrl(this.config.daemonHttpUrl, `/files/${encodeURIComponent(fileId)}`);
|
||||
return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -947,7 +947,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
connectEvents(handlers: KimiEventHandlers): KimiEventConnection {
|
||||
const wsUrl = buildWsUrl(this.config.daemonHttpUrl, this.config.clientId);
|
||||
const wsUrl = buildWsUrl(this.config.serverHttpUrl, this.config.clientId);
|
||||
|
||||
// Per-session projector for raw agent-core events.
|
||||
// Keyed by session_id; reset when a session is re-subscribed or resynced.
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ export interface AppProvider {
|
|||
|
||||
export interface KimiWebApi {
|
||||
getHealth(): Promise<{ status: 'ok'; uptimeSec: number }>;
|
||||
getMeta(): Promise<{ daemonVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean> }>;
|
||||
getMeta(): Promise<{ serverVersion: string; serverId: string; startedAt: string; capabilities: Record<string, boolean> }>;
|
||||
listSessions(input?: PageRequest & { status?: AppSessionStatus; workspaceId?: string }): Promise<Page<AppSession>>;
|
||||
createSession(input: { title?: string; cwd?: string; model?: string; workspaceId?: string }): Promise<AppSession>;
|
||||
/** Fetch one session by id (deep links beyond the first listSessions page). */
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { nextTick, onBeforeUnmount, ref } from 'vue';
|
|||
import { useI18n } from 'vue-i18n';
|
||||
import type { Session, WorkspaceGroup, WorkspaceView } from '../types';
|
||||
import type { Accent, ColorScheme, Theme } from '../composables/useKimiWebClient';
|
||||
import { daemonEndpointLabel } from '../api/config';
|
||||
import { serverEndpointLabel } from '../api/config';
|
||||
import LanguageSwitcher from './LanguageSwitcher.vue';
|
||||
import SessionRow from './SessionRow.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** Address of the real daemon this client connects to (shown in the settings popover). */
|
||||
const daemonEndpoint = daemonEndpointLabel();
|
||||
const daemonEndpoint = serverEndpointLabel();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ interface QueuedPrompt {
|
|||
|
||||
interface ExtendedState extends KimiClientState {
|
||||
connected: boolean;
|
||||
daemonVersion: string;
|
||||
serverVersion: string;
|
||||
workspaceName: string;
|
||||
connection: ConnectionState;
|
||||
permission: PermissionMode;
|
||||
|
|
@ -290,7 +290,7 @@ interface ExtendedState extends KimiClientState {
|
|||
const rawState: ExtendedState = reactive({
|
||||
...createInitialState(),
|
||||
connected: false,
|
||||
daemonVersion: '',
|
||||
serverVersion: '',
|
||||
workspaceName: 'kimi-web',
|
||||
connection: 'disconnected' as ConnectionState,
|
||||
permission: loadPermissionFromStorage(),
|
||||
|
|
@ -1391,7 +1391,7 @@ async function load(): Promise<void> {
|
|||
// Parallel: health + meta + sessions + models
|
||||
const [, , sessionsPage] = await Promise.all([
|
||||
api.getHealth().catch(() => null),
|
||||
api.getMeta().then((m) => { rawState.daemonVersion = m.daemonVersion; }).catch(() => null),
|
||||
api.getMeta().then((m) => { rawState.serverVersion = m.serverVersion; }).catch(() => null),
|
||||
api.listSessions({ pageSize: 20 }).catch(() => ({ items: [], hasMore: false })),
|
||||
loadModels(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@ import vue from '@vitejs/plugin-vue';
|
|||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const webPort = Number(process.env.WEB_PORT) || 5175;
|
||||
// Where the dev proxy forwards daemon traffic. Defaults to the local daemon
|
||||
// (or `pnpm dev:stub`). Override to point dev at another daemon instance.
|
||||
const daemonTarget = process.env.KIMI_DAEMON_URL || 'http://127.0.0.1:7878';
|
||||
// Where the dev proxy forwards server traffic. Defaults to the local server
|
||||
// (or `pnpm dev:stub`). Override to point dev at another server instance.
|
||||
const serverTarget = process.env.KIMI_SERVER_URL || 'http://127.0.0.1:7878';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
// Expose the dev proxy's upstream daemon target to the client so the UI can
|
||||
// show which daemon it is connected to (the browser otherwise only sees its
|
||||
// Expose the dev proxy's upstream server target to the client so the UI can
|
||||
// show which server it is connected to (the browser otherwise only sees its
|
||||
// own same-origin URL). Unused by the same-origin production build.
|
||||
define: {
|
||||
__KIMI_DEV_PROXY_TARGET__: JSON.stringify(daemonTarget),
|
||||
__KIMI_DEV_PROXY_TARGET__: JSON.stringify(serverTarget),
|
||||
},
|
||||
server: {
|
||||
port: webPort,
|
||||
strictPort: false,
|
||||
// Same-origin dev: the browser calls Vite, Vite forwards to the daemon.
|
||||
// No CORS anywhere. The real daemon serves REST + WS all under /api/v1.
|
||||
// Same-origin dev: the browser calls Vite, Vite forwards to the server.
|
||||
// No CORS anywhere. The real server serves REST + WS all under /api/v1.
|
||||
proxy: {
|
||||
'/api/v1': { target: daemonTarget, changeOrigin: true, ws: true },
|
||||
'/api/v1': { target: serverTarget, changeOrigin: true, ws: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo
|
|||
|
||||
## Subcommands
|
||||
|
||||
`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `daemon` (local REST and WebSocket service), `web` (local browser UI), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers).
|
||||
`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (alias for `kimi server run --open`), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers).
|
||||
|
||||
### `kimi login`
|
||||
|
||||
|
|
@ -141,48 +141,68 @@ Switch Kimi Code CLI to ACP (Agent Client Protocol) mode, communicating with an
|
|||
kimi acp
|
||||
```
|
||||
|
||||
### `kimi daemon`
|
||||
### `kimi server`
|
||||
|
||||
Run the local daemon that exposes Kimi Code over REST and WebSocket, and serves the web UI from the same origin. By default, the command starts the daemon in the background, waits until it is healthy, prints the endpoint and log path, then exits. If a daemon is already running, the command reports the existing process instead of starting another one.
|
||||
Run, install, and manage the local Kimi server — a single process that exposes the REST + WebSocket API and serves the web UI from the same origin. The parent command is split into a foreground entrypoint (`run`) and an OS-managed service lifecycle (`install`, `uninstall`, `start`, `stop`, `restart`, `status`). Background operation is **never** spawned implicitly by a foreground command; you opt in via `install` + `start`.
|
||||
|
||||
```sh
|
||||
kimi daemon
|
||||
kimi server run # foreground (logs in the current terminal)
|
||||
kimi server install # register with launchd / systemd / schtasks
|
||||
kimi server start # start the OS-managed service
|
||||
kimi server status # snapshot of installed/running state
|
||||
```
|
||||
|
||||
#### `kimi server run`
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--host <host>` | Bind host for the daemon; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Bind port for the daemon; defaults to `7878` |
|
||||
| `--log-level <level>` | Daemon log level; defaults to `info` |
|
||||
| `--foreground` | Keep the daemon attached to the current terminal instead of starting it in the background |
|
||||
| `--host <host>` | Bind host; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Bind port; defaults to `7878` |
|
||||
| `--log-level <level>` | Log level; defaults to `info` |
|
||||
| `--debug-endpoints` | Mount `/api/v1/debug/*` routes (off by default) |
|
||||
| `--open` | Open the web UI in the default browser once the server is healthy |
|
||||
|
||||
Use foreground mode when you want logs in the current terminal or when another process manager owns the daemon lifecycle:
|
||||
`kimi server run` does not return — it stays attached to the current terminal and shuts down cleanly on `SIGINT` / `SIGTERM`. For background operation, use the OS-service path below.
|
||||
|
||||
```sh
|
||||
kimi daemon --foreground
|
||||
```
|
||||
#### `kimi server install`
|
||||
|
||||
### `kimi web`
|
||||
Register the server as an OS-managed service so it starts at login and restarts after a crash. The backend picks itself based on the running platform:
|
||||
|
||||
Open the daemon-hosted browser UI. Without `--daemon-host`, `kimi web` checks the local daemon at `http://127.0.0.1:7878`; if it is not running, it starts `kimi daemon` in the background first, then opens the daemon URL. The web assets and `/api/v1/*` routes are served by the daemon from the same origin.
|
||||
|
||||
```sh
|
||||
kimi web
|
||||
```
|
||||
- **macOS**: writes a LaunchAgent plist to `~/Library/LaunchAgents/ai.moonshot.kimi-server.plist` and bootstraps it via `launchctl bootstrap gui/<uid>`.
|
||||
- **Linux**: writes a `--user` systemd unit to `~/.config/systemd/user/kimi-server.service` and runs `systemctl --user enable --now`.
|
||||
- **Windows**: registers a scheduled task named `KimiServer` via `schtasks /Create /XML`.
|
||||
|
||||
| Option | Description |
|
||||
| --- | --- |
|
||||
| `--host <host>` | Bind host when starting the local daemon; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Port when starting the local daemon; defaults to `7878` |
|
||||
| `--daemon-host <url>` | Open an existing daemon directly instead of starting the local daemon |
|
||||
| `--no-open` | Do not open the browser automatically |
|
||||
| `--host <host>` | Bind host the supervised server uses; defaults to `127.0.0.1` |
|
||||
| `--port <port>` | Bind port the supervised server uses; defaults to `7878` |
|
||||
| `--log-level <level>` | Log level recorded in the generated unit |
|
||||
| `--force` | Replace an existing install instead of failing |
|
||||
| `--json` | Output JSON instead of a human-readable line |
|
||||
|
||||
Point the web UI at an existing daemon when the daemon runs on another machine or is managed by a separate process. In this mode, `kimi web` does not start any local service:
|
||||
The chosen host / port / log-level are recorded to `~/.kimi-code/server/install.json` so `kimi server status` can report them even when the service is stopped.
|
||||
|
||||
#### Lifecycle subcommands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `kimi server uninstall` | Stop and remove the OS service definition. Idempotent. |
|
||||
| `kimi server start` | Start the OS-managed service. Errors if not installed. |
|
||||
| `kimi server stop` | Stop the OS-managed service. |
|
||||
| `kimi server restart` | Restart the OS-managed service. |
|
||||
| `kimi server status` | Print installed / running / pid / port / log-path. `--json` for automation. |
|
||||
|
||||
#### `kimi web`
|
||||
|
||||
Alias for `kimi server run` with `--open` defaulted to `true` — runs the server in the foreground and opens the web UI in the default browser once it is healthy. Use `--no-open` to skip the browser launch (effectively turning it back into `kimi server run`).
|
||||
|
||||
```sh
|
||||
kimi web --daemon-host=http://daemon.example.test:7878
|
||||
kimi web # foreground + open browser
|
||||
kimi web --no-open # equivalent to `kimi server run`
|
||||
```
|
||||
|
||||
The same `--host`, `--port`, `--log-level`, and `--debug-endpoints` flags work as on `kimi server run`.
|
||||
|
||||
### `kimi doctor`
|
||||
|
||||
Validate `config.toml` and `tui.toml` without starting the TUI or modifying either file. By default, the command checks the files under `KIMI_CODE_HOME` (or `~/.kimi-code` when the environment variable is unset). Missing default files are reported as skipped because built-in defaults can apply.
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ kimi -p "List changed files" --output-format stream-json
|
|||
|
||||
## 子命令
|
||||
|
||||
`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`daemon`(本地 REST 与 WebSocket 服务)、`web`(本地浏览器界面)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。
|
||||
`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(`kimi server run --open` 的别名)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。
|
||||
|
||||
### `kimi login`
|
||||
|
||||
|
|
@ -141,48 +141,68 @@ kimi login
|
|||
kimi acp
|
||||
```
|
||||
|
||||
### `kimi daemon`
|
||||
### `kimi server`
|
||||
|
||||
运行本地 daemon,通过 REST 与 WebSocket 暴露 Kimi Code 能力,并从同一 origin 托管 web UI。默认情况下,该命令会在后台启动 daemon,等待健康检查通过,打印访问端点和日志路径后退出。如果 daemon 已经在运行,命令会报告现有进程,而不是重复启动。
|
||||
运行并管理本地 Kimi 服务 —— 同一个进程同时挂载 REST + WebSocket API 与 web UI。父命令拆成前台入口 (`run`) 与 OS 级生命周期管理 (`install`、`uninstall`、`start`、`stop`、`restart`、`status`)。前台命令**不会**隐式 spawn 后台进程;只有显式 `install` + `start` 才会让服务被 OS 接管。
|
||||
|
||||
```sh
|
||||
kimi daemon
|
||||
kimi server run # 前台运行(日志输出到当前终端)
|
||||
kimi server install # 注册到 launchd / systemd / schtasks
|
||||
kimi server start # 启动 OS 管理的服务
|
||||
kimi server status # 查看安装与运行状态
|
||||
```
|
||||
|
||||
#### `kimi server run`
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--host <host>` | daemon 绑定地址,默认 `127.0.0.1` |
|
||||
| `--port <port>` | daemon 绑定端口,默认 `7878` |
|
||||
| `--log-level <level>` | daemon 日志级别,默认 `info` |
|
||||
| `--foreground` | 保持 daemon 运行在当前终端中,而不是后台启动 |
|
||||
| `--host <host>` | 绑定地址;默认 `127.0.0.1` |
|
||||
| `--port <port>` | 绑定端口;默认 `7878` |
|
||||
| `--log-level <level>` | 日志级别;默认 `info` |
|
||||
| `--debug-endpoints` | 挂载 `/api/v1/debug/*` 调试路由(默认关闭) |
|
||||
| `--open` | 服务健康后用默认浏览器打开 web UI |
|
||||
|
||||
需要在当前终端查看日志,或由其他进程管理 daemon 生命周期时,使用前台模式:
|
||||
`kimi server run` 不会返回——保持挂在当前终端,在 `SIGINT` / `SIGTERM` 时干净退出。后台运行请走下面的 OS 服务方式。
|
||||
|
||||
```sh
|
||||
kimi daemon --foreground
|
||||
```
|
||||
#### `kimi server install`
|
||||
|
||||
### `kimi web`
|
||||
把服务注册成 OS 管理的进程,开机自启、崩溃后自动重启。根据当前平台选择对应后端:
|
||||
|
||||
打开由 daemon 托管的浏览器界面。不传 `--daemon-host` 时,`kimi web` 会先检查本地 daemon `http://127.0.0.1:7878`;如果尚未运行,则先在后台启动 `kimi daemon`,然后打开 daemon URL。web 资源和 `/api/v1/*` 路由由 daemon 在同一 origin 上提供。
|
||||
|
||||
```sh
|
||||
kimi web
|
||||
```
|
||||
- **macOS**:写 LaunchAgent plist 到 `~/Library/LaunchAgents/ai.moonshot.kimi-server.plist`,并通过 `launchctl bootstrap gui/<uid>` 启动。
|
||||
- **Linux**:写 `--user` systemd unit 到 `~/.config/systemd/user/kimi-server.service`,并执行 `systemctl --user enable --now`。
|
||||
- **Windows**:通过 `schtasks /Create /XML` 注册名为 `KimiServer` 的计划任务。
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--host <host>` | 启动本地 daemon 时使用的绑定地址,默认 `127.0.0.1` |
|
||||
| `--port <port>` | 启动本地 daemon 时使用的端口,默认 `7878` |
|
||||
| `--daemon-host <url>` | 直接打开已有 daemon,而不是启动本地 daemon |
|
||||
| `--no-open` | 不自动打开浏览器 |
|
||||
| `--host <host>` | 被托管的服务绑定地址;默认 `127.0.0.1` |
|
||||
| `--port <port>` | 被托管的服务绑定端口;默认 `7878` |
|
||||
| `--log-level <level>` | 写入生成 unit 的日志级别 |
|
||||
| `--force` | 已安装时强制覆盖 |
|
||||
| `--json` | 用 JSON 替代人类可读输出 |
|
||||
|
||||
当 daemon 运行在另一台机器上,或由单独的进程管理时,让 web UI 指向已有 daemon。此模式不会启动任何本地服务:
|
||||
选定的 host / port / log-level 会写入 `~/.kimi-code/server/install.json`,即便服务停掉 `kimi server status` 也能读到。
|
||||
|
||||
#### 生命周期子命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --- | --- |
|
||||
| `kimi server uninstall` | 停止并移除 OS 服务定义。幂等。 |
|
||||
| `kimi server start` | 启动 OS 管理的服务。未安装时会报错。 |
|
||||
| `kimi server stop` | 停止 OS 管理的服务。 |
|
||||
| `kimi server restart` | 重启 OS 管理的服务。 |
|
||||
| `kimi server status` | 打印 installed / running / pid / port / log-path;`--json` 用于脚本。 |
|
||||
|
||||
#### `kimi web`
|
||||
|
||||
`kimi server run --open` 的别名:前台跑服务,健康后立即用默认浏览器打开 web UI。加 `--no-open` 等价于纯 `kimi server run`。
|
||||
|
||||
```sh
|
||||
kimi web --daemon-host=http://daemon.example.test:7878
|
||||
kimi web # 前台 + 自动打开浏览器
|
||||
kimi web --no-open # 等价于 `kimi server run`
|
||||
```
|
||||
|
||||
`--host`、`--port`、`--log-level`、`--debug-endpoints` 与 `kimi server run` 完全一致。
|
||||
|
||||
### `kimi doctor`
|
||||
|
||||
校验 `config.toml` 和 `tui.toml`,不会启动 TUI,也不会修改任一文件。默认检查 `KIMI_CODE_HOME` 下的文件;未设置该环境变量时检查 `~/.kimi-code`。默认路径缺失时会显示为跳过,因为内置默认值仍可生效。
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
workspacePaths = [
|
||||
./packages/acp-adapter
|
||||
./packages/agent-core
|
||||
./packages/daemon
|
||||
./packages/server
|
||||
./packages/kaos
|
||||
./packages/kosong
|
||||
./packages/migration-legacy
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
workspaceNames = [
|
||||
"@moonshot-ai/acp-adapter"
|
||||
"@moonshot-ai/agent-core"
|
||||
"@moonshot-ai/daemon"
|
||||
"@moonshot-ai/server"
|
||||
"@moonshot-ai/kaos"
|
||||
"@moonshot-ai/kosong"
|
||||
"@moonshot-ai/migration-legacy"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"build:packages": "pnpm -r --filter './packages/*' run build",
|
||||
"dev:cli": "pnpm -C apps/kimi-code run dev",
|
||||
"dev:web": "pnpm -C apps/kimi-web run dev",
|
||||
"dev:daemon": "pnpm -C apps/kimi-code run dev:daemon",
|
||||
"dev:server": "pnpm -C apps/kimi-code run dev:server",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* **Startup-timing constraint (plan §4.5)**: the default handler intentionally
|
||||
* does NOT resolve `ILogService` at module-load time. At import time the DI
|
||||
* container is empty — touching `ILogService` would NPE. Instead, the default
|
||||
* stays as a plain `console.error` until the daemon's `startDaemon` later
|
||||
* stays as a plain `console.error` until the daemon's `startServer` later
|
||||
* calls `setUnexpectedErrorHandler(...)` with a logger-bound version (once
|
||||
* `ILogger` has been resolved from the accessor). Until that handoff,
|
||||
* exceptions routed here surface on stderr — visible but unstructured.
|
||||
|
|
@ -29,7 +29,7 @@ let currentHandler: UnexpectedErrorHandler = defaultHandler;
|
|||
|
||||
/**
|
||||
* Install a new global handler. Replaces any previously-installed handler.
|
||||
* `startDaemon` calls this once after the DI container is fully wired so
|
||||
* `startServer` calls this once after the DI container is fully wired so
|
||||
* later exceptions route through `ILogService` instead of stderr.
|
||||
*/
|
||||
export function setUnexpectedErrorHandler(handler: UnexpectedErrorHandler): void {
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
# daemon-e2e Agent Guide
|
||||
|
||||
This file contains package-local rules for `packages/daemon-e2e`.
|
||||
|
||||
## Testing Principle
|
||||
|
||||
- Keep observability inside each daemon-e2e case. Do not add a separate "observable" test or scenario as a substitute for making the existing cases explain what they drove and what the daemon returned.
|
||||
- Every live daemon case should print structured, case-scoped details for the flow it exercises: key REST requests, response envelopes or unwrapped responses, WebSocket handshakes / acks / replay summaries, prompt terminal frames, and error envelopes.
|
||||
- Prefer a shared logging helper over ad hoc `console.log` formatting. Logs must be visible for passing Vitest cases, so write through stdout when Vitest would otherwise capture console output.
|
||||
- Keep logs factual and diagnostic. Print enough detail to debug the wire contract, but avoid unrelated narration.
|
||||
|
||||
## Workflow
|
||||
|
||||
- When adding or changing a daemon-e2e case, update that case's observability at the same time.
|
||||
- Do not add a new scenario solely to print data that an existing scenario or Vitest case should already expose.
|
||||
- Run the relevant daemon-e2e tests against `DAEMON_URL=http://127.0.0.1:7878` when a daemon is available, and confirm the output includes the case-scoped diagnostic blocks.
|
||||
- Run Docker e2e with `pnpm --filter @moonshot-ai/daemon-e2e docker:e2e`; each run must derive its Docker runner name/namespace from the current workspace to avoid cross-workspace conflicts.
|
||||
|
||||
## Command Reference
|
||||
|
||||
- Start a local daemon from the repo root before validating live cases: `pnpm dev:daemon`.
|
||||
- Run only the undo helper/live e2e coverage: `DAEMON_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/daemon-e2e test -- test/client.test.ts -t undoSession`.
|
||||
- Run the full daemon client Vitest file: `DAEMON_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/daemon-e2e test -- test/client.test.ts`.
|
||||
- Run all daemon-e2e Vitest tests: `DAEMON_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/daemon-e2e test`.
|
||||
- Run all executable scenarios against the local daemon: `DAEMON_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/daemon-e2e test:scenarios`.
|
||||
- Run type checking for this package: `pnpm --filter @moonshot-ai/daemon-e2e typecheck`.
|
||||
|
|
@ -4,7 +4,7 @@ import { metaResponseSchema, type MetaResponse } from '../rest/meta';
|
|||
|
||||
describe('metaResponseSchema', () => {
|
||||
const sample = {
|
||||
daemon_version: '0.1.0',
|
||||
server_version: '0.1.0',
|
||||
capabilities: {
|
||||
websocket: true,
|
||||
file_upload: true,
|
||||
|
|
@ -12,15 +12,15 @@ describe('metaResponseSchema', () => {
|
|||
mcp: true,
|
||||
background_tasks: true,
|
||||
},
|
||||
daemon_id: '01HXYZABCDEFGHJKMNPQRSTVWX',
|
||||
server_id: '01HXYZABCDEFGHJKMNPQRSTVWX',
|
||||
started_at: '2026-06-04T10:30:00.000Z',
|
||||
};
|
||||
|
||||
it('round-trips a well-formed payload', () => {
|
||||
const parsed: MetaResponse = metaResponseSchema.parse(sample);
|
||||
expect(parsed.daemon_version).toBe('0.1.0');
|
||||
expect(parsed.server_version).toBe('0.1.0');
|
||||
expect(parsed.capabilities.websocket).toBe(true);
|
||||
expect(parsed.daemon_id).toBe('01HXYZABCDEFGHJKMNPQRSTVWX');
|
||||
expect(parsed.server_id).toBe('01HXYZABCDEFGHJKMNPQRSTVWX');
|
||||
expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z');
|
||||
});
|
||||
|
||||
|
|
@ -33,8 +33,8 @@ describe('metaResponseSchema', () => {
|
|||
expect(parsed.started_at).toBe('2026-06-04T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects missing daemon_version', () => {
|
||||
const { daemon_version: _omit, ...rest } = sample;
|
||||
it('rejects missing server_version', () => {
|
||||
const { server_version: _omit, ...rest } = sample;
|
||||
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -43,8 +43,8 @@ describe('metaResponseSchema', () => {
|
|||
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing daemon_id', () => {
|
||||
const { daemon_id: _omit, ...rest } = sample;
|
||||
it('rejects missing server_id', () => {
|
||||
const { server_id: _omit, ...rest } = sample;
|
||||
expect(metaResponseSchema.safeParse(rest).success).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -61,8 +61,8 @@ describe('metaResponseSchema', () => {
|
|||
expect(metaResponseSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty daemon_version string', () => {
|
||||
const bad = { ...sample, daemon_version: '' };
|
||||
it('rejects an empty server_version string', () => {
|
||||
const bad = { ...sample, server_version: '' };
|
||||
expect(metaResponseSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* GET /v1/meta
|
||||
* Reply: MetaResponse {
|
||||
* daemon_version,
|
||||
* server_version,
|
||||
* capabilities,
|
||||
* daemon_id,
|
||||
* server_id,
|
||||
* started_at
|
||||
* }
|
||||
*/
|
||||
|
|
@ -22,9 +22,9 @@ export const metaCapabilitiesSchema = z.object({
|
|||
export type MetaCapabilities = z.infer<typeof metaCapabilitiesSchema>;
|
||||
|
||||
export const metaResponseSchema = z.object({
|
||||
daemon_version: z.string().min(1),
|
||||
server_version: z.string().min(1),
|
||||
capabilities: metaCapabilitiesSchema,
|
||||
daemon_id: z.string().min(1),
|
||||
server_id: z.string().min(1),
|
||||
started_at: isoDateTimeSchema,
|
||||
});
|
||||
|
||||
|
|
|
|||
26
packages/server-e2e/AGENTS.md
Normal file
26
packages/server-e2e/AGENTS.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# server-e2e Agent Guide
|
||||
|
||||
This file contains package-local rules for `packages/server-e2e`.
|
||||
|
||||
## Testing Principle
|
||||
|
||||
- Keep observability inside each server-e2e case. Do not add a separate "observable" test or scenario as a substitute for making the existing cases explain what they drove and what the server returned.
|
||||
- Every live server case should print structured, case-scoped details for the flow it exercises: key REST requests, response envelopes or unwrapped responses, WebSocket handshakes / acks / replay summaries, prompt terminal frames, and error envelopes.
|
||||
- Prefer a shared logging helper over ad hoc `console.log` formatting. Logs must be visible for passing Vitest cases, so write through stdout when Vitest would otherwise capture console output.
|
||||
- Keep logs factual and diagnostic. Print enough detail to debug the wire contract, but avoid unrelated narration.
|
||||
|
||||
## Workflow
|
||||
|
||||
- When adding or changing a server-e2e case, update that case's observability at the same time.
|
||||
- Do not add a new scenario solely to print data that an existing scenario or Vitest case should already expose.
|
||||
- Run the relevant server-e2e tests against `KIMI_SERVER_URL=http://127.0.0.1:7878` when a server is available, and confirm the output includes the case-scoped diagnostic blocks.
|
||||
- Run Docker e2e with `pnpm --filter @moonshot-ai/server-e2e docker:e2e`; each run must derive its Docker runner name/namespace from the current workspace to avoid cross-workspace conflicts.
|
||||
|
||||
## Command Reference
|
||||
|
||||
- Start a local server from the repo root before validating live cases: `pnpm dev:server`.
|
||||
- Run only the undo helper/live e2e coverage: `KIMI_SERVER_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/server-e2e test -- test/client.test.ts -t undoSession`.
|
||||
- Run the full server client Vitest file: `KIMI_SERVER_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/server-e2e test -- test/client.test.ts`.
|
||||
- Run all server-e2e Vitest tests: `KIMI_SERVER_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/server-e2e test`.
|
||||
- Run all executable scenarios against the local server: `KIMI_SERVER_URL=http://127.0.0.1:7878 pnpm --filter @moonshot-ai/server-e2e test:scenarios`.
|
||||
- Run type checking for this package: `pnpm --filter @moonshot-ai/server-e2e typecheck`.
|
||||
|
|
@ -1,27 +1,27 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# daemon-e2e docker-run image.
|
||||
# server-e2e docker-run image.
|
||||
#
|
||||
# This image layers daemon-e2e defaults on top of the repository daemon dev
|
||||
# This image layers server-e2e defaults on top of the repository server dev
|
||||
# image. Source code and node_modules are still provided by bind mounts from
|
||||
# scripts/run-docker-e2e.sh, so host edits are picked up without rebuilding.
|
||||
#
|
||||
# The launcher intentionally does not pass -p/--publish to docker run. The
|
||||
# daemon binds inside the container only, so this workflow can run alongside the
|
||||
# docker-compose.yml daemon that publishes host port 7878.
|
||||
# server binds inside the container only, so this workflow can run alongside the
|
||||
# docker-compose.yml server that publishes host port 7878.
|
||||
|
||||
ARG BASE_IMAGE=kimi-daemon:dev
|
||||
ARG BASE_IMAGE=kimi-server:dev
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ENV KIMI_CODE_HOME=/data/docker-e2e/kimi-code-home \
|
||||
DAEMON_URL=http://127.0.0.1:7878 \
|
||||
DAEMON_E2E_REPORT_DIR=/data/daemon-e2e-reports/docker/latest \
|
||||
KIMI_SERVER_URL=http://127.0.0.1:7878 \
|
||||
KIMI_SERVER_E2E_REPORT_DIR=/data/server-e2e-reports/docker/latest \
|
||||
TMPDIR=/data/docker-e2e/tmp \
|
||||
TERM=xterm-256color \
|
||||
TZ=Asia/Shanghai \
|
||||
npm_config_store_dir=/workspace/kimi-code/node_modules/.pnpm-store \
|
||||
npm_config_package_import_method=copy
|
||||
|
||||
WORKDIR /workspace/kimi-code/packages/daemon-e2e
|
||||
WORKDIR /workspace/kimi-code/packages/server-e2e
|
||||
|
||||
CMD ["bash"]
|
||||
|
|
@ -1,29 +1,29 @@
|
|||
# @moonshot-ai/daemon-e2e
|
||||
# @moonshot-ai/server-e2e
|
||||
|
||||
Wire-level test client for the kimi-code daemon (HTTP + WS). This package is
|
||||
Wire-level test client for the kimi-code server (HTTP + WS). This package is
|
||||
**private** — it ships scenario scripts that double as smoke tests and a small
|
||||
typed `DaemonClient` you can reuse in vitest e2e files.
|
||||
|
||||
## When to use this
|
||||
|
||||
- You want to drive a real, running daemon process from a Node script and
|
||||
- You want to drive a real, running server process from a Node script and
|
||||
observe HTTP + WS behavior end to end.
|
||||
- You're writing a vitest e2e that covers daemon REST + WS lifecycle as a
|
||||
whole — not a single in-process unit (those belong in `packages/daemon/test/`).
|
||||
- You're writing a vitest e2e that covers server REST + WS lifecycle as a
|
||||
whole — not a single in-process unit (those belong in `packages/server/test/`).
|
||||
- You need a reference for the wire shape of approval / question / events.
|
||||
|
||||
## When NOT to use this
|
||||
|
||||
- You're testing the WS gateway in isolation — keep using
|
||||
`packages/daemon/test/ws-*.e2e.test.ts` (in-process `startDaemon` boots are
|
||||
faster and assert on the daemon's internal services directly).
|
||||
- You want a typed in-process facade over the daemon for user-facing code —
|
||||
`packages/server/test/ws-*.e2e.test.ts` (in-process `startServer` boots are
|
||||
faster and assert on the server's internal services directly).
|
||||
- You want a typed in-process facade over the server for user-facing code —
|
||||
use `@moonshot-ai/node-sdk` instead (`KimiHarness`, `Session`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { DaemonClient } from '@moonshot-ai/daemon-e2e';
|
||||
import { DaemonClient } from '@moonshot-ai/server-e2e';
|
||||
|
||||
const client = new DaemonClient(); // http://127.0.0.1:7878 by default
|
||||
|
||||
|
|
@ -41,46 +41,49 @@ await client.close();
|
|||
await client.deleteSession(session.id);
|
||||
```
|
||||
|
||||
> The exported facade is still spelled `DaemonClient` to keep the diff small;
|
||||
> conceptually it is the **server** client.
|
||||
|
||||
## Scripts
|
||||
|
||||
```sh
|
||||
pnpm --filter @moonshot-ai/daemon-e2e typecheck
|
||||
pnpm --filter @moonshot-ai/daemon-e2e test # vitest self-tests
|
||||
pnpm --filter @moonshot-ai/daemon-e2e test:scenarios # run every scenarios/*.ts
|
||||
pnpm --filter @moonshot-ai/daemon-e2e docker:e2e # run daemon + scenarios in docker
|
||||
pnpm --filter @moonshot-ai/server-e2e typecheck
|
||||
pnpm --filter @moonshot-ai/server-e2e test # vitest self-tests
|
||||
pnpm --filter @moonshot-ai/server-e2e test:scenarios # run every scenarios/*.ts
|
||||
pnpm --filter @moonshot-ai/server-e2e docker:e2e # run server + scenarios in docker
|
||||
```
|
||||
|
||||
Both `test` and `test:scenarios` require a running daemon (set `DAEMON_URL`
|
||||
Both `test` and `test:scenarios` require a running server (set `KIMI_SERVER_URL`
|
||||
to override the default `http://127.0.0.1:7878`). The vitest suite skips its
|
||||
live-dependent cases when no daemon is reachable so CI stays green. Scenarios
|
||||
live-dependent cases when no server is reachable so CI stays green. Scenarios
|
||||
are run via `tsx` because they execute TypeScript directly.
|
||||
|
||||
Both commands write a browser-readable report to
|
||||
`packages/daemon-e2e/reports/latest/index.html` (override with
|
||||
`DAEMON_E2E_REPORT_DIR`). The report groups events by case and shows a compact
|
||||
`packages/server-e2e/reports/latest/index.html` (override with
|
||||
`KIMI_SERVER_E2E_REPORT_DIR`). The report groups events by case and shows a compact
|
||||
timeline of case logs, HTTP request / response envelopes, WebSocket frames, and
|
||||
test results. JSON payloads are kept in collapsed detail blocks so the terminal
|
||||
can stay concise while the full wire trace remains available.
|
||||
|
||||
`docker:e2e` builds `kimi-daemon:dev` from the root `Dockerfile`, layers
|
||||
`packages/daemon-e2e/Dockerfile` on top, then runs a one-shot Docker container.
|
||||
The container starts the daemon on container-local `127.0.0.1:7878` and runs
|
||||
`docker:e2e` builds `kimi-server:dev` from the root `Dockerfile`, layers
|
||||
`packages/server-e2e/Dockerfile` on top, then runs a one-shot Docker container.
|
||||
The container starts the server on container-local `127.0.0.1:7878` and runs
|
||||
`pnpm test:scenarios` in the same container. The launcher intentionally does
|
||||
not pass `-p` / `--publish`, so it does not expose a daemon port on the host and
|
||||
can coexist with the `docker-compose.yml` daemon that publishes host port 7878.
|
||||
not pass `-p` / `--publish`, so it does not expose a server port on the host and
|
||||
can coexist with the `docker-compose.yml` server that publishes host port 7878.
|
||||
Reports are written under
|
||||
`~/.kimi-code-daemon-dev/daemon-e2e-reports/docker/<run-id>/latest/index.html`;
|
||||
the daemon log is written beside them as `daemon.log`.
|
||||
`~/.kimi-code-server-dev/server-e2e-reports/docker/<run-id>/latest/index.html`;
|
||||
the server log is written beside them as `server.log`.
|
||||
|
||||
The Docker workflow uses an isolated KIMI home at
|
||||
`~/.kimi-code-daemon-dev/docker-e2e/<run-id>/kimi-code-home` to avoid sharing
|
||||
daemon locks with Compose. `<run-id>` is deterministic by default:
|
||||
`~/.kimi-code-server-dev/docker-e2e/<run-id>/kimi-code-home` to avoid sharing
|
||||
server locks with Compose. `<run-id>` is deterministic by default:
|
||||
`<repo-basename>-<cksum-of-repo-path>`, so different worktrees do not collide.
|
||||
On first run it seeds `config.toml` and `credentials/` from
|
||||
`~/.kimi-code-daemon-dev/kimi-home/kimi-code-home` when those files exist.
|
||||
Override the namespace with `DAEMON_E2E_RUN_ID`, or override paths with
|
||||
`DAEMON_E2E_STATE_ROOT`, `DAEMON_E2E_KIMI_HOME_HOST`,
|
||||
`DAEMON_E2E_SEED_KIMI_HOME_HOST`, or `DAEMON_E2E_REPORT_DIR_HOST`.
|
||||
`~/.kimi-code-server-dev/kimi-home/kimi-code-home` when those files exist.
|
||||
Override the namespace with `KIMI_SERVER_E2E_RUN_ID`, or override paths with
|
||||
`KIMI_SERVER_E2E_STATE_ROOT`, `KIMI_SERVER_E2E_KIMI_HOME_HOST`,
|
||||
`KIMI_SERVER_E2E_SEED_KIMI_HOME_HOST`, or `KIMI_SERVER_E2E_REPORT_DIR_HOST`.
|
||||
|
||||
## Public API summary
|
||||
|
||||
|
|
@ -98,8 +101,8 @@ See `scenarios/README.md` for the executable script catalog and conventions.
|
|||
|
||||
## Scope notes
|
||||
|
||||
- **No in-process daemon bootstrap** — point at an already-running daemon.
|
||||
An in-process `startDaemon(port:0)` helper is intentionally out of scope.
|
||||
- **No in-process server bootstrap** — point at an already-running server.
|
||||
An in-process `startServer(port:0)` helper is intentionally out of scope.
|
||||
- **No auto-discovery** — the WS endpoint is hard-coded to `${apiPrefix}/ws`.
|
||||
Override via `apiPrefix` only.
|
||||
- **Not published** — `private: true`. Internal tooling only.
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@moonshot-ai/daemon-e2e",
|
||||
"name": "@moonshot-ai/server-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Wire-level E2E test client for the kimi-code daemon (HTTP + WS). Powers scenario scripts and self-tests against a real daemon process.",
|
||||
"description": "Wire-level E2E test client for the kimi-code server (HTTP + WS). Powers scenario scripts and self-tests against a real server process.",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/MoonshotAI/kimi-code.git",
|
||||
"directory": "packages/daemon-e2e"
|
||||
"directory": "packages/server-e2e"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/MoonshotAI/kimi-code/issues"
|
||||
|
|
@ -4,21 +4,21 @@
|
|||
* poll messages, assert the assistant text contains the expected token.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/01-create-and-send.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/01-create-and-send.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure or daemon error
|
||||
* 1 — assertion failure or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DaemonClient } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const EXPECTED_TOKEN = 'OK';
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
let sid: string | undefined;
|
||||
try {
|
||||
|
|
@ -10,21 +10,21 @@
|
|||
* - The assistant message also surfaced the canary.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/02-tool-call-with-approval.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/02-tool-call-with-approval.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure, timeout, or daemon error
|
||||
* 1 — assertion failure, timeout, or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DaemonClient } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const CANARY = `HELLO_FROM_AUDIT_${process.pid}`;
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
let sid: string | undefined;
|
||||
let approvalCount = 0;
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* from the history list" wire-level walkthrough.
|
||||
*
|
||||
* This is the worst-case page-load sequence a web client runs while the
|
||||
* daemon is already up. The phases (REST.md §3, WS.md §3) are:
|
||||
* server is already up. The phases (REST.md §3, WS.md §3) are:
|
||||
*
|
||||
* Phase 0 environment probes GET /healthz, /meta, /auth
|
||||
* Phase 1 open WS BEFORE history server_hello → client_hello(cursors) → ack
|
||||
|
|
@ -16,27 +16,27 @@
|
|||
* in the ring buffer, but REST `/messages` only reflects flushed-to-store
|
||||
* content, so the "in-flight" delta would be invisible to the UI.
|
||||
*
|
||||
* What this scenario exercises end-to-end against a running daemon:
|
||||
* - All three Phase 0 endpoints respond and `meta.daemon_id` is non-empty.
|
||||
* What this scenario exercises end-to-end against a running server:
|
||||
* - All three Phase 0 endpoints respond and `meta.server_id` is non-empty.
|
||||
* - A first WS session completes one prompt; we record the current ring-buffer
|
||||
* seq for the session.
|
||||
* - We close the WS, open a fresh one, and on `client_hello` pass
|
||||
* `cursors: { [sid]: { seq: currentSeq } }` — the daemon should ack
|
||||
* `cursors: { [sid]: { seq: currentSeq } }` — the server should ack
|
||||
* with `accepted_subscriptions: [sid]`, `resync_required: []`, and NOT
|
||||
* replay any events (we are caught up).
|
||||
* - We then open a THIRD connection, this time with
|
||||
* `cursors: { [sid]: { seq: 0 } }` — the daemon should replay every
|
||||
* `cursors: { [sid]: { seq: 0 } }` — the server should replay every
|
||||
* durable event (seq 1..N) BEFORE the ack lands.
|
||||
* - Phase 2 REST snapshot reflects the user + assistant messages persisted
|
||||
* during the first run.
|
||||
* - Phase 5: a new prompt over the third connection delivers events on WS.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/03-refresh-replay.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/03-refresh-replay.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure, timeout, or daemon error
|
||||
* 1 — assertion failure, timeout, or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ import { DaemonClient, WsClient, type AnyFrame } from '../src/index';
|
|||
import { fetchWithReport } from '../src/report';
|
||||
import { WebSocket as WsWebSocket } from 'ws';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000;
|
||||
const PROMPT_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -56,8 +56,8 @@ interface Envelope<T> {
|
|||
}
|
||||
|
||||
interface MetaResponse {
|
||||
daemon_id: string;
|
||||
daemon_version: string;
|
||||
server_id: string;
|
||||
server_version: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ async function openSocketWithHello({
|
|||
sid: string;
|
||||
lastSeq?: number;
|
||||
}): Promise<OpenSocketResult> {
|
||||
const wsUrl = `${DAEMON_URL.replace(/^http/, 'ws')}${API_PREFIX}/ws`;
|
||||
const wsUrl = `${KIMI_SERVER_URL.replace(/^http/, 'ws')}${API_PREFIX}/ws`;
|
||||
const ws = new WsClient({ url: wsUrl, wsImpl: WsWebSocket, logger: () => {} });
|
||||
await ws.open();
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ async function openSocketWithHello({
|
|||
|
||||
// Replay events arrived before the ack — slice them out of the log by
|
||||
// position. (Anything appended to `log` AFTER this point is steady-state.)
|
||||
// The daemon emits agent-core event types without an `event.` prefix
|
||||
// The server emits agent-core event types without an `event.` prefix
|
||||
// (`turn.started`, `assistant.delta`, …), so we filter on the structural
|
||||
// shape (has `seq`, has `session_id`, isn't a system frame) instead of a
|
||||
// type prefix.
|
||||
|
|
@ -149,28 +149,28 @@ async function openSocketWithHello({
|
|||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`▶ daemon at ${DAEMON_URL}`);
|
||||
console.log(`▶ server at ${KIMI_SERVER_URL}`);
|
||||
|
||||
// ── Phase 0 ─────────────────────────────────────────────────────────────
|
||||
const health = await fetchEnvelope<{ ok: boolean }>(`${DAEMON_URL}${API_PREFIX}/healthz`);
|
||||
const health = await fetchEnvelope<{ ok: boolean }>(`${KIMI_SERVER_URL}${API_PREFIX}/healthz`);
|
||||
assert.equal(health.ok, true, 'healthz did not return ok=true');
|
||||
|
||||
const meta = await fetchEnvelope<MetaResponse>(`${DAEMON_URL}${API_PREFIX}/meta`);
|
||||
assert.ok(typeof meta.daemon_id === 'string' && meta.daemon_id.length > 0, 'missing daemon_id');
|
||||
const meta = await fetchEnvelope<MetaResponse>(`${KIMI_SERVER_URL}${API_PREFIX}/meta`);
|
||||
assert.ok(typeof meta.server_id === 'string' && meta.server_id.length > 0, 'missing server_id');
|
||||
assert.ok(typeof meta.started_at === 'string', 'missing started_at');
|
||||
assert.ok(typeof meta.daemon_version === 'string', 'missing daemon_version');
|
||||
const firstDaemonId = meta.daemon_id;
|
||||
console.log(`▶ phase 0: daemon_id=${firstDaemonId} version=${meta.daemon_version}`);
|
||||
assert.ok(typeof meta.server_version === 'string', 'missing server_version');
|
||||
const firstServerId = meta.server_id;
|
||||
console.log(`▶ phase 0: server_id=${firstServerId} version=${meta.server_version}`);
|
||||
|
||||
const auth = await fetchEnvelope<{ ready: boolean; providers_count: number }>(
|
||||
`${DAEMON_URL}${API_PREFIX}/auth`,
|
||||
`${KIMI_SERVER_URL}${API_PREFIX}/auth`,
|
||||
);
|
||||
assert.equal(typeof auth.ready, 'boolean', 'auth.ready missing');
|
||||
assert.equal(typeof auth.providers_count, 'number', 'auth.providers_count missing');
|
||||
console.log(`▶ phase 0: auth.ready=${auth.ready}`);
|
||||
|
||||
// ── Initial flow: create + drive a prompt to populate the ring buffer ───
|
||||
const initial = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const initial = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
let sid: string | undefined;
|
||||
try {
|
||||
const session = await initial.createSession({ metadata: { cwd: process.cwd() } });
|
||||
|
|
@ -202,8 +202,8 @@ async function main() {
|
|||
await initial.close();
|
||||
|
||||
// ── Phase 0 again — simulate a browser refresh (cheap re-probe) ──────
|
||||
const meta2 = await fetchEnvelope<MetaResponse>(`${DAEMON_URL}${API_PREFIX}/meta`);
|
||||
assert.equal(meta2.daemon_id, firstDaemonId, 'daemon_id changed mid-scenario — daemon restarted?');
|
||||
const meta2 = await fetchEnvelope<MetaResponse>(`${KIMI_SERVER_URL}${API_PREFIX}/meta`);
|
||||
assert.equal(meta2.server_id, firstServerId, 'server_id changed mid-scenario — server restarted?');
|
||||
|
||||
// ── Phase 1 (refresh #1): caught-up reconnect — no replay expected ───
|
||||
const caughtUp = await openSocketWithHello({ sid, lastSeq: maxSeq });
|
||||
|
|
@ -227,7 +227,7 @@ async function main() {
|
|||
console.log(`▶ refresh #1: caught-up; accepted=[${sid}], replayed=0`);
|
||||
await caughtUp.ws.close();
|
||||
|
||||
// ── Phase 1 (refresh #2): seq=0 — daemon replays the whole buffer ────
|
||||
// ── Phase 1 (refresh #2): seq=0 — server replays the whole buffer ────
|
||||
const replay = await openSocketWithHello({ sid, lastSeq: 0 });
|
||||
assert.equal(replay.ack.code, 0, `replay client_hello rejected: ${replay.ack.msg}`);
|
||||
const ackPayloadB = (replay.ack.payload ?? {}) as AckPayload;
|
||||
|
|
@ -243,7 +243,7 @@ async function main() {
|
|||
);
|
||||
assert.ok(
|
||||
replay.replayed.length > 0,
|
||||
`expected daemon to replay buffered events on last_seq=0, got 0`,
|
||||
`expected server to replay buffered events on last_seq=0, got 0`,
|
||||
);
|
||||
const seqs = replay.replayed
|
||||
.map((f) => f.seq)
|
||||
|
|
@ -277,7 +277,7 @@ async function main() {
|
|||
// No `listTasks` helper on HttpClient — call the endpoint directly to
|
||||
// verify it responds with the documented `{items: []}` envelope.
|
||||
const tasks = await fetchEnvelope<{ items: unknown[] }>(
|
||||
`${DAEMON_URL}${API_PREFIX}/sessions/${encodeURIComponent(sid)}/tasks`,
|
||||
`${KIMI_SERVER_URL}${API_PREFIX}/sessions/${encodeURIComponent(sid)}/tasks`,
|
||||
);
|
||||
assert.ok(Array.isArray(tasks.items), 'GET /tasks must return items[]');
|
||||
console.log(`▶ phase 2: messages=${messages.length} tasks=${tasks.items.length}`);
|
||||
|
|
@ -19,17 +19,17 @@
|
|||
* The old version of this scenario only watched `agent.status.updated`
|
||||
* frames — but a no-op submit and a redundant re-dispatch produce the same
|
||||
* WS surface, so "shadow held" couldn't be proven. This version asserts
|
||||
* directly against the daemon's `/debug` endpoints:
|
||||
* directly against the server's `/debug` endpoints:
|
||||
*
|
||||
* GET /api/v1/debug/prompts/{sid}/state -> shadow snapshot
|
||||
* GET /api/v1/debug/prompts/{sid}/dispatch-log -> ring buffer
|
||||
*
|
||||
* The daemon must be launched with `--debug-endpoints` (or
|
||||
* `startDaemon({debugEndpoints: true})`) for those routes to exist.
|
||||
* The server must be launched with `--debug-endpoints` (or
|
||||
* `startServer({debugEndpoints: true})`) for those routes to exist.
|
||||
*
|
||||
* Wire sequence:
|
||||
* 1. Submit with default controls. Capture the dispatch-log baseline:
|
||||
* this may be 0, or it may include setter calls if the daemon's
|
||||
* this may be 0, or it may include setter calls if the server's
|
||||
* config.toml defaults differ from the scenario's default body. Either
|
||||
* is correct — what matters is the deltas between phases.
|
||||
* 2. Submit with `plan_mode: true`. Expect EXACTLY 1 new entry of
|
||||
|
|
@ -45,18 +45,18 @@
|
|||
* content-only `POST /prompts` → expect +0 dispatches.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/04-stateless-controls.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/04-stateless-controls.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure or daemon error
|
||||
* 1 — assertion failure or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DaemonClient } from '../src/index';
|
||||
import { fetchWithReport } from '../src/report';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const PROMPT_TIMEOUT_MS = 60_000;
|
||||
|
||||
|
|
@ -82,15 +82,15 @@ interface DispatchEntry {
|
|||
/**
|
||||
* Fetch the debug shadow snapshot for `sid`. Returns `null` when the
|
||||
* session has not yet bootstrapped (i.e. no submit has run). Throws a
|
||||
* descriptive error when `/debug/*` isn't mounted (daemon launched
|
||||
* descriptive error when `/debug/*` isn't mounted (server launched
|
||||
* without `--debug-endpoints`).
|
||||
*/
|
||||
async function fetchDebugState(sid: string): Promise<DebugState | null> {
|
||||
const url = `${DAEMON_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/state`;
|
||||
const url = `${KIMI_SERVER_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/state`;
|
||||
const res = await fetchWithReport(url);
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
`GET ${url} -> 404. Did you start the daemon with --debug-endpoints?`,
|
||||
`GET ${url} -> 404. Did you start the server with --debug-endpoints?`,
|
||||
);
|
||||
}
|
||||
const env = (await res.json()) as Envelope<DebugState>;
|
||||
|
|
@ -102,11 +102,11 @@ async function fetchDebugState(sid: string): Promise<DebugState | null> {
|
|||
|
||||
/** Fetch the dispatch-log entries (newest-last). */
|
||||
async function fetchDispatchLog(sid: string): Promise<DispatchEntry[]> {
|
||||
const url = `${DAEMON_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/dispatch-log`;
|
||||
const url = `${KIMI_SERVER_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/dispatch-log`;
|
||||
const res = await fetchWithReport(url);
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
`GET ${url} -> 404. Did you start the daemon with --debug-endpoints?`,
|
||||
`GET ${url} -> 404. Did you start the server with --debug-endpoints?`,
|
||||
);
|
||||
}
|
||||
const env = (await res.json()) as Envelope<{ entries: DispatchEntry[] }>;
|
||||
|
|
@ -117,7 +117,7 @@ async function fetchDispatchLog(sid: string): Promise<DispatchEntry[]> {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
let sid: string | undefined;
|
||||
try {
|
||||
|
|
@ -129,7 +129,7 @@ async function main() {
|
|||
await client.subscribe(sid);
|
||||
|
||||
// ── Phase 1 — defaults, baseline ──────────────────────────────────────
|
||||
// We don't know whether the daemon's config.toml defaults match the
|
||||
// We don't know whether the server's config.toml defaults match the
|
||||
// scenario's defaults — they often don't, so this submit may legally
|
||||
// emit `setModel` / `setThinking` / `setPermission` against bootstrap.
|
||||
// Capture the resulting state + dispatch-log baseline; later phases
|
||||
|
|
@ -145,7 +145,7 @@ async function main() {
|
|||
logAfterPhase1 = await fetchDispatchLog(sid);
|
||||
assert.ok(state !== null, 'phase 1: shadow should be bootstrapped after first submit');
|
||||
// After phase 1 the shadow reflects the body we sent (defaults), so
|
||||
// permission/plan are pinned regardless of what the daemon's config
|
||||
// permission/plan are pinned regardless of what the server's config
|
||||
// started at — any divergent setters fired here landed in the log.
|
||||
assert.equal(state.planMode, false, `phase 1: shadow.planMode=${state.planMode}, want false`);
|
||||
assert.equal(state.permissionMode, 'manual', `phase 1: shadow.permissionMode=${state.permissionMode}, want manual`);
|
||||
|
|
@ -6,17 +6,17 @@
|
|||
* 1. `GET /fs:home` — picker landing payload (home + recent roots)
|
||||
* 2. `GET /fs:browse` — list child dirs of $HOME (sanity check the wire)
|
||||
* 3. `POST /workspaces { root }` — register a workspace on a fresh tmpdir
|
||||
* 4. `POST /sessions { workspace_id }` — daemon resolves cwd from workspace
|
||||
* 4. `POST /sessions { workspace_id }` — server resolves cwd from workspace
|
||||
* 5. `GET /sessions?workspace_id=` — fast-path filter returns just our session
|
||||
* 6. Round-trip a real prompt through that session (depends on DAEMON_AUTH)
|
||||
* 7. `DELETE /workspaces/{id}` — unregister (does NOT remove the session)
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/05-workspace.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/05-workspace.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure or daemon error
|
||||
* 1 — assertion failure or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
||||
|
|
@ -25,15 +25,15 @@ import { join } from 'node:path';
|
|||
|
||||
import { DaemonClient } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const EXPECTED_TOKEN = 'OK';
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
// Use a fresh tmpdir as the workspace root so the scenario doesn't pollute
|
||||
// the daemon's workspace registry with persistent entries. realpathSync()
|
||||
// matches what the picker / daemon registry surface (avoids macOS
|
||||
// the server's workspace registry with persistent entries. realpathSync()
|
||||
// matches what the picker / server registry surface (avoids macOS
|
||||
// `/var` ↔ `/private/var` mismatch with the session cwd).
|
||||
const wsRoot = realpathSync(mkdtempSync(join(tmpdir(), 'kimi-e2e-workspace-')));
|
||||
let workspaceId: string | undefined;
|
||||
|
|
@ -64,7 +64,7 @@ async function main() {
|
|||
assert.equal(workspace.session_count, 0, 'no sessions in fresh workspace');
|
||||
console.log(`▶ POST /workspaces → ${workspaceId} (root=${workspace.root})`);
|
||||
|
||||
// 4. Create a session BY workspace_id (daemon resolves cwd from the
|
||||
// 4. Create a session BY workspace_id (server resolves cwd from the
|
||||
// registered root). The Session response carries workspace_id verbatim.
|
||||
const session = await client.createSession({ workspace_id: workspaceId });
|
||||
sid = session.id;
|
||||
|
|
@ -80,7 +80,7 @@ async function main() {
|
|||
assert.equal(filteredSession.id, sid, 'session id round-trips through the filter');
|
||||
|
||||
// 6. Round-trip a real prompt. Skipped when DAEMON_AUTH isn't wired (the
|
||||
// daemon answers 401xx without an authenticated provider, which surfaces
|
||||
// server answers 401xx without an authenticated provider, which surfaces
|
||||
// in `submitAndWait` as a HTTP error — keep the scenario useful even
|
||||
// when run without provider creds).
|
||||
if (process.env['DAEMON_AUTH'] !== 'skip') {
|
||||
|
|
@ -13,11 +13,11 @@ import assert from 'node:assert/strict';
|
|||
|
||||
import { DaemonClient } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
|
||||
async function main() {
|
||||
console.log(`▶ daemon at ${DAEMON_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
console.log(`▶ server at ${KIMI_SERVER_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
try {
|
||||
const auth = await client.getAuth();
|
||||
|
|
@ -16,19 +16,19 @@ import { ErrorCode } from '@moonshot-ai/protocol';
|
|||
|
||||
import { DaemonClient, EnvelopeError } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const PROMPT_TIMEOUT_MS = 120_000;
|
||||
const PARENT_PROMPT_TOKEN = 'PARENT_SESSION_OK';
|
||||
const CHILD_PROMPT_TOKEN = 'CHILD_SESSION_OK';
|
||||
|
||||
async function main() {
|
||||
console.log(`▶ daemon at ${DAEMON_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
console.log(`▶ server at ${KIMI_SERVER_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
const sessions: string[] = [];
|
||||
|
||||
try {
|
||||
const parent = await client.createSession({
|
||||
title: 'daemon-e2e session children',
|
||||
title: 'server-e2e session children',
|
||||
metadata: { cwd: process.cwd(), scenario: '07-session-children' },
|
||||
});
|
||||
sessions.push(parent.id);
|
||||
|
|
@ -39,7 +39,7 @@ async function main() {
|
|||
await submitPromptAndAssertToken(client, parent.id, PARENT_PROMPT_TOKEN, 'parent');
|
||||
|
||||
const child = await client.createChild(parent.id, {
|
||||
title: 'daemon-e2e child',
|
||||
title: 'server-e2e child',
|
||||
metadata: { branch: 'direct-child' },
|
||||
});
|
||||
sessions.push(child.id);
|
||||
|
|
@ -51,7 +51,7 @@ async function main() {
|
|||
await submitPromptAndAssertToken(client, child.id, CHILD_PROMPT_TOKEN, 'child');
|
||||
|
||||
const grandchild = await client.createChild(child.id, {
|
||||
title: 'daemon-e2e grandchild',
|
||||
title: 'server-e2e grandchild',
|
||||
metadata: { branch: 'grandchild' },
|
||||
});
|
||||
sessions.push(grandchild.id);
|
||||
|
|
@ -14,9 +14,9 @@ import type { QuestionAnswer } from '@moonshot-ai/protocol';
|
|||
|
||||
import { DaemonClient, type AnyFrame } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const PROMPT_TIMEOUT_MS = 120_000;
|
||||
const CANARY = `DAEMON_E2E_PENDING_${process.pid}`;
|
||||
const CANARY = `KIMI_SERVER_E2E_PENDING_${process.pid}`;
|
||||
|
||||
interface ApprovalRequestedPayload {
|
||||
approval_id: string;
|
||||
|
|
@ -41,13 +41,13 @@ interface QuestionRequestedPayload {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`▶ daemon at ${DAEMON_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
console.log(`▶ server at ${KIMI_SERVER_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
let sid: string | undefined;
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
title: 'daemon-e2e pending recovery',
|
||||
title: 'server-e2e pending recovery',
|
||||
metadata: { cwd: process.cwd(), scenario: '08-pending-recovery' },
|
||||
});
|
||||
sid = session.id;
|
||||
|
|
@ -123,7 +123,7 @@ async function exerciseQuestionRecovery(client: DaemonClient, sid: string): Prom
|
|||
type: 'text',
|
||||
text: [
|
||||
'Use the AskUserQuestion tool now.',
|
||||
'Ask exactly one question: "Which daemon e2e recovery option should continue?"',
|
||||
'Ask exactly one question: "Which server e2e recovery option should continue?"',
|
||||
'Use header "E2E".',
|
||||
'Use two options: "Continue (Recommended)" and "Stop".',
|
||||
'After I answer, reply with the selected option label.',
|
||||
|
|
@ -13,7 +13,7 @@ import { ErrorCode } from '@moonshot-ai/protocol';
|
|||
|
||||
import { DaemonClient, EnvelopeError } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const SHORT_TIMEOUT_MS = 15_000;
|
||||
|
||||
const ONE_BY_ONE_PNG = Buffer.from(
|
||||
|
|
@ -22,14 +22,14 @@ const ONE_BY_ONE_PNG = Buffer.from(
|
|||
);
|
||||
|
||||
async function main() {
|
||||
console.log(`▶ daemon at ${DAEMON_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
console.log(`▶ server at ${KIMI_SERVER_URL}`);
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
const files: string[] = [];
|
||||
let sid: string | undefined;
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
title: 'daemon-e2e image file prompts',
|
||||
title: 'server-e2e image file prompts',
|
||||
metadata: { cwd: process.cwd(), scenario: '09-image-file-prompts' },
|
||||
});
|
||||
sid = session.id;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
/**
|
||||
* Scenario 10 — prompt queue steer.
|
||||
*
|
||||
* Verifies the daemon REST + WS contract for steering queued prompts into an
|
||||
* Verifies the server REST + WS contract for steering queued prompts into an
|
||||
* already-running turn:
|
||||
*
|
||||
* 1. create and subscribe to a session;
|
||||
|
|
@ -12,23 +12,23 @@
|
|||
* 5. assert the REST response, `prompt.steered` WS frame, steered content,
|
||||
* and final queue state.
|
||||
*
|
||||
* The daemon must be launched with `--debug-endpoints` because normal prompt
|
||||
* The server must be launched with `--debug-endpoints` because normal prompt
|
||||
* submission usually completes too quickly to deterministically hold an active
|
||||
* turn while queued prompts are submitted.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/10-prompt-queue-steer.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/10-prompt-queue-steer.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — pass
|
||||
* 1 — assertion failure or daemon error
|
||||
* 1 — assertion failure or server error
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DaemonClient, type AnyFrame } from '../src/index';
|
||||
import { fetchWithReport } from '../src/report';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const PROMPT_TIMEOUT_MS = 60_000;
|
||||
|
||||
|
|
@ -49,13 +49,13 @@ interface PromptSteeredPayload {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({ baseUrl: DAEMON_URL });
|
||||
const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL });
|
||||
|
||||
let sid: string | undefined;
|
||||
const promptIdsForCleanup: string[] = [];
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
title: 'daemon-e2e prompt queue steer',
|
||||
title: 'server-e2e prompt queue steer',
|
||||
metadata: { cwd: process.cwd(), scenario: 'prompt-queue-steer' },
|
||||
});
|
||||
sid = session.id;
|
||||
|
|
@ -75,7 +75,7 @@ async function main() {
|
|||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'First queued prompt for daemon steer.',
|
||||
text: 'First queued prompt for server steer.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -87,7 +87,7 @@ async function main() {
|
|||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Second queued prompt for daemon steer.',
|
||||
text: 'Second queued prompt for server steer.',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -117,8 +117,8 @@ async function main() {
|
|||
assert.equal(steered.activePromptId, active.prompt_id);
|
||||
assert.deepEqual(steered.promptIds, promptIds);
|
||||
assert.deepEqual(textContentOf(steered.content), [
|
||||
'First queued prompt for daemon steer.',
|
||||
'Second queued prompt for daemon steer.',
|
||||
'First queued prompt for server steer.',
|
||||
'Second queued prompt for server steer.',
|
||||
]);
|
||||
console.log(`▶ prompt.steered frame: ${JSON.stringify(frameForLog(steeredFrame))}`);
|
||||
|
||||
|
|
@ -189,7 +189,7 @@ async function injectActivePrompt(
|
|||
sid: string,
|
||||
body: { prompt_id: string },
|
||||
): Promise<{ prompt_id: string }> {
|
||||
const url = `${DAEMON_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/active`;
|
||||
const url = `${KIMI_SERVER_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/active`;
|
||||
const res = await fetchWithReport(url, {
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
||||
|
|
@ -197,7 +197,7 @@ async function injectActivePrompt(
|
|||
});
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
`POST ${url} -> 404. Did you start the daemon with --debug-endpoints?`,
|
||||
`POST ${url} -> 404. Did you start the server with --debug-endpoints?`,
|
||||
);
|
||||
}
|
||||
const envelope = (await res.json()) as Envelope<{ prompt_id: string }>;
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Scenarios
|
||||
|
||||
Each `.ts` file under this directory is an executable wire-level test of a
|
||||
single user-facing flow against a running daemon.
|
||||
single user-facing flow against a running server.
|
||||
|
||||
## Running
|
||||
|
||||
Default base URL is `http://127.0.0.1:7878`. Start a daemon (`pnpm dev:daemon`
|
||||
Default base URL is `http://127.0.0.1:7878`. Start a server (`pnpm dev:server`
|
||||
from the repo root) before invoking scenarios.
|
||||
|
||||
Scenarios import directly from the package's `src/*.ts` so they need a TS
|
||||
|
|
@ -13,18 +13,18 @@ loader. `tsx` (a workspace devDependency) works out of the box:
|
|||
|
||||
```sh
|
||||
# Single scenario
|
||||
npx tsx packages/daemon-e2e/scenarios/01-create-and-send.ts
|
||||
npx tsx packages/server-e2e/scenarios/01-create-and-send.ts
|
||||
|
||||
# All scenarios (sequential; first failure exits non-zero)
|
||||
pnpm --filter @moonshot-ai/daemon-e2e test:scenarios
|
||||
pnpm --filter @moonshot-ai/server-e2e test:scenarios
|
||||
|
||||
# Custom daemon URL
|
||||
DAEMON_URL=http://127.0.0.1:8080 npx tsx packages/daemon-e2e/scenarios/02-tool-call-with-approval.ts
|
||||
# Custom server URL
|
||||
KIMI_SERVER_URL=http://127.0.0.1:8080 npx tsx packages/server-e2e/scenarios/02-tool-call-with-approval.ts
|
||||
```
|
||||
|
||||
`test:scenarios` writes `reports/latest/index.html` with the scenario timeline,
|
||||
including stdout/stderr milestones, HTTP request / response envelopes, and
|
||||
WebSocket frames. Set `DAEMON_E2E_REPORT_DIR=/tmp/daemon-e2e-report` to write it
|
||||
WebSocket frames. Set `KIMI_SERVER_E2E_REPORT_DIR=/tmp/server-e2e-report` to write it
|
||||
somewhere else.
|
||||
|
||||
## Catalog
|
||||
|
|
@ -54,5 +54,5 @@ Conventions:
|
|||
greps for those prefixes when surfacing CI logs.
|
||||
- Default timeouts to 60s; tool-call scenarios may want 120s.
|
||||
- Use `client.onApprovalRequested` / `client.onQuestionAsked` to auto-resolve
|
||||
reverse-RPC requests — bypassing them risks 60s daemon-side timeouts that
|
||||
reverse-RPC requests — bypassing them risks 60s server-side timeouts that
|
||||
look like flaky scenarios.
|
||||
|
|
@ -3,24 +3,24 @@
|
|||
* Template scenario — copy-paste starting point.
|
||||
*
|
||||
* Usage:
|
||||
* DAEMON_URL=http://127.0.0.1:7878 npx tsx scenarios/_template.ts
|
||||
* KIMI_SERVER_URL=http://127.0.0.1:7878 npx tsx scenarios/_template.ts
|
||||
*
|
||||
* (`tsx` is a workspace devDependency; it handles the `.ts` imports below.
|
||||
* Plain `node` won't resolve them.)
|
||||
*
|
||||
* Each scenario:
|
||||
* 1. Constructs a `DaemonClient` pointed at the live daemon.
|
||||
* 1. Constructs a `DaemonClient` pointed at the live server.
|
||||
* 2. Opens an HTTP session and a WS connection.
|
||||
* 3. Subscribes to the session, drives some flow, asserts on the result.
|
||||
* 4. Cleans up — close the WS, delete the session.
|
||||
*/
|
||||
import { DaemonClient } from '../src/index';
|
||||
|
||||
const DAEMON_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
|
||||
async function main() {
|
||||
const client = new DaemonClient({
|
||||
baseUrl: DAEMON_URL,
|
||||
baseUrl: KIMI_SERVER_URL,
|
||||
logger: (level, msg, meta) => console.log(`[${level}] ${msg}`, meta ?? ''),
|
||||
});
|
||||
|
||||
|
|
@ -16,28 +16,28 @@ if [[ -z "${workspace_slug}" ]]; then
|
|||
workspace_slug="workspace"
|
||||
fi
|
||||
workspace_hash="$(printf '%s' "${REPO_ROOT}" | cksum | awk '{print $1}')"
|
||||
RUN_ID="${DAEMON_E2E_RUN_ID:-${workspace_slug}-${workspace_hash}}"
|
||||
RUN_ID="${KIMI_SERVER_E2E_RUN_ID:-${workspace_slug}-${workspace_hash}}"
|
||||
|
||||
BASE_IMAGE="${DAEMON_E2E_BASE_IMAGE:-kimi-daemon-e2e-base:${RUN_ID}}"
|
||||
IMAGE="${DAEMON_E2E_IMAGE:-kimi-daemon-e2e:${RUN_ID}}"
|
||||
CONTAINER="${DAEMON_E2E_CONTAINER:-kimi-daemon-e2e-${RUN_ID}}"
|
||||
STATE_ROOT="${DAEMON_E2E_STATE_ROOT:-${HOME}/.kimi-code-daemon-dev}"
|
||||
PORT="${DAEMON_E2E_DAEMON_PORT:-7878}"
|
||||
BASE_IMAGE="${KIMI_SERVER_E2E_BASE_IMAGE:-kimi-server-e2e-base:${RUN_ID}}"
|
||||
IMAGE="${KIMI_SERVER_E2E_IMAGE:-kimi-server-e2e:${RUN_ID}}"
|
||||
CONTAINER="${KIMI_SERVER_E2E_CONTAINER:-kimi-server-e2e-${RUN_ID}}"
|
||||
STATE_ROOT="${KIMI_SERVER_E2E_STATE_ROOT:-${HOME}/.kimi-code-server-dev}"
|
||||
PORT="${KIMI_SERVER_E2E_PORT:-7878}"
|
||||
|
||||
KIMI_HOME_HOST="${DAEMON_E2E_KIMI_HOME_HOST:-${STATE_ROOT}/docker-e2e/${RUN_ID}/kimi-code-home}"
|
||||
KIMI_HOME_HOST="${KIMI_SERVER_E2E_KIMI_HOME_HOST:-${STATE_ROOT}/docker-e2e/${RUN_ID}/kimi-code-home}"
|
||||
KIMI_HOME_CONTAINER="/data/docker-e2e/kimi-code-home"
|
||||
SEED_HOME_HOST="${DAEMON_E2E_SEED_KIMI_HOME_HOST:-${STATE_ROOT}/kimi-home/kimi-code-home}"
|
||||
SEED_HOME_HOST="${KIMI_SERVER_E2E_SEED_KIMI_HOME_HOST:-${STATE_ROOT}/kimi-home/kimi-code-home}"
|
||||
|
||||
if [[ -n "${DAEMON_E2E_REPORT_DIR_HOST:-}" ]]; then
|
||||
REPORT_DIR_HOST="${DAEMON_E2E_REPORT_DIR_HOST}"
|
||||
if [[ -n "${KIMI_SERVER_E2E_REPORT_DIR_HOST:-}" ]]; then
|
||||
REPORT_DIR_HOST="${KIMI_SERVER_E2E_REPORT_DIR_HOST}"
|
||||
REPORT_ROOT_HOST="$(dirname -- "${REPORT_DIR_HOST}")"
|
||||
REPORT_DIR_NAME="$(basename -- "${REPORT_DIR_HOST}")"
|
||||
else
|
||||
REPORT_ROOT_HOST="${DAEMON_E2E_REPORT_ROOT_HOST:-${STATE_ROOT}/daemon-e2e-reports/docker/${RUN_ID}}"
|
||||
REPORT_ROOT_HOST="${KIMI_SERVER_E2E_REPORT_ROOT_HOST:-${STATE_ROOT}/server-e2e-reports/docker/${RUN_ID}}"
|
||||
REPORT_DIR_NAME="latest"
|
||||
REPORT_DIR_HOST="${REPORT_ROOT_HOST}/${REPORT_DIR_NAME}"
|
||||
fi
|
||||
REPORT_ROOT_CONTAINER="/data/daemon-e2e-reports/docker"
|
||||
REPORT_ROOT_CONTAINER="/data/server-e2e-reports/docker"
|
||||
REPORT_DIR_CONTAINER="${REPORT_ROOT_CONTAINER}/${REPORT_DIR_NAME}"
|
||||
TMPDIR_CONTAINER="/data/docker-e2e/tmp"
|
||||
|
||||
|
|
@ -53,8 +53,8 @@ workspace_node_modules=(
|
|||
"docs:/workspace/kimi-code/docs/node_modules"
|
||||
"pkg_acp-adapter:/workspace/kimi-code/packages/acp-adapter/node_modules"
|
||||
"pkg_agent-core:/workspace/kimi-code/packages/agent-core/node_modules"
|
||||
"pkg_daemon:/workspace/kimi-code/packages/daemon/node_modules"
|
||||
"pkg_daemon-e2e:/workspace/kimi-code/packages/daemon-e2e/node_modules"
|
||||
"pkg_server:/workspace/kimi-code/packages/server/node_modules"
|
||||
"pkg_server-e2e:/workspace/kimi-code/packages/server-e2e/node_modules"
|
||||
"pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules"
|
||||
"pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules"
|
||||
"pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules"
|
||||
|
|
@ -70,8 +70,8 @@ for mount in "${workspace_node_modules[@]}"; do
|
|||
mkdir -p "${NM_ROOT}/${mount%%:*}"
|
||||
done
|
||||
|
||||
# Seed only auth/config into the isolated docker-e2e home. Never copy daemon
|
||||
# locks, sessions, uploaded files, or reports from the compose daemon home.
|
||||
# Seed only auth/config into the isolated docker-e2e home. Never copy server
|
||||
# locks, sessions, uploaded files, or reports from the compose server home.
|
||||
if [[ -f "${SEED_HOME_HOST}/config.toml" && ! -f "${KIMI_HOME_HOST}/config.toml" ]]; then
|
||||
cp "${SEED_HOME_HOST}/config.toml" "${KIMI_HOME_HOST}/config.toml"
|
||||
fi
|
||||
|
|
@ -79,7 +79,7 @@ if [[ -d "${SEED_HOME_HOST}/credentials" && ! -d "${KIMI_HOME_HOST}/credentials"
|
|||
cp -R "${SEED_HOME_HOST}/credentials" "${KIMI_HOME_HOST}/credentials"
|
||||
fi
|
||||
|
||||
if [[ "${DAEMON_E2E_SKIP_BUILD:-0}" != "1" ]]; then
|
||||
if [[ "${KIMI_SERVER_E2E_SKIP_BUILD:-0}" != "1" ]]; then
|
||||
docker build -t "${BASE_IMAGE}" -f "${REPO_ROOT}/Dockerfile" "${REPO_ROOT}"
|
||||
docker build \
|
||||
-t "${IMAGE}" \
|
||||
|
|
@ -94,33 +94,33 @@ read -r -d '' container_script <<'EOS' || true
|
|||
set -euo pipefail
|
||||
|
||||
cd /workspace/kimi-code
|
||||
mkdir -p "${KIMI_CODE_HOME}/daemon" "${DAEMON_E2E_REPORT_DIR}" "${TMPDIR}" /data/daemon-e2e-reports/docker
|
||||
rm -f "${KIMI_CODE_HOME}/daemon/lock"
|
||||
mkdir -p "${KIMI_CODE_HOME}/server" "${KIMI_SERVER_E2E_REPORT_DIR}" "${TMPDIR}" /data/server-e2e-reports/docker
|
||||
rm -f "${KIMI_CODE_HOME}/server/lock"
|
||||
|
||||
if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/daemon-e2e/node_modules/ws ]]; then
|
||||
echo "[daemon-e2e:docker] installing pnpm deps"
|
||||
if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/server-e2e/node_modules/ws ]]; then
|
||||
echo "[server-e2e:docker] installing pnpm deps"
|
||||
pnpm install --frozen-lockfile
|
||||
else
|
||||
echo "[daemon-e2e:docker] pnpm deps already present"
|
||||
echo "[server-e2e:docker] pnpm deps already present"
|
||||
fi
|
||||
|
||||
daemon_log="/data/daemon-e2e-reports/docker/daemon.log"
|
||||
: > "${daemon_log}"
|
||||
server_log="/data/server-e2e-reports/docker/server.log"
|
||||
: > "${server_log}"
|
||||
|
||||
echo "[daemon-e2e:docker] starting daemon on container-local ${DAEMON_URL}"
|
||||
pnpm dev:daemon \
|
||||
echo "[server-e2e:docker] starting server on container-local ${KIMI_SERVER_URL}"
|
||||
pnpm dev:server -- \
|
||||
--host 127.0.0.1 \
|
||||
--port "${DAEMON_E2E_DAEMON_PORT}" \
|
||||
--port "${KIMI_SERVER_E2E_PORT}" \
|
||||
--log-level debug \
|
||||
--debug-endpoints \
|
||||
>"${daemon_log}" 2>&1 &
|
||||
daemon_pid=$!
|
||||
>"${server_log}" 2>&1 &
|
||||
server_pid=$!
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if kill -0 "${daemon_pid}" >/dev/null 2>&1; then
|
||||
kill "${daemon_pid}" >/dev/null 2>&1 || true
|
||||
wait "${daemon_pid}" >/dev/null 2>&1 || true
|
||||
if kill -0 "${server_pid}" >/dev/null 2>&1; then
|
||||
kill "${server_pid}" >/dev/null 2>&1 || true
|
||||
wait "${server_pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit "${status}"
|
||||
}
|
||||
|
|
@ -128,27 +128,27 @@ trap cleanup EXIT INT TERM
|
|||
|
||||
ready=0
|
||||
for attempt in $(seq 1 90); do
|
||||
if curl -fsS "${DAEMON_URL}/api/v1/meta" >/tmp/daemon-meta.json 2>/tmp/daemon-curl.err; then
|
||||
if curl -fsS "${KIMI_SERVER_URL}/api/v1/meta" >/tmp/server-meta.json 2>/tmp/server-curl.err; then
|
||||
ready=1
|
||||
echo "[daemon-e2e:docker] daemon ready: $(cat /tmp/daemon-meta.json)"
|
||||
echo "[server-e2e:docker] server ready: $(cat /tmp/server-meta.json)"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "${daemon_pid}" >/dev/null 2>&1; then
|
||||
echo "[daemon-e2e:docker] daemon exited before readiness" >&2
|
||||
tail -n 200 "${daemon_log}" >&2 || true
|
||||
if ! kill -0 "${server_pid}" >/dev/null 2>&1; then
|
||||
echo "[server-e2e:docker] server exited before readiness" >&2
|
||||
tail -n 200 "${server_log}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${ready}" != "1" ]]; then
|
||||
echo "[daemon-e2e:docker] daemon did not become ready within 90s" >&2
|
||||
cat /tmp/daemon-curl.err >&2 || true
|
||||
tail -n 200 "${daemon_log}" >&2 || true
|
||||
echo "[server-e2e:docker] server did not become ready within 90s" >&2
|
||||
cat /tmp/server-curl.err >&2 || true
|
||||
tail -n 200 "${server_log}" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /workspace/kimi-code/packages/daemon-e2e
|
||||
cd /workspace/kimi-code/packages/server-e2e
|
||||
pnpm test:scenarios
|
||||
EOS
|
||||
|
||||
|
|
@ -157,11 +157,11 @@ docker_args=(
|
|||
--rm
|
||||
--init
|
||||
--name "${CONTAINER}"
|
||||
--workdir /workspace/kimi-code/packages/daemon-e2e
|
||||
--workdir /workspace/kimi-code/packages/server-e2e
|
||||
--env "KIMI_CODE_HOME=${KIMI_HOME_CONTAINER}"
|
||||
--env "DAEMON_E2E_DAEMON_PORT=${PORT}"
|
||||
--env "DAEMON_URL=http://127.0.0.1:${PORT}"
|
||||
--env "DAEMON_E2E_REPORT_DIR=${REPORT_DIR_CONTAINER}"
|
||||
--env "KIMI_SERVER_E2E_PORT=${PORT}"
|
||||
--env "KIMI_SERVER_URL=http://127.0.0.1:${PORT}"
|
||||
--env "KIMI_SERVER_E2E_REPORT_DIR=${REPORT_DIR_CONTAINER}"
|
||||
--env "TMPDIR=${TMPDIR_CONTAINER}"
|
||||
--env "TERM=xterm-256color"
|
||||
--env "TZ=Asia/Shanghai"
|
||||
|
|
@ -176,12 +176,12 @@ for mount in "${workspace_node_modules[@]}"; do
|
|||
docker_args+=(--volume "${NM_ROOT}/${mount%%:*}:${mount#*:}")
|
||||
done
|
||||
|
||||
echo "[daemon-e2e:docker] running ${IMAGE} without host port publishing"
|
||||
echo "[server-e2e:docker] running ${IMAGE} without host port publishing"
|
||||
set +e
|
||||
docker "${docker_args[@]}" "${IMAGE}" bash -lc "${container_script}"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
echo "[daemon-e2e:docker] report: ${REPORT_DIR_HOST}/index.html"
|
||||
echo "[daemon-e2e:docker] daemon log: ${REPORT_ROOT_HOST}/daemon.log"
|
||||
echo "[server-e2e:docker] report: ${REPORT_DIR_HOST}/index.html"
|
||||
echo "[server-e2e:docker] server log: ${REPORT_ROOT_HOST}/server.log"
|
||||
exit "${status}"
|
||||
|
|
@ -51,9 +51,9 @@ async function main(): Promise<void> {
|
|||
|
||||
const htmlPath = writeHtmlReport({
|
||||
reportDir,
|
||||
title: `daemon-e2e scenarios (${failed ? 'failed' : 'passed'})`,
|
||||
title: `server-e2e scenarios (${failed ? 'failed' : 'passed'})`,
|
||||
});
|
||||
process.stdout.write(`[daemon-e2e] HTML report: ${htmlPath}\n`);
|
||||
process.stdout.write(`[server-e2e] HTML report: ${htmlPath}\n`);
|
||||
if (failed) process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -66,8 +66,8 @@ async function runScenario(
|
|||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DAEMON_E2E_CASE_NAME: caseName,
|
||||
DAEMON_E2E_REPORT_DIR: reportDir,
|
||||
KIMI_SERVER_E2E_CASE_NAME: caseName,
|
||||
KIMI_SERVER_E2E_REPORT_DIR: reportDir,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
|
@ -118,9 +118,9 @@ try {
|
|||
error: error instanceof Error ? { name: error.name, message: error.message } : error,
|
||||
});
|
||||
const htmlPath = writeHtmlReport({
|
||||
title: 'daemon-e2e scenarios (failed)',
|
||||
title: 'server-e2e scenarios (failed)',
|
||||
});
|
||||
process.stderr.write(`✗ scenario runner failed: ${String(error)}\n`);
|
||||
process.stderr.write(`[daemon-e2e] HTML report: ${htmlPath}\n`);
|
||||
process.stderr.write(`[server-e2e] HTML report: ${htmlPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* `DaemonClient` — wire-level test client for the kimi-code daemon.
|
||||
* `DaemonClient` — wire-level test client for the kimi-code server.
|
||||
*
|
||||
* Wraps the daemon's HTTP REST + WS surfaces (`/api/v1/...` + `/api/v1/ws`)
|
||||
* Wraps the server's HTTP REST + WS surfaces (`/api/v1/...` + `/api/v1/ws`)
|
||||
* into a single, typed object that scenarios can drive. Handles:
|
||||
* - Envelope unwrap + typed REST helpers
|
||||
* - WS `server_hello` → `client_hello` → ack handshake
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
* - Approval + question reverse-RPC auto-resolve via per-event handlers
|
||||
* - `waitForFrame` / `waitForSessionStatus` convenience waits
|
||||
*
|
||||
* **What it is NOT**: a daemon bootstrap helper. Connect to a daemon process
|
||||
* **What it is NOT**: a server bootstrap helper. Connect to a server process
|
||||
* that's already running at `baseUrl` (default `http://127.0.0.1:7878`).
|
||||
*/
|
||||
import type {
|
||||
|
|
@ -65,7 +65,7 @@ export interface DaemonClientOptions {
|
|||
baseUrl?: string;
|
||||
/** Default `/api/v1`. WS endpoint is `${apiPrefix}/ws`. */
|
||||
apiPrefix?: string;
|
||||
/** Default `daemon-e2e-<ulid>` — used as the `client_hello.client_id`. */
|
||||
/** Default `server-e2e-<ulid>` — used as the `client_hello.client_id`. */
|
||||
clientId?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
wsImpl?: typeof WsWebSocket;
|
||||
|
|
@ -90,13 +90,13 @@ const DEFAULT_API_PREFIX = '/api/v1';
|
|||
const DEFAULT_CONTROL_ACK_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Per-request stateless session controls that the daemon REST surface
|
||||
* Per-request stateless session controls that the server REST surface
|
||||
* requires on every prompt submission. Scenarios that don't care about
|
||||
* these can leave them at the defaults; tests that exercise switching
|
||||
* model / thinking / permission / plan mode override only the field
|
||||
* they need.
|
||||
*
|
||||
* `model` matches what the existing daemon-e2e scenarios assume (the
|
||||
* `model` matches what the existing server-e2e scenarios assume (the
|
||||
* default provider exposes `kimi-code/kimi-for-coding`).
|
||||
*/
|
||||
export const DEFAULT_PROMPT_CONTROLS = {
|
||||
|
|
@ -142,7 +142,7 @@ export class DaemonClient {
|
|||
constructor(opts: DaemonClientOptions = {}) {
|
||||
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.apiPrefix = opts.apiPrefix ?? DEFAULT_API_PREFIX;
|
||||
this.clientId = opts.clientId ?? `daemon-e2e-${ulid()}`;
|
||||
this.clientId = opts.clientId ?? `server-e2e-${ulid()}`;
|
||||
this._wsImpl = opts.wsImpl ?? WsWebSocket;
|
||||
this._logger = opts.logger ?? noopLogger;
|
||||
this._reportDir = opts.reportDir;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* REST envelope helpers — unwrap `{ code, msg, data, request_id }` into either
|
||||
* a typed `data` or an `EnvelopeError` thrown by the caller.
|
||||
*
|
||||
* Mirrors `packages/protocol/src/envelope.ts` so the daemon's wire shape and
|
||||
* Mirrors `packages/protocol/src/envelope.ts` so the server's wire shape and
|
||||
* this client's parsing stay in lockstep.
|
||||
*/
|
||||
import { ErrorCode, ErrorCodeReason, type Envelope } from '@moonshot-ai/protocol';
|
||||
|
|
@ -10,7 +10,7 @@ import { ErrorCode, ErrorCodeReason, type Envelope } from '@moonshot-ai/protocol
|
|||
/**
|
||||
* Thrown when an HTTP call lands but `envelope.code !== 0`.
|
||||
*
|
||||
* `data` is preserved separately because several daemon endpoints return
|
||||
* `data` is preserved separately because several server endpoints return
|
||||
* non-zero envelopes with a non-null `data` payload (REST §3.6 idempotent
|
||||
* re-resolve: `code: 40902 + data: { resolved: false }`).
|
||||
*/
|
||||
|
|
@ -22,7 +22,7 @@ export class EnvelopeError<T = unknown> extends Error {
|
|||
|
||||
constructor(envelope: Envelope<T>) {
|
||||
const reason = ErrorCodeReason[envelope.code as ErrorCode] ?? 'unknown';
|
||||
super(`daemon returned code=${envelope.code} (${reason}): ${envelope.msg}`);
|
||||
super(`server returned code=${envelope.code} (${reason}): ${envelope.msg}`);
|
||||
this.name = 'EnvelopeError';
|
||||
this.code = envelope.code;
|
||||
this.reason = reason;
|
||||
|
|
@ -39,7 +39,7 @@ export function unwrap<T>(envelope: Envelope<T>): T {
|
|||
if (envelope.code !== 0) throw new EnvelopeError(envelope);
|
||||
if (envelope.data === null) {
|
||||
// `code: 0 + data: null` is reserved for "no body" success envelopes; the
|
||||
// current daemon surface always returns a non-null data on success, so
|
||||
// current server surface always returns a non-null data on success, so
|
||||
// surface this as a hard error rather than silently returning `null`.
|
||||
throw new EnvelopeError({ ...envelope, code: 50001, msg: 'success envelope had null data' });
|
||||
}
|
||||
|
|
@ -111,7 +111,7 @@ export class HttpClient {
|
|||
{ reportDir: this.opts.reportDir },
|
||||
);
|
||||
throw new Error(
|
||||
`daemon ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
||||
`server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
|
@ -156,7 +156,7 @@ export class HttpClient {
|
|||
envelope = JSON.parse(text) as Envelope<T>;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`daemon ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
||||
`server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
|
@ -205,7 +205,7 @@ export class HttpClient {
|
|||
}
|
||||
updateSession(sid: string, body: SessionUpdate): Promise<Session> {
|
||||
// Daemon canonical route: `POST /v1/sessions/{sid}/profile` (REST.md §3.3).
|
||||
// Earlier scaffolding spoke `PATCH /v1/sessions/{sid}`, which the daemon
|
||||
// Earlier scaffolding spoke `PATCH /v1/sessions/{sid}`, which the server
|
||||
// never wired — keep the helper name (used by existing fixtures) and just
|
||||
// dispatch to the right URL.
|
||||
return this.request<Session>(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* `@moonshot-ai/daemon-e2e` — wire-level test client for the kimi-code daemon.
|
||||
* `@moonshot-ai/server-e2e` — wire-level test client for the kimi-code server.
|
||||
*
|
||||
* Use this package from scenarios (`scenarios/*.ts`) and vitest e2e tests
|
||||
* to drive a real daemon process at `http://127.0.0.1:7878` (or any baseUrl
|
||||
* to drive a real server process at `http://127.0.0.1:7878` (or any baseUrl
|
||||
* you pass via `DaemonClientOptions.baseUrl`).
|
||||
*
|
||||
* Public surface:
|
||||
|
|
@ -142,7 +142,7 @@ export function readReportEvents(reportDir = defaultReportDir()): StoredReportEv
|
|||
export function writeHtmlReport(options?: HtmlReportOptions): string {
|
||||
const reportDir = options?.reportDir ?? defaultReportDir();
|
||||
mkdirSync(reportDir, { recursive: true });
|
||||
const title = options?.title ?? 'daemon-e2e report';
|
||||
const title = options?.title ?? 'server-e2e report';
|
||||
const events = readReportEvents(reportDir);
|
||||
const htmlPath = join(reportDir, 'index.html');
|
||||
writeFileSync(htmlPath, renderHtml(title, events));
|
||||
|
|
@ -196,7 +196,7 @@ export async function fetchWithReport(
|
|||
}
|
||||
|
||||
export function defaultReportDir(): string {
|
||||
return resolve(process.env['DAEMON_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'latest'));
|
||||
return resolve(process.env['KIMI_SERVER_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'latest'));
|
||||
}
|
||||
|
||||
function normalizeEvent(event: ReportEvent): StoredReportEvent {
|
||||
|
|
@ -204,7 +204,7 @@ function normalizeEvent(event: ReportEvent): StoredReportEvent {
|
|||
return {
|
||||
...stored,
|
||||
at: event.at ?? new Date().toISOString(),
|
||||
caseName: event.caseName ?? getActiveReportCase() ?? process.env['DAEMON_E2E_CASE_NAME'] ?? 'unassigned',
|
||||
caseName: event.caseName ?? getActiveReportCase() ?? process.env['KIMI_SERVER_E2E_CASE_NAME'] ?? 'unassigned',
|
||||
pid: process.pid,
|
||||
ordinal: ordinal++,
|
||||
};
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
* decision/answer to `/sessions/{sid}/{kind}s/{id}`.
|
||||
*
|
||||
* Errors from the user handler (or the REST POST) are swallowed into a
|
||||
* logger.warn — failing reverse-RPC silently means the daemon will time out
|
||||
* logger.warn — failing reverse-RPC silently means the server will time out
|
||||
* the approval/question after 60s, which the scenario will observe as a
|
||||
* timeout in `waitForFrame`. Surfacing those errors here would break the
|
||||
* "framework auto-responds" contract.
|
||||
|
|
@ -19,7 +19,7 @@ import type { AnyFrame, WsClient } from './ws.js';
|
|||
export interface ReverseRpcOptions<Req, Res> {
|
||||
requestEventType: string;
|
||||
idField: keyof Req & string;
|
||||
/** REST path under the daemon API prefix. */
|
||||
/** REST path under the server API prefix. */
|
||||
buildPath: (sessionId: string, id: string) => string;
|
||||
handler: (req: Req) => Promise<Res> | Res;
|
||||
/** POST helper bound to the right path. */
|
||||
|
|
@ -25,7 +25,7 @@ export function waitForFrame(
|
|||
|
||||
/**
|
||||
* Poll `GET /sessions/{sid}` until `status` matches. Useful as a final
|
||||
* synchronization point — the daemon's `turn.ended` arrives before the
|
||||
* synchronization point — the server's `turn.ended` arrives before the
|
||||
* session row flips to `idle`, so scenarios that want a quiescent session
|
||||
* must poll.
|
||||
*/
|
||||
|
|
@ -10,14 +10,14 @@
|
|||
* - `server_hello`/`ping`/`resync_required`/`error`: each carries `timestamp`
|
||||
*
|
||||
* We don't Zod-validate frames here — preserving forward-compat ("unknown
|
||||
* fields pass through") and avoiding double-work since the daemon emits the
|
||||
* fields pass through") and avoiding double-work since the server emits the
|
||||
* shapes already.
|
||||
*/
|
||||
import { WebSocket as WsWebSocket } from 'ws';
|
||||
|
||||
import { recordReportEvent } from './report.js';
|
||||
|
||||
/** Wire frame shape — kept loose because the daemon adds new event types. */
|
||||
/** Wire frame shape — kept loose because the server adds new event types. */
|
||||
export interface AnyFrame {
|
||||
readonly type: string;
|
||||
readonly seq?: number;
|
||||
|
|
@ -51,9 +51,9 @@ interface PendingWaiter {
|
|||
* - `waitForFrame(predicate)` consumes the *first matching* frame; matching
|
||||
* frames already in `_queue` are dispatched immediately.
|
||||
*
|
||||
* Both queue and waiters are needed because the daemon's first `server_hello`
|
||||
* Both queue and waiters are needed because the server's first `server_hello`
|
||||
* can land in the same tick as `open`, before the test has a chance to
|
||||
* register its first waiter (see `daemon/test/ws-handshake.e2e.test.ts:88-117`
|
||||
* register its first waiter (see `server/test/ws-handshake.e2e.test.ts:88-117`
|
||||
* for the pattern this is ported from).
|
||||
*/
|
||||
export class WsClient {
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* Self-tests for `DaemonClient` against a live daemon at
|
||||
* `process.env.DAEMON_URL ?? http://127.0.0.1:7878`.
|
||||
* Self-tests for `DaemonClient` against a live server at
|
||||
* `process.env.KIMI_SERVER_URL ?? http://127.0.0.1:7878`.
|
||||
*
|
||||
* Every test gates on a `daemonReachable()` check so CI / dev machines
|
||||
* without a running daemon stay green. Run a daemon (`pnpm dev:daemon` from
|
||||
* without a running server stay green. Run a server (`pnpm dev:server` from
|
||||
* repo root) to exercise these locally.
|
||||
*
|
||||
* Coverage:
|
||||
|
|
@ -29,7 +29,7 @@ import { DaemonClient, EnvelopeError } from '../src/index.js';
|
|||
import { fetchWithReport } from '../src/report.js';
|
||||
import { createCaseLogger, errorForLog } from './log.js';
|
||||
|
||||
const BASE_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const PROMPT_TIMEOUT_MS = 120_000;
|
||||
|
||||
async function daemonReachable(): Promise<boolean> {
|
||||
|
|
@ -64,7 +64,7 @@ afterEach(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
describeLive('DaemonClient (live daemon required)', () => {
|
||||
describeLive('DaemonClient (live server required)', () => {
|
||||
it('throws EnvelopeError on code !== 0', async () => {
|
||||
const log = createCaseLogger('client: missing session envelope');
|
||||
const client = new DaemonClient({ baseUrl: BASE_URL });
|
||||
|
|
@ -335,7 +335,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
const calls: FetchCall[] = [];
|
||||
const fork = testSession({ id: 'sess_fork', title: 'Fork: Source session' });
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetch(okEnvelope(fork), calls),
|
||||
});
|
||||
|
||||
|
|
@ -348,7 +348,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
log('unwrapped result', result);
|
||||
expect(result).toEqual(fork);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe('http://daemon.example.test/api/v1/sessions/sess_source:fork');
|
||||
expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:fork');
|
||||
expect(calls[0]?.init.method).toBe('POST');
|
||||
expect(parseRecordedJsonBody(calls[0])).toEqual({
|
||||
title: 'Custom fork',
|
||||
|
|
@ -360,7 +360,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
const log = createCaseLogger('client helper: compactSession');
|
||||
const calls: FetchCall[] = [];
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetch(
|
||||
{
|
||||
code: ErrorCode.COMPACTION_UNABLE,
|
||||
|
|
@ -392,7 +392,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe('http://daemon.example.test/api/v1/sessions/sess_source:compact');
|
||||
expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:compact');
|
||||
expect(calls[0]?.init.method).toBe('POST');
|
||||
expect(parseRecordedJsonBody(calls[0])).toEqual({
|
||||
instruction: ' focus on decisions ',
|
||||
|
|
@ -408,7 +408,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
status: testSessionStatus(),
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetch(okEnvelope(undoResponse), calls),
|
||||
});
|
||||
|
||||
|
|
@ -418,7 +418,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
log('unwrapped result', result);
|
||||
expect(result).toEqual(undoResponse);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe('http://daemon.example.test/api/v1/sessions/sess_source:undo');
|
||||
expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:undo');
|
||||
expect(calls[0]?.init.method).toBe('POST');
|
||||
expect(parseRecordedJsonBody(calls[0])).toEqual({
|
||||
count: 2,
|
||||
|
|
@ -432,7 +432,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
const model = testModel({ model: 'kimi-code/kimi-for-coding' });
|
||||
const provider = testProvider({ id: 'kimi', models: [model.model] });
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetchSequence(
|
||||
[
|
||||
okEnvelope({
|
||||
|
|
@ -461,11 +461,11 @@ describe('DaemonClient session action helpers', () => {
|
|||
|
||||
log('fetch calls', calls);
|
||||
expect(calls.map((call) => [call.init.method, call.url])).toEqual([
|
||||
['GET', 'http://daemon.example.test/api/v1/auth'],
|
||||
['GET', 'http://daemon.example.test/api/v1/models'],
|
||||
['POST', 'http://daemon.example.test/api/v1/models/kimi-code%2Fkimi-for-coding:set_default'],
|
||||
['GET', 'http://daemon.example.test/api/v1/providers'],
|
||||
['GET', 'http://daemon.example.test/api/v1/providers/kimi'],
|
||||
['GET', 'http://server.example.test/api/v1/auth'],
|
||||
['GET', 'http://server.example.test/api/v1/models'],
|
||||
['POST', 'http://server.example.test/api/v1/models/kimi-code%2Fkimi-for-coding:set_default'],
|
||||
['GET', 'http://server.example.test/api/v1/providers'],
|
||||
['GET', 'http://server.example.test/api/v1/providers/kimi'],
|
||||
]);
|
||||
expect(parseRecordedJsonBody(calls[2])).toEqual({});
|
||||
});
|
||||
|
|
@ -475,7 +475,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
const calls: FetchCall[] = [];
|
||||
const child = testSession({ id: 'sess_child', title: 'Child session' });
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetchSequence(
|
||||
[
|
||||
okEnvelope(child),
|
||||
|
|
@ -506,11 +506,11 @@ describe('DaemonClient session action helpers', () => {
|
|||
|
||||
log('fetch calls', calls);
|
||||
expect(calls.map((call) => [call.init.method, call.url])).toEqual([
|
||||
['POST', 'http://daemon.example.test/api/v1/sessions/sess_parent/children'],
|
||||
['GET', 'http://daemon.example.test/api/v1/sessions/sess_parent/children?page_size=5&status=idle'],
|
||||
['GET', 'http://daemon.example.test/api/v1/sessions/sess_parent/approvals?status=pending'],
|
||||
['GET', 'http://daemon.example.test/api/v1/sessions/sess_parent/questions?status=pending'],
|
||||
['POST', 'http://daemon.example.test/api/v1/sessions/sess_parent/questions/question_1:dismiss'],
|
||||
['POST', 'http://server.example.test/api/v1/sessions/sess_parent/children'],
|
||||
['GET', 'http://server.example.test/api/v1/sessions/sess_parent/children?page_size=5&status=idle'],
|
||||
['GET', 'http://server.example.test/api/v1/sessions/sess_parent/approvals?status=pending'],
|
||||
['GET', 'http://server.example.test/api/v1/sessions/sess_parent/questions?status=pending'],
|
||||
['POST', 'http://server.example.test/api/v1/sessions/sess_parent/questions/question_1:dismiss'],
|
||||
]);
|
||||
expect(parseRecordedJsonBody(calls[0])).toEqual({
|
||||
title: 'Child session',
|
||||
|
|
@ -524,7 +524,7 @@ describe('DaemonClient session action helpers', () => {
|
|||
const calls: FetchCall[] = [];
|
||||
const file = testFile({ id: 'file_png', name: 'tiny.png', media_type: 'image/png', size: 3 });
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl: recordingFetchSequence(
|
||||
[
|
||||
okEnvelope(file),
|
||||
|
|
@ -546,8 +546,8 @@ describe('DaemonClient session action helpers', () => {
|
|||
|
||||
log('fetch calls', calls);
|
||||
expect(calls.map((call) => [call.init.method, call.url])).toEqual([
|
||||
['POST', 'http://daemon.example.test/api/v1/files'],
|
||||
['DELETE', 'http://daemon.example.test/api/v1/files/file_png'],
|
||||
['POST', 'http://server.example.test/api/v1/files'],
|
||||
['DELETE', 'http://server.example.test/api/v1/files/file_png'],
|
||||
]);
|
||||
const form = calls[0]?.init.body;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
|
|
@ -635,7 +635,7 @@ function testSession(overrides: Partial<Session> = {}): Session {
|
|||
created_at: '2026-06-09T00:00:00.000Z',
|
||||
updated_at: '2026-06-09T00:00:00.000Z',
|
||||
status: 'idle',
|
||||
metadata: { cwd: '/tmp/example-daemon-e2e' },
|
||||
metadata: { cwd: '/tmp/example-server-e2e' },
|
||||
agent_config: { model: '' },
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
|
|
@ -24,7 +24,7 @@ export function createCaseLogger(caseName: string): (label: string, value?: unkn
|
|||
});
|
||||
return (label, value) => {
|
||||
recordReportEvent({ kind: 'log', caseName, label, value });
|
||||
const prefix = `[daemon-e2e] ${caseName} :: ${label}`;
|
||||
const prefix = `[server-e2e] ${caseName} :: ${label}`;
|
||||
if (value === undefined) {
|
||||
writeLogLine(prefix);
|
||||
return;
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* Prompt queue + steer live-daemon invariant.
|
||||
* Prompt queue + steer live-server invariant.
|
||||
*
|
||||
* Drives the TUI Ctrl-S equivalent over daemon REST + WS:
|
||||
* Drives the TUI Ctrl-S equivalent over server REST + WS:
|
||||
* 1. use the debug-only prompt test hook to mark one prompt active;
|
||||
* 2. submit a second prompt and assert it is queued instead of rejected;
|
||||
* 3. list prompts and assert active + queued state;
|
||||
* 4. steer the queued prompt and assert `prompt.steered` is broadcast and
|
||||
* the queue is drained.
|
||||
*
|
||||
* Requires a daemon launched with debug endpoints enabled. Normal production
|
||||
* Requires a server launched with debug endpoints enabled. Normal production
|
||||
* daemons do not expose `/debug/*`, so this file skips when that surface is
|
||||
* absent.
|
||||
*/
|
||||
|
|
@ -18,7 +18,7 @@ import { DaemonClient, type AnyFrame } from '../src/index.js';
|
|||
import { fetchWithReport } from '../src/report.js';
|
||||
import { createCaseLogger } from './log.js';
|
||||
|
||||
const BASE_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const SHORT_TIMEOUT_MS = 15_000;
|
||||
|
||||
|
|
@ -82,14 +82,14 @@ afterEach(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
describeLive('prompt queue + steer (live daemon required)', () => {
|
||||
describeLive('prompt queue + steer (live server required)', () => {
|
||||
it(
|
||||
'queues a busy prompt and steers it into the active turn',
|
||||
async () => {
|
||||
const log = createCaseLogger('prompt queue: steer');
|
||||
const client = new DaemonClient({ baseUrl: BASE_URL });
|
||||
const session = await client.createSession({
|
||||
title: 'daemon-e2e prompt queue steer',
|
||||
title: 'server-e2e prompt queue steer',
|
||||
metadata: { cwd: process.cwd(), scenario: 'prompt-queue-steer' },
|
||||
});
|
||||
const cleanup: { client: DaemonClient; sid: string; promptIds: string[] } = {
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
/**
|
||||
* Refresh / reload wire-level invariants.
|
||||
*
|
||||
* Models the page-refresh path a web client takes when the daemon is already
|
||||
* Models the page-refresh path a web client takes when the server is already
|
||||
* up: hit `/healthz`, `/meta`, `/auth`, then open a fresh WebSocket and replay
|
||||
* any missed events via `client_hello.cursors` BEFORE pulling REST history
|
||||
* (REST.md §3 + WS.md §3.2).
|
||||
*
|
||||
* What's asserted here (and NOT in `client.test.ts`):
|
||||
* 1. `/healthz` returns `{ok: true}`.
|
||||
* 2. `/meta` exposes a non-empty `daemon_id`. (Since the v2 sync protocol,
|
||||
* 2. `/meta` exposes a non-empty `server_id`. (Since the v2 sync protocol,
|
||||
* cursors carry a journal `epoch` and seq is durable across restarts —
|
||||
* a stale cursor is detected server-side via `epoch_changed` instead of
|
||||
* clients comparing `daemon_id`.)
|
||||
* clients comparing `server_id`.)
|
||||
* 3. `/auth` returns the `AuthSummary` shape.
|
||||
* 4. After running one prompt to populate the journal, a fresh WS that
|
||||
* passes `cursors: { [sid]: { seq: currentSeq } }` is acked with
|
||||
|
|
@ -23,8 +23,8 @@
|
|||
* 6. After reconnect, `GET /messages` reflects the persisted state from
|
||||
* before the WS close.
|
||||
*
|
||||
* Live-daemon gated via the same `daemonReachable()` check as
|
||||
* `client.test.ts`; missing daemon → tests skip cleanly so CI stays green.
|
||||
* Live-server gated via the same `daemonReachable()` check as
|
||||
* `client.test.ts`; missing server → tests skip cleanly so CI stays green.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WebSocket as WsWebSocket } from 'ws';
|
||||
|
|
@ -33,7 +33,7 @@ import { DaemonClient, WsClient, type AnyFrame } from '../src/index.js';
|
|||
import { fetchWithReport } from '../src/report.js';
|
||||
import { createCaseLogger } from './log.js';
|
||||
|
||||
const BASE_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000;
|
||||
const PROMPT_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -162,7 +162,7 @@ afterEach(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
describeLive('refresh-replay (live daemon required)', () => {
|
||||
describeLive('refresh-replay (live server required)', () => {
|
||||
it('phase 0: /healthz returns ok:true', async () => {
|
||||
const log = createCaseLogger('refresh: healthz');
|
||||
const health = await getEnvelope<{ ok: boolean }>('/healthz', log);
|
||||
|
|
@ -170,17 +170,17 @@ describeLive('refresh-replay (live daemon required)', () => {
|
|||
expect(health.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('phase 0: /meta exposes daemon_id, version, started_at', async () => {
|
||||
it('phase 0: /meta exposes server_id, version, started_at', async () => {
|
||||
const log = createCaseLogger('refresh: meta');
|
||||
const meta = await getEnvelope<{
|
||||
daemon_id: string;
|
||||
daemon_version: string;
|
||||
server_id: string;
|
||||
server_version: string;
|
||||
started_at: string;
|
||||
capabilities: Record<string, boolean>;
|
||||
}>('/meta', log);
|
||||
log('data', meta);
|
||||
expect(meta.daemon_id).toMatch(/.+/);
|
||||
expect(meta.daemon_version).toMatch(/.+/);
|
||||
expect(meta.server_id).toMatch(/.+/);
|
||||
expect(meta.server_version).toMatch(/.+/);
|
||||
expect(meta.started_at).toMatch(/.+/);
|
||||
expect(meta.capabilities['websocket']).toBe(true);
|
||||
});
|
||||
|
|
@ -261,7 +261,7 @@ describeLive('refresh-replay (live daemon required)', () => {
|
|||
);
|
||||
|
||||
it(
|
||||
'reconnect with last_seq=0 → daemon replays buffered events in order before ack',
|
||||
'reconnect with last_seq=0 → server replays buffered events in order before ack',
|
||||
async () => {
|
||||
const log = createCaseLogger('refresh: replay from zero');
|
||||
const client = new DaemonClient({ baseUrl: BASE_URL });
|
||||
|
|
@ -28,12 +28,12 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
function tmpReportDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'daemon-e2e-report-'));
|
||||
const dir = mkdtempSync(join(tmpdir(), 'server-e2e-report-'));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('daemon-e2e report', () => {
|
||||
describe('server-e2e report', () => {
|
||||
it('renders HTTP and WS trace events into a readable HTML timeline', () => {
|
||||
const reportDir = tmpReportDir();
|
||||
resetReportDir(reportDir);
|
||||
|
|
@ -64,7 +64,7 @@ describe('daemon-e2e report', () => {
|
|||
caseName: 'refresh: replay from zero',
|
||||
direction: 'lifecycle',
|
||||
message: 'open',
|
||||
url: 'ws://daemon.example.test/api/v1/ws',
|
||||
url: 'ws://server.example.test/api/v1/ws',
|
||||
},
|
||||
{ reportDir },
|
||||
);
|
||||
|
|
@ -203,7 +203,7 @@ describe('daemon-e2e report', () => {
|
|||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)) as typeof fetch;
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
apiPrefix: '/api/v1',
|
||||
fetchImpl,
|
||||
reportDir,
|
||||
|
|
@ -239,7 +239,7 @@ describe('daemon-e2e report', () => {
|
|||
FakeWebSocket.instances = [];
|
||||
|
||||
const ws = new WsClient({
|
||||
url: 'ws://daemon.example.test/api/v1/ws',
|
||||
url: 'ws://server.example.test/api/v1/ws',
|
||||
wsImpl: FakeWebSocket as unknown as typeof WsWebSocket,
|
||||
logger: () => {},
|
||||
reportDir,
|
||||
|
|
@ -277,8 +277,8 @@ describe('daemon-e2e report', () => {
|
|||
it('records createCaseLogger entries under the active case', () => {
|
||||
const reportDir = tmpReportDir();
|
||||
resetReportDir(reportDir);
|
||||
const previousReportDir = process.env['DAEMON_E2E_REPORT_DIR'];
|
||||
process.env['DAEMON_E2E_REPORT_DIR'] = reportDir;
|
||||
const previousReportDir = process.env['KIMI_SERVER_E2E_REPORT_DIR'];
|
||||
process.env['KIMI_SERVER_E2E_REPORT_DIR'] = reportDir;
|
||||
try {
|
||||
const log = createCaseLogger('refresh: auth');
|
||||
log('http envelope', { method: 'GET', path: '/auth', code: 0 });
|
||||
|
|
@ -292,9 +292,9 @@ describe('daemon-e2e report', () => {
|
|||
});
|
||||
} finally {
|
||||
if (previousReportDir === undefined) {
|
||||
delete process.env['DAEMON_E2E_REPORT_DIR'];
|
||||
delete process.env['KIMI_SERVER_E2E_REPORT_DIR'];
|
||||
} else {
|
||||
process.env['DAEMON_E2E_REPORT_DIR'] = previousReportDir;
|
||||
process.env['KIMI_SERVER_E2E_REPORT_DIR'] = previousReportDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -328,19 +328,19 @@ describe('daemon-e2e report', () => {
|
|||
code: 0,
|
||||
msg: 'success',
|
||||
request_id: 'req_meta',
|
||||
data: { daemon_id: 'daemon_1' },
|
||||
data: { server_id: 'daemon_1' },
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)) as typeof fetch;
|
||||
|
||||
const response = await fetchWithReport(
|
||||
'http://daemon.example.test/api/v1/meta',
|
||||
'http://server.example.test/api/v1/meta',
|
||||
{ headers: { accept: 'application/json' } },
|
||||
{ reportDir, fetchImpl },
|
||||
);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
code: 0,
|
||||
data: { daemon_id: 'daemon_1' },
|
||||
data: { server_id: 'daemon_1' },
|
||||
});
|
||||
|
||||
const event = readReportEvents(reportDir).find((entry) => entry.kind === 'http');
|
||||
|
|
@ -355,7 +355,7 @@ describe('daemon-e2e report', () => {
|
|||
code: 0,
|
||||
msg: 'success',
|
||||
request_id: 'req_meta',
|
||||
data: { daemon_id: 'daemon_1' },
|
||||
data: { server_id: 'daemon_1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -364,7 +364,7 @@ describe('daemon-e2e report', () => {
|
|||
it('propagates DaemonClient reportDir to HTTP and WS traces', async () => {
|
||||
const reportDir = tmpReportDir();
|
||||
resetReportDir(reportDir);
|
||||
setActiveReportCase('daemon client: custom report dir');
|
||||
setActiveReportCase('server client: custom report dir');
|
||||
FakeWebSocket.instances = [];
|
||||
|
||||
const fetchImpl = (async () =>
|
||||
|
|
@ -400,7 +400,7 @@ describe('daemon-e2e report', () => {
|
|||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)) as typeof fetch;
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon.example.test',
|
||||
baseUrl: 'http://server.example.test',
|
||||
fetchImpl,
|
||||
wsImpl: FakeWebSocket as unknown as typeof WsWebSocket,
|
||||
logger: () => {},
|
||||
|
|
@ -436,19 +436,19 @@ describe('daemon-e2e report', () => {
|
|||
expect.objectContaining({
|
||||
kind: 'http',
|
||||
path: '/sessions',
|
||||
caseName: 'daemon client: custom report dir',
|
||||
caseName: 'server client: custom report dir',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'ws',
|
||||
direction: 'in',
|
||||
frame: expect.objectContaining({ type: 'server_hello' }),
|
||||
caseName: 'daemon client: custom report dir',
|
||||
caseName: 'server client: custom report dir',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'ws',
|
||||
direction: 'out',
|
||||
frame: expect.objectContaining({ type: 'client_hello' }),
|
||||
caseName: 'daemon client: custom report dir',
|
||||
caseName: 'server client: custom report dir',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
@ -32,12 +32,12 @@ export default class DaemonE2eHtmlReporter implements Reporter {
|
|||
}
|
||||
const htmlPath = writeHtmlReport({
|
||||
reportDir,
|
||||
title: `daemon-e2e report (${reason})`,
|
||||
title: `server-e2e report (${reason})`,
|
||||
});
|
||||
process.stdout.write(`[daemon-e2e] HTML report: ${htmlPath}\n`);
|
||||
process.stdout.write(`[server-e2e] HTML report: ${htmlPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function vitestReportDir(): string {
|
||||
return resolve(process.env['DAEMON_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'vitest', 'latest'));
|
||||
return resolve(process.env['KIMI_SERVER_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'vitest', 'latest'));
|
||||
}
|
||||
|
|
@ -13,11 +13,11 @@
|
|||
* protocol exposes `idle | running | awaiting_approval |
|
||||
* awaiting_question | aborted` (`session.ts:36-42`); pre-fix,
|
||||
* `toProtocolSession` (`services/src/session/session.ts:178`) hardcoded
|
||||
* `status: 'idle'`. This test asserts the live daemon transitions
|
||||
* `status: 'idle'`. This test asserts the live server transitions
|
||||
* `idle → running → idle` across a prompt; it's marked `it.fails` so the
|
||||
* runner flags the moment the hardcode is removed.
|
||||
*
|
||||
* Both tests gate on `daemonReachable()` so CI without a daemon stays green.
|
||||
* Both tests gate on `daemonReachable()` so CI without a server stays green.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ import { DaemonClient } from '../src/index.js';
|
|||
import { fetchWithReport } from '../src/report.js';
|
||||
import { createCaseLogger } from './log.js';
|
||||
|
||||
const BASE_URL = process.env['DAEMON_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:7878';
|
||||
const API_PREFIX = '/api/v1';
|
||||
const PROMPT_TIMEOUT_MS = 120_000;
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ afterEach(async () => {
|
|||
}
|
||||
});
|
||||
|
||||
describeLive('session resume + status (live daemon required)', () => {
|
||||
describeLive('session resume + status (live server required)', () => {
|
||||
// ── Gap 1: cold-session /messages ──────────────────────────────────────
|
||||
it(
|
||||
'GET /messages on a persisted session returns 200 (resumeSession injection)',
|
||||
|
|
@ -69,7 +69,7 @@ describeLive('session resume + status (live daemon required)', () => {
|
|||
const probe = new DaemonClient({ baseUrl: BASE_URL });
|
||||
|
||||
// Seed: ensure at least one session exists in the store. (If a prior
|
||||
// daemon process already left some, we'll still pick one of those —
|
||||
// server process already left some, we'll still pick one of those —
|
||||
// either way, the resumeSession code path is the same.)
|
||||
const seeded = await probe.createSession({ metadata: { cwd: process.cwd() } });
|
||||
created.push({ client: probe, sid: seeded.id });
|
||||
|
|
@ -100,7 +100,7 @@ describeLive('session resume + status (live daemon required)', () => {
|
|||
});
|
||||
expect(sessions.length).toBeGreaterThan(0);
|
||||
|
||||
// Probe up to 3 sessions to keep the test deterministic across daemon
|
||||
// Probe up to 3 sessions to keep the test deterministic across server
|
||||
// states (fresh-start vs. long-running). Every one must return 200.
|
||||
const probeSet = sessions.slice(0, 3);
|
||||
for (const s of probeSet) {
|
||||
|
|
@ -144,7 +144,7 @@ describeLive('session resume + status (live daemon required)', () => {
|
|||
});
|
||||
log('submit response', submit);
|
||||
|
||||
// Race the daemon: poll status quickly until we observe a non-idle
|
||||
// Race the server: poll status quickly until we observe a non-idle
|
||||
// value or `prompt.completed` lands. Either outcome ends the loop; we
|
||||
// assert on the captured `seenRunning` flag afterward.
|
||||
let seenRunning = false;
|
||||
|
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Alias `@moonshot-ai/protocol` to its src so tests track source edits without
|
||||
// requiring a rebuild — mirrors `packages/daemon/vitest.config.ts`.
|
||||
// requiring a rebuild — mirrors `packages/server/vitest.config.ts`.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
@ -13,7 +13,7 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
test: {
|
||||
name: 'daemon-e2e',
|
||||
name: 'server-e2e',
|
||||
include: ['test/**/*.test.ts'],
|
||||
reporters: ['default', './test/report/vitest-reporter.ts'],
|
||||
},
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@moonshot-ai/daemon",
|
||||
"name": "@moonshot-ai/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local REST + WebSocket daemon exposing kimi-code SDK over a stable wire protocol.",
|
||||
"description": "Local REST + WebSocket server 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"
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/MoonshotAI/kimi-code/issues"
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
* Re-export the envelope helpers from `@moonshot-ai/protocol`.
|
||||
*
|
||||
* The wire-shape source of truth lives in `@moonshot-ai/protocol`. Re-exporting
|
||||
* the protocol helpers preserves field order and JSON output for daemon
|
||||
* the protocol helpers preserves field order and JSON output for server
|
||||
* responses.
|
||||
*
|
||||
* Keep this file as a re-export shim (not a direct re-export from the package
|
||||
* barrel) so downstream `from './envelope'` imports inside the daemon stay
|
||||
* barrel) so downstream `from './envelope'` imports inside the server stay
|
||||
* stable and don't all need to be touched.
|
||||
*/
|
||||
export { okEnvelope, errEnvelope, type Envelope } from '@moonshot-ai/protocol';
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Fastify error hook for unhandled daemon exceptions.
|
||||
* Fastify error hook for unhandled server exceptions.
|
||||
*
|
||||
* Wraps unhandled exceptions in the Feishu-style envelope:
|
||||
* - HTTP status ALWAYS 200 (business outcome lives in `code`);
|
||||
|
|
@ -22,8 +22,8 @@ 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
|
||||
* `FastifyInstance` and the server's pino-typed variant
|
||||
* (`FastifyInstance<…, ServerLogger>`). The type checker chokes on the
|
||||
* concrete generic mismatch otherwise.
|
||||
*/
|
||||
interface ErrorHandlerHost {
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
export { startDaemon, DaemonLockedError } from './start';
|
||||
export type { DaemonStartOptions, RunningDaemon } from './start';
|
||||
export { startServer, ServerLockedError } from './start';
|
||||
export type { ServerStartOptions, RunningServer } from './start';
|
||||
export { okEnvelope, errEnvelope } from './envelope';
|
||||
export type { Envelope } from './envelope';
|
||||
export { createDaemonLogger } from './services/pinoLoggerService';
|
||||
export { createServerLogger } from './services/pinoLoggerService';
|
||||
export type {
|
||||
CreateLoggerOptions,
|
||||
DaemonLogger,
|
||||
DaemonLogLevel,
|
||||
ServerLogger,
|
||||
ServerLogLevel,
|
||||
} from './services/pinoLoggerService';
|
||||
export { acquireLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock';
|
||||
export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock';
|
||||
|
||||
export { resolveServiceManager, ServiceUnsupportedError } from './svc';
|
||||
export type {
|
||||
InstallArgs,
|
||||
InstallResult,
|
||||
LifecycleResult,
|
||||
ServiceManager,
|
||||
ServiceStatus,
|
||||
} from './svc';
|
||||
|
||||
export { IRestGateway } from '#/services/gateway';
|
||||
export { IConnectionRegistry } from '#/services/gateway';
|
||||
export { ISessionClientsService } from '#/services/gateway';
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
/**
|
||||
* Filesystem lock for single-instance daemon enforcement.
|
||||
* Filesystem lock for single-instance server enforcement.
|
||||
*
|
||||
* The lock is a small JSON file at `<KIMI_CODE_HOME>/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`,
|
||||
* The lock is a small JSON file at `<KIMI_CODE_HOME>/server/lock` (defaults
|
||||
* to `~/.kimi-code/server/lock`; overridable via `KIMI_CODE_HOME` env or
|
||||
* `lockPath` for tests). It records the live server's `pid`, `started_at`,
|
||||
* and `port`. Acquisition is exclusive (`O_WRONLY | O_CREAT | O_EXCL`) —
|
||||
* racing daemons can't both win.
|
||||
* racing servers 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.
|
||||
* we throw `ESERVER_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
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
* 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
|
||||
* matches ours, we log and continue rather than throw. Crashed servers may
|
||||
* leave the file dangling; the next start's stale-check cleans it up.
|
||||
*/
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ import { dirname, join } from 'node:path';
|
|||
|
||||
import { resolveKimiHome } from '@moonshot-ai/agent-core';
|
||||
|
||||
export const DEFAULT_LOCK_DIR = join(resolveKimiHome(), 'daemon');
|
||||
export const DEFAULT_LOCK_DIR = join(resolveKimiHome(), 'server');
|
||||
export const DEFAULT_LOCK_PATH = join(DEFAULT_LOCK_DIR, 'lock');
|
||||
|
||||
/** JSON shape stored in the lock file. snake_case to match operator-facing logs. */
|
||||
|
|
@ -45,31 +45,30 @@ export interface LockContents {
|
|||
pid: number;
|
||||
started_at: string;
|
||||
port: number;
|
||||
/** Host CLI version that started this daemon (e.g. kimi-code package version).
|
||||
Lets `kimi web`/`kimi daemon` detect a build-mismatched daemon and restart
|
||||
it instead of silently serving stale code. Absent in locks written by
|
||||
older builds. */
|
||||
/** Host CLI version that started this server (e.g. kimi-code package version).
|
||||
Lets `kimi server status` detect a build-mismatched server. Absent in locks
|
||||
written by older builds. */
|
||||
host_version?: string;
|
||||
/** Absolute path of the CLI entry that spawned the daemon. Distinguishes two
|
||||
/** Absolute path of the CLI entry that spawned the server. Distinguishes two
|
||||
installs that share a version string (e.g. two pkg.pr.new builds of the
|
||||
same base version living in different npx cache dirs). */
|
||||
entry?: string;
|
||||
}
|
||||
|
||||
export interface AcquireLockOptions {
|
||||
/** Override default `<KIMI_CODE_HOME>/daemon/lock` — used in tests. */
|
||||
/** Override default `<KIMI_CODE_HOME>/server/lock` — used in tests. */
|
||||
lockPath?: string;
|
||||
/** Port the daemon will bind to. Recorded in the lock file for diagnostics. */
|
||||
/** Port the server will bind to. Recorded in the lock file for diagnostics. */
|
||||
port: number;
|
||||
/** Host CLI version, recorded as `host_version` for build-mismatch detection. */
|
||||
hostVersion?: string;
|
||||
/** CLI entry path that spawned this daemon, recorded as `entry`. */
|
||||
/** CLI entry path that spawned this server, recorded as `entry`. */
|
||||
entry?: string;
|
||||
/** 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.
|
||||
* different server owning the lock. Production callers should not set this.
|
||||
*/
|
||||
pid?: number;
|
||||
}
|
||||
|
|
@ -81,14 +80,14 @@ export interface AcquireLockResult {
|
|||
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;
|
||||
/** Error thrown when another server is already holding the lock. */
|
||||
export class ServerLockedError extends Error {
|
||||
override readonly name = 'ServerLockedError';
|
||||
readonly code = 'ESERVER_LOCKED' as const;
|
||||
/**
|
||||
* Process exit code preferred by CLI consumers. `2` is distinct from generic
|
||||
* failure `1` so operators can scriptly distinguish
|
||||
* "another daemon is running" from "daemon crashed". Commander reads this if
|
||||
* "another server is running" from "server crashed". Commander reads this if
|
||||
* present; library callers can ignore it.
|
||||
*/
|
||||
readonly exitCode = 2 as const;
|
||||
|
|
@ -161,8 +160,8 @@ function tryExclusiveCreate(path: string, contents: LockContents): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Acquire an exclusive lock for this daemon instance. Throws `DaemonLockedError`
|
||||
* if another live daemon holds the lock; silently takes over a stale lock whose
|
||||
* Acquire an exclusive lock for this server instance. Throws `ServerLockedError`
|
||||
* if another live server holds the lock; silently takes over a stale lock whose
|
||||
* recorded pid is no longer running.
|
||||
*/
|
||||
export function acquireLock(opts: AcquireLockOptions): AcquireLockResult {
|
||||
|
|
@ -190,8 +189,8 @@ export function acquireLock(opts: AcquireLockOptions): AcquireLockResult {
|
|||
// 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})`,
|
||||
throw new ServerLockedError(
|
||||
`server already running (pid=${existing.pid}, port=${existing.port}, started=${existing.started_at})`,
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
|
@ -209,9 +208,9 @@ export function acquireLock(opts: AcquireLockOptions): AcquireLockResult {
|
|||
if (!tryExclusiveCreate(lockPath, contents)) {
|
||||
// Someone slipped in. Re-read for diagnostic.
|
||||
const winner = readLockContents(lockPath);
|
||||
throw new DaemonLockedError(
|
||||
throw new ServerLockedError(
|
||||
winner
|
||||
? `daemon already running (pid=${winner.pid}, port=${winner.port}, started=${winner.started_at})`
|
||||
? `server 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 },
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Zod → JSON Schema conversion helpers for Fastify `@fastify/swagger`.
|
||||
*
|
||||
* All daemon REST responses are wrapped in a uniform envelope
|
||||
* All server REST responses are wrapped in a uniform envelope
|
||||
* `{ code, msg, data, request_id }`. The helpers here let route modules
|
||||
* declare their OpenAPI schema by re-using the same Zod schemas that
|
||||
* already drive runtime validation — no second source of truth.
|
||||
|
|
@ -60,7 +60,7 @@ function jsonSchemaForTarget(
|
|||
}
|
||||
|
||||
/**
|
||||
* Wrap a data Zod schema in the daemon's envelope shape and return its
|
||||
* Wrap a data Zod schema in the server's envelope shape and return its
|
||||
* JSON Schema representation.
|
||||
*/
|
||||
export function envelopeJsonSchema(
|
||||
|
|
@ -23,7 +23,7 @@ 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
|
||||
* concrete generic parameters so it works against the server's pino-typed
|
||||
* variant without TS friction.
|
||||
*/
|
||||
interface ValidationRequest {
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* `request_id` resolution at the daemon's REST boundary.
|
||||
* `request_id` resolution at the server's REST boundary.
|
||||
*
|
||||
* Delegates to `parseOrGenerateRequestId` from `@moonshot-ai/protocol`, which:
|
||||
* - returns a bare 26-char ULID (no `req_` prefix);
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
* - 40404 (approval.not_found) — no pending approval matches {aid}
|
||||
* - 40902 (approval.already_resolved) — second resolve; custom envelope
|
||||
* `{code:40902, data:{resolved:false}}`
|
||||
* matching the daemon's idempotent-conflict pattern
|
||||
* matching the server's idempotent-conflict pattern
|
||||
* - 40001 (validation.failed) — bad body via the Zod preHandler
|
||||
*
|
||||
* **Mechanism**: idempotency is handled by the broker's `isPending()` gate
|
||||
|
|
@ -39,7 +39,7 @@ export function registerAuthRoute(app: RouteHost, ix: IInstantiationService): vo
|
|||
method: 'GET',
|
||||
path: '/auth',
|
||||
success: { data: authSummarySchema },
|
||||
description: 'Get daemon auth readiness snapshot',
|
||||
description: 'Get server auth readiness snapshot',
|
||||
tags: ['auth'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
/**
|
||||
* `/debug/*` REST routes.
|
||||
*
|
||||
* Only mounted when `startDaemon({debugEndpoints: true})`. The CLI never
|
||||
* Only mounted when `startServer({debugEndpoints: true})`. The CLI never
|
||||
* sets that option, so production daemons don't expose this surface.
|
||||
* Tests (daemon-e2e + in-process) flip it on to assert internals
|
||||
* Tests (server-e2e + in-process) flip it on to assert internals
|
||||
* that the user-facing surface can't reveal:
|
||||
*
|
||||
* GET /debug/prompts/{sid}/state data: AgentStateSnapshot | null
|
||||
|
|
@ -22,13 +22,13 @@
|
|||
* No auth; only mounted in explicit debug/test mode. Read endpoints return
|
||||
* `null` data (state) or empty array (dispatch-log) when the session has never
|
||||
* bootstrapped. The mutating active-prompt endpoint is test-only scaffolding
|
||||
* for daemon-e2e queue/steer coverage and should not be mounted by production
|
||||
* for server-e2e queue/steer coverage and should not be mounted by production
|
||||
* CLI callers.
|
||||
*
|
||||
* **Anti-corruption**: we resolve `IPromptService` via the accessor and
|
||||
* cast to the concrete `PromptService` class to reach the underscore-
|
||||
* prefixed `_agentStateForTest` / `_dispatchLogForTest` accessors. Same
|
||||
* pattern `packages/daemon/test/prompt.e2e.test.ts` already uses.
|
||||
* pattern `packages/server/test/prompt.e2e.test.ts` already uses.
|
||||
*/
|
||||
|
||||
import { IPromptService, PromptService } from '@moonshot-ai/services';
|
||||
|
|
@ -16,10 +16,10 @@
|
|||
* - Other errors fall through to the global `installErrorHandler` (→ 50001).
|
||||
*
|
||||
* **Wiring**: takes an `IInstantiationService` so each request resolves
|
||||
* `IMessageService` via the daemon's DI container. Same pattern as
|
||||
* `IMessageService` via the server'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
|
||||
* **Anti-corruption**: route file lives in `packages/server/src/` and goes
|
||||
* through `accessor.get(IMessageService)` whose impl lives in
|
||||
* `@moonshot-ai/services`. No SDK package imports.
|
||||
*/
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* `GET /meta` route handler.
|
||||
*
|
||||
* Returns the daemon's `daemon_version`, declared `capabilities` literal map,
|
||||
* a per-process `daemon_id` (ULID minted at boot — reset on every restart so
|
||||
* clients can detect a daemon restart and resync), and `started_at` ISO time.
|
||||
* Returns the server's `server_version`, declared `capabilities` literal map,
|
||||
* a per-process `server_id` (ULID minted at boot — reset on every restart so
|
||||
* clients can detect a server restart and resync), and `started_at` ISO time.
|
||||
*
|
||||
* **No DI**: this route doesn't touch services — it's pure daemon-self info.
|
||||
* **No DI**: this route doesn't touch services — it's pure server-self info.
|
||||
* The `MetaRouteOptions` payload
|
||||
* is provided by `start.ts` at registration time and frozen for the daemon's
|
||||
* is provided by `start.ts` at registration time and frozen for the server's
|
||||
* lifetime.
|
||||
*
|
||||
* **Wire shape**: matches `metaResponseSchema` (REST.md §3.1) exactly. The
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
* ULID set by Fastify's `genReqId` via `resolveRequestId`.
|
||||
*
|
||||
* **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.
|
||||
* version source is the server's own `package.json` read via
|
||||
* `getServerVersion()` — no indirection through services or agent-core.
|
||||
*/
|
||||
|
||||
import { metaResponseSchema } from '@moonshot-ai/protocol';
|
||||
|
|
@ -28,8 +28,8 @@ 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
|
||||
* `FastifyInstance` and the server's pino-typed variant
|
||||
* (`FastifyInstance<…, ServerLogger>`), same pattern as
|
||||
* `error-handler.ts:ErrorHandlerHost` and `rest-gateway.ts:FastifyLike`.
|
||||
*/
|
||||
interface RouteHost {
|
||||
|
|
@ -44,19 +44,19 @@ interface RouteHost {
|
|||
}
|
||||
|
||||
export interface MetaRouteOptions {
|
||||
/** Daemon `package.json` version. Cached at startup. */
|
||||
readonly daemonVersion: string;
|
||||
/** Server `package.json` version. Cached at startup. */
|
||||
readonly serverVersion: string;
|
||||
/** Per-process ULID. Minted once at boot in `start.ts`. */
|
||||
readonly daemonId: string;
|
||||
/** ISO 8601 UTC timestamp the daemon went live at. */
|
||||
readonly serverId: string;
|
||||
/** ISO 8601 UTC timestamp the server 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).
|
||||
// for the server's lifetime (capabilities are first-version literal `true`s).
|
||||
const data: MetaResponse = Object.freeze({
|
||||
daemon_version: opts.daemonVersion,
|
||||
server_version: opts.serverVersion,
|
||||
capabilities: Object.freeze({
|
||||
websocket: true as const,
|
||||
file_upload: true as const,
|
||||
|
|
@ -64,7 +64,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void
|
|||
mcp: true as const,
|
||||
background_tasks: true as const,
|
||||
}),
|
||||
daemon_id: opts.daemonId,
|
||||
server_id: opts.serverId,
|
||||
started_at: opts.startedAt,
|
||||
});
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void
|
|||
method: 'GET',
|
||||
path: '/meta',
|
||||
success: { data: metaResponseSchema },
|
||||
description: 'Get daemon metadata',
|
||||
description: 'Get server metadata',
|
||||
tags: ['meta'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
|
|
@ -35,7 +35,7 @@ import { defineRoute } from '../middleware/defineRoute';
|
|||
|
||||
/**
|
||||
* Structural Fastify subset — same shape as `meta.ts` / `auth.ts` so the
|
||||
* generic-mismatch with the daemon's pino-typed FastifyInstance doesn't
|
||||
* generic-mismatch with the server's pino-typed FastifyInstance doesn't
|
||||
* bleed into this file.
|
||||
*/
|
||||
interface RouteHost {
|
||||
|
|
@ -39,7 +39,7 @@ interface ApiV1RouteHost {
|
|||
}
|
||||
|
||||
export interface RegisterApiV1RoutesOptions {
|
||||
readonly daemonVersion: string;
|
||||
readonly serverVersion: string;
|
||||
readonly debugEndpoints?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +54,8 @@ export async function registerApiV1Routes(
|
|||
registerHealthRoute(apiV1);
|
||||
|
||||
registerMetaRoute(apiV1, {
|
||||
daemonVersion: opts.daemonVersion,
|
||||
daemonId: ulid(),
|
||||
serverVersion: opts.serverVersion,
|
||||
serverId: ulid(),
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
* session ← `ISessionService.get`
|
||||
* messages (asc) ← `IMessageService.list` (most recent page)
|
||||
* in_flight_turn ← broadcast's `InFlightTurnTracker`
|
||||
* pending_approvals ← daemon `ApprovalService.listPending`
|
||||
* pending_questions ← daemon `QuestionService.listPending`
|
||||
* pending_approvals ← server `ApprovalService.listPending`
|
||||
* pending_questions ← server `QuestionService.listPending`
|
||||
*
|
||||
* Watermark stability: the durable seq is read before and after assembly;
|
||||
* if a durable event landed in between, assembly retries (bounded). Durable
|
||||
|
|
@ -29,7 +29,7 @@ async function assertWebAssets(assetsDir: string): Promise<void> {
|
|||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Kimi web assets were not found at ${assetsDir}. Run the package build before starting the daemon.`,
|
||||
`Kimi web assets were not found at ${assetsDir}. Run the package build before starting the server.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function registerWorkspaceFsRoutes(
|
|||
path: '/fs::browse',
|
||||
querystring: fsBrowseQuerySchema,
|
||||
success: { data: fsBrowseResponseSchema },
|
||||
description: 'Browse local directories (daemon folder picker backend)',
|
||||
description: 'Browse local directories (server folder picker backend)',
|
||||
tags: ['workspaces'],
|
||||
operationId: 'fsBrowse',
|
||||
},
|
||||
|
|
@ -65,7 +65,7 @@ class PendingApproval implements IDisposable {
|
|||
this._settled = true;
|
||||
clearTimeout(this._timer);
|
||||
try {
|
||||
this._rejectFn(new Error('daemon shutting down'));
|
||||
this._rejectFn(new Error('server shutting down'));
|
||||
} catch {
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue