mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
feat(native,server): embed web assets and enrich lifecycle output
- collect and embed server web assets into the native SEA binary\n- add web-assets manifest and collection scripts for native builds\n- make swagger/swagger-ui registration conditional in server start\n- enrich lifecycle commands with service URL, running state, log path, and notes\n- add --no-open flag to control auto-opening web UI after install\n- introduce ServiceUnavailableError and ProgramServiceManager\n- update service managers (launchd, systemd, schtasks) with new status fields
This commit is contained in:
parent
657430f721
commit
5629f91dca
26 changed files with 1061 additions and 72 deletions
6
.changeset/embed-native-web-assets.md
Normal file
6
.changeset/embed-native-web-assets.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
"@moonshot-ai/server": patch
|
||||
---
|
||||
|
||||
Embed server web assets in the native binary and make installed server lifecycle output show the web URL and service state.
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
nativeSeaConfigPath,
|
||||
targetTriple,
|
||||
} from './paths.mjs';
|
||||
import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs';
|
||||
|
||||
async function ensureBundleExists() {
|
||||
try {
|
||||
|
|
@ -31,13 +32,19 @@ async function writeSeaConfig(target) {
|
|||
appRoot,
|
||||
target,
|
||||
});
|
||||
const web = await collectWebAssets({ appRoot, target });
|
||||
const manifestPath = resolve(nativeManifestDir(target), 'manifest.json');
|
||||
const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json');
|
||||
await mkdir(dirname(manifestPath), { recursive: true });
|
||||
await mkdir(dirname(webManifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, manifestJson);
|
||||
await writeFile(webManifestPath, web.manifestJson);
|
||||
|
||||
const seaAssets = {
|
||||
[nativeAssetManifestKey(target)]: manifestPath,
|
||||
[webAssetManifestKey(target)]: webManifestPath,
|
||||
...assets,
|
||||
...web.assets,
|
||||
};
|
||||
const config = {
|
||||
main: nativeJsBundlePath(),
|
||||
|
|
@ -55,6 +62,9 @@ async function writeSeaConfig(target) {
|
|||
for (const line of nativeAssetSummary(manifest)) {
|
||||
console.log(`- ${line}`);
|
||||
}
|
||||
console.log(
|
||||
`Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runSeaBlobStep() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const optionalRuntimeRequires = new Set([
|
|||
]);
|
||||
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
|
||||
const handledNativeRuntimeRequires = new Set(['koffi']);
|
||||
const disabledProductionDynamicImports = new Set(['@fastify/swagger', '@fastify/swagger-ui']);
|
||||
|
||||
function isAllowedSpecifier(specifier) {
|
||||
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
|
||||
|
|
@ -64,6 +65,7 @@ for (const line of executableLines()) {
|
|||
errors.push(`relative dynamic import remains: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (disabledProductionDynamicImports.has(specifier)) continue;
|
||||
if (!isAllowedSpecifier(specifier)) {
|
||||
errors.push(`external dynamic import remains: ${specifier}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
|
||||
export const WEB_ASSET_MANIFEST_VERSION = 1;
|
||||
|
||||
export function buildManifestKey(target) {
|
||||
return `native/${target}/manifest.json`;
|
||||
|
|
@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) {
|
|||
export function buildAssetKey(target, packageRoot, relativePath) {
|
||||
return `native/${target}/${packageRoot}/${relativePath}`;
|
||||
}
|
||||
|
||||
export function buildWebManifestKey(target) {
|
||||
return `web/${target}/manifest.json`;
|
||||
}
|
||||
|
||||
export function buildWebAssetKey(target, relativePath) {
|
||||
return `web/${target}/dist-web/${relativePath}`;
|
||||
}
|
||||
|
|
|
|||
118
apps/kimi-code/scripts/native/web-assets.mjs
Normal file
118
apps/kimi-code/scripts/native/web-assets.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
WEB_ASSET_MANIFEST_VERSION,
|
||||
buildWebAssetKey,
|
||||
buildWebManifestKey,
|
||||
} from './manifest.mjs';
|
||||
|
||||
export { WEB_ASSET_MANIFEST_VERSION };
|
||||
|
||||
const WEB_ASSETS_DIR = 'dist-web';
|
||||
|
||||
function toPosixPath(path) {
|
||||
return path.split('\\').join('/');
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function listFiles(root) {
|
||||
const files = [];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) {
|
||||
const requiredPath = join(assetRoot, requiredFile);
|
||||
try {
|
||||
const info = await stat(requiredPath);
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`${requiredFile} is not a file`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function webAssetManifestKey(target) {
|
||||
return buildWebManifestKey(target);
|
||||
}
|
||||
|
||||
export function webAssetKey(target, relativePath) {
|
||||
return buildWebAssetKey(target, relativePath);
|
||||
}
|
||||
|
||||
async function collectAssetRoot({
|
||||
appRoot,
|
||||
target,
|
||||
root,
|
||||
requiredFile,
|
||||
missingMessage,
|
||||
assetKey,
|
||||
}) {
|
||||
const assetRoot = resolve(appRoot, ...root.split('/'));
|
||||
await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage });
|
||||
|
||||
const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b));
|
||||
const manifestFiles = [];
|
||||
const assets = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file)) continue;
|
||||
const bytes = await readFile(file);
|
||||
const relativePath = toPosixPath(relative(assetRoot, file));
|
||||
const key = assetKey(target, relativePath);
|
||||
manifestFiles.push({
|
||||
assetKey: key,
|
||||
relativePath,
|
||||
sha256: sha256(bytes),
|
||||
});
|
||||
assets[key] = file;
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: WEB_ASSET_MANIFEST_VERSION,
|
||||
target,
|
||||
root,
|
||||
files: manifestFiles,
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestJson: `${JSON.stringify(manifest, null, 2)}\n`,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectWebAssets({ appRoot, target }) {
|
||||
const buildCommand =
|
||||
'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build';
|
||||
return collectAssetRoot({
|
||||
appRoot,
|
||||
target,
|
||||
root: WEB_ASSETS_DIR,
|
||||
requiredFile: 'index.html',
|
||||
missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`,
|
||||
assetKey: webAssetKey,
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
import type { Command } from 'commander';
|
||||
|
||||
import {
|
||||
ServiceUnavailableError,
|
||||
ServiceUnsupportedError,
|
||||
resolveServiceManager,
|
||||
type InstallArgs,
|
||||
|
|
@ -18,12 +19,15 @@ import {
|
|||
type ServiceStatus,
|
||||
} from '@moonshot-ai/server';
|
||||
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
|
||||
import {
|
||||
DEFAULT_LOG_LEVEL,
|
||||
DEFAULT_SERVER_HOST,
|
||||
DEFAULT_SERVER_PORT,
|
||||
parseLogLevel,
|
||||
parsePort,
|
||||
serverOrigin,
|
||||
VALID_LOG_LEVELS,
|
||||
} from './shared';
|
||||
|
||||
|
|
@ -32,6 +36,7 @@ export interface InstallCliOptions {
|
|||
port?: string;
|
||||
logLevel?: string;
|
||||
force?: boolean;
|
||||
open?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +46,14 @@ export interface JsonCliOptions {
|
|||
|
||||
export interface LifecycleCommandDeps {
|
||||
resolveManager(): ServiceManager;
|
||||
openUrl(url: string): void;
|
||||
stdout: Pick<NodeJS.WriteStream, 'write'>;
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPS: LifecycleCommandDeps = {
|
||||
resolveManager: resolveServiceManager,
|
||||
openUrl: defaultOpenUrl,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
};
|
||||
|
|
@ -64,6 +71,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
DEFAULT_LOG_LEVEL,
|
||||
)
|
||||
.option('--force', 'Reinstall and overwrite if already installed', false)
|
||||
.option('--no-open', 'Do not open the web UI after install.', true)
|
||||
.option('--json', 'Output JSON', false)
|
||||
.action(async (opts: InstallCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
|
|
@ -74,7 +82,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
force: opts.force === true,
|
||||
};
|
||||
const result = await mgr.install(args);
|
||||
return {
|
||||
const status = await readStatus(mgr);
|
||||
const enriched = withStatusDetails({
|
||||
ok: true,
|
||||
action: 'install',
|
||||
status: result.status,
|
||||
|
|
@ -82,7 +91,11 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
unitPath: result.unitPath,
|
||||
taskName: result.taskName,
|
||||
message: result.message,
|
||||
};
|
||||
}, status, args);
|
||||
if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') {
|
||||
deps.openUrl(enriched.url);
|
||||
}
|
||||
return enriched;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -104,7 +117,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
.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 };
|
||||
const status = await readStatus(mgr);
|
||||
return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -126,7 +140,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
.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 };
|
||||
const status = await readStatus(mgr);
|
||||
return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -137,7 +152,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
|
|||
.action(async (opts: JsonCliOptions) => {
|
||||
await runLifecycle(deps, opts.json === true, async (mgr) => {
|
||||
const status: ServiceStatus = await mgr.status();
|
||||
return { ok: true, action: 'status', ...status };
|
||||
return withStatusDetails({ ok: true, action: 'status', ...status }, status);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -156,10 +171,10 @@ async function runLifecycle(
|
|||
}
|
||||
deps.stdout.write(formatHuman(result));
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceUnsupportedError) {
|
||||
if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) {
|
||||
const payload = {
|
||||
ok: false,
|
||||
action: 'unsupported',
|
||||
action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported',
|
||||
platform: error.platform,
|
||||
message: error.message,
|
||||
};
|
||||
|
|
@ -187,5 +202,54 @@ function formatHuman(result: Record<string, unknown>): string {
|
|||
const action = typeof rawAction === 'string' ? rawAction : 'action';
|
||||
const rawMessage = result['message'];
|
||||
const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : '';
|
||||
return `${action}${message}\n`;
|
||||
const lines = [`${action}${message}`];
|
||||
|
||||
const url = result['url'];
|
||||
if (typeof url === 'string') lines.push(`URL: ${url}`);
|
||||
|
||||
const running = result['running'];
|
||||
if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`);
|
||||
|
||||
const logPath = result['logPath'];
|
||||
if (typeof logPath === 'string') lines.push(`Log: ${logPath}`);
|
||||
|
||||
const notes = result['notes'];
|
||||
if (Array.isArray(notes)) {
|
||||
for (const note of notes) {
|
||||
if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
async function readStatus(mgr: ServiceManager): Promise<ServiceStatus | undefined> {
|
||||
try {
|
||||
return await mgr.status();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function withStatusDetails(
|
||||
result: Record<string, unknown>,
|
||||
status: ServiceStatus | undefined,
|
||||
fallback?: { host: string; port: number },
|
||||
): Record<string, unknown> & { url?: string; running?: boolean } {
|
||||
const host = status?.host ?? fallback?.host;
|
||||
const port = status?.port ?? fallback?.port;
|
||||
const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined;
|
||||
return {
|
||||
...result,
|
||||
url,
|
||||
running: status?.running,
|
||||
host,
|
||||
port,
|
||||
logPath: status?.logPath,
|
||||
notes: status?.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function formatServiceUrl(host: string, port: number): string {
|
||||
return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { join } from 'node:path';
|
|||
|
||||
import { ServerLockedError, startServer } from '@moonshot-ai/server';
|
||||
|
||||
import { getNativeWebAssetsDir } from '#/native/web-assets';
|
||||
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
|
||||
|
||||
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
|
||||
|
|
@ -141,7 +142,13 @@ export async function startServerForeground(
|
|||
}
|
||||
|
||||
function serverWebAssetsDir(): string {
|
||||
return join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
return resolveServerWebAssetsDir();
|
||||
}
|
||||
|
||||
export function resolveServerWebAssetsDir(
|
||||
nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
|
||||
): string {
|
||||
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
|
||||
}
|
||||
|
||||
function formatAlreadyRunning(port: number, pid: number): string {
|
||||
|
|
|
|||
183
apps/kimi-code/src/native/web-assets.ts
Normal file
183
apps/kimi-code/src/native/web-assets.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { KIMI_BUILD_INFO } from '#/cli/build-info';
|
||||
import {
|
||||
getNativeCacheBase,
|
||||
getSeaAssetSource,
|
||||
type NativeAssetSource,
|
||||
} from './native-assets';
|
||||
import {
|
||||
WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION,
|
||||
buildWebManifestKey,
|
||||
} from '../../scripts/native/manifest.mjs';
|
||||
|
||||
export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION;
|
||||
|
||||
export interface WebAssetFile {
|
||||
readonly assetKey: string;
|
||||
readonly relativePath: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface WebAssetManifest {
|
||||
readonly version: typeof WEB_ASSET_MANIFEST_VERSION;
|
||||
readonly target: string;
|
||||
readonly root: 'dist-web';
|
||||
readonly files: readonly WebAssetFile[];
|
||||
}
|
||||
|
||||
export type WebAssetSource = NativeAssetSource;
|
||||
|
||||
export interface WebAssetOptions {
|
||||
readonly source?: WebAssetSource | null;
|
||||
readonly manifest?: WebAssetManifest | null;
|
||||
readonly cacheBase?: string;
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly homeDir?: string;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
type RawWebAssetManifest = Omit<WebAssetManifest, 'version' | 'root'> & {
|
||||
readonly version: number;
|
||||
readonly root: string;
|
||||
};
|
||||
|
||||
function currentTarget(): string {
|
||||
return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (typeof value === 'string') return Buffer.from(value);
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer | Uint8Array | string): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return sanitized.length > 0 ? sanitized : 'unknown';
|
||||
}
|
||||
|
||||
function readFileSha256(path: string): string | null {
|
||||
try {
|
||||
return sha256(readFileSync(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void {
|
||||
if (readFileSha256(path) === expectedSha256) return;
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, bytes, { mode: 0o644 });
|
||||
|
||||
try {
|
||||
renameSync(tempPath, path);
|
||||
return;
|
||||
} catch {
|
||||
if (readFileSha256(path) === expectedSha256) {
|
||||
rmSync(tempPath, { force: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
renameSync(tempPath, path);
|
||||
} catch (error) {
|
||||
rmSync(tempPath, { force: true });
|
||||
if (readFileSha256(path) === expectedSha256) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(relativePath: string): void {
|
||||
if (
|
||||
relativePath.length === 0 ||
|
||||
relativePath.startsWith('/') ||
|
||||
relativePath.includes('\\') ||
|
||||
relativePath.split('/').includes('..') ||
|
||||
/^[A-Za-z]:/.test(relativePath)
|
||||
) {
|
||||
throw new Error(`Invalid web asset relative path: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function webAssetManifestKey(target: string = currentTarget()): string {
|
||||
return buildWebManifestKey(target);
|
||||
}
|
||||
|
||||
export function getEmbeddedWebAssetManifest(
|
||||
source: WebAssetSource | null = getSeaAssetSource(),
|
||||
target = currentTarget(),
|
||||
): WebAssetManifest | null {
|
||||
if (source === null) return null;
|
||||
const key = webAssetManifestKey(target);
|
||||
if (!source.getAssetKeys().includes(key)) return null;
|
||||
const raw = source.getRawAsset(key);
|
||||
const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest;
|
||||
if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) {
|
||||
throw new Error(`Unsupported web asset manifest version: ${manifest.version}`);
|
||||
}
|
||||
if (manifest.target !== target) {
|
||||
throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`);
|
||||
}
|
||||
if (manifest.root !== 'dist-web') {
|
||||
throw new Error(`Unsupported web asset root: ${manifest.root}`);
|
||||
}
|
||||
return manifest as WebAssetManifest;
|
||||
}
|
||||
|
||||
export function getWebAssetCacheRoot(
|
||||
manifest: WebAssetManifest,
|
||||
options: WebAssetOptions = {},
|
||||
): string {
|
||||
const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev');
|
||||
const manifestHash = sha256(JSON.stringify(manifest));
|
||||
return join(
|
||||
getNativeCacheBase({
|
||||
cacheBase: options.cacheBase,
|
||||
env: options.env,
|
||||
platform: options.platform,
|
||||
homeDir: options.homeDir,
|
||||
}),
|
||||
'web',
|
||||
version,
|
||||
sanitizeSegment(manifest.target),
|
||||
manifestHash,
|
||||
manifest.root,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null {
|
||||
const source = options.source ?? getSeaAssetSource();
|
||||
if (source === null) return null;
|
||||
|
||||
const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget());
|
||||
if (manifest === null) return null;
|
||||
|
||||
const cacheRoot = getWebAssetCacheRoot(manifest, options);
|
||||
for (const file of manifest.files) {
|
||||
assertSafeRelativePath(file.relativePath);
|
||||
const bytes = toBuffer(source.getRawAsset(file.assetKey));
|
||||
const actualSha256 = sha256(bytes);
|
||||
if (actualSha256 !== file.sha256) {
|
||||
throw new Error(
|
||||
`Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`,
|
||||
);
|
||||
}
|
||||
ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256);
|
||||
}
|
||||
return cacheRoot;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { Command } from 'commander';
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerServerCommand } from '#/cli/sub/server';
|
||||
import { addLifecycleCommands } from '#/cli/sub/server/lifecycle';
|
||||
|
||||
function makeProgram(): Command {
|
||||
// `commander` exitOverride avoids killing the test runner when --help/error fires.
|
||||
|
|
@ -54,7 +55,7 @@ describe('kimi server', () => {
|
|||
expect(longs).toContain('--open');
|
||||
});
|
||||
|
||||
it('`server install` exposes --host, --port, --log-level, --force, --json', () => {
|
||||
it('`server install` exposes --host, --port, --log-level, --force, --no-open, --json', () => {
|
||||
const program = makeProgram();
|
||||
const install = program.commands
|
||||
.find((c) => c.name() === 'server')
|
||||
|
|
@ -65,6 +66,7 @@ describe('kimi server', () => {
|
|||
expect(longs).toContain('--port');
|
||||
expect(longs).toContain('--log-level');
|
||||
expect(longs).toContain('--force');
|
||||
expect(longs).toContain('--no-open');
|
||||
expect(longs).toContain('--json');
|
||||
});
|
||||
|
||||
|
|
@ -95,6 +97,175 @@ describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported
|
|||
});
|
||||
});
|
||||
|
||||
describe('`kimi server` lifecycle handles unavailable service managers', () => {
|
||||
it('prints a friendly JSON error and exits 2', async () => {
|
||||
const { ServiceUnavailableError } = await import('@moonshot-ai/server');
|
||||
const program = new Command('kimi').exitOverride();
|
||||
const server = program.command('server');
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => {
|
||||
throw new Error(`process.exit(${String(code)})`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
addLifecycleCommands(server, {
|
||||
resolveManager: () => ({
|
||||
install: async () => {
|
||||
throw new ServiceUnavailableError(
|
||||
'linux',
|
||||
'systemd --user is not available in this environment.',
|
||||
);
|
||||
},
|
||||
uninstall: async () => ({ ok: true, message: 'unused' }),
|
||||
start: async () => ({ ok: true, message: 'unused' }),
|
||||
stop: async () => ({ ok: true, message: 'unused' }),
|
||||
restart: async () => ({ ok: true, message: 'unused' }),
|
||||
status: async () => ({ platform: 'linux', installed: false, running: false }),
|
||||
}),
|
||||
openUrl: vi.fn(),
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['node', 'kimi', 'server', 'install', '--json']),
|
||||
).rejects.toThrow('process.exit(2)');
|
||||
|
||||
exit.mockRestore();
|
||||
expect(stderr).toBe('');
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
ok: false,
|
||||
action: 'unavailable',
|
||||
platform: 'linux',
|
||||
message: expect.stringContaining('server run --host 0.0.0.0'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server` lifecycle output', () => {
|
||||
it('install passes --force/--port, prints the URL, and opens it when running', async () => {
|
||||
const program = new Command('kimi').exitOverride();
|
||||
const server = program.command('server');
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let installArgs: unknown;
|
||||
const openUrl = vi.fn();
|
||||
|
||||
addLifecycleCommands(server, {
|
||||
resolveManager: () => ({
|
||||
install: async (args) => {
|
||||
installArgs = args;
|
||||
return {
|
||||
status: 'replaced',
|
||||
message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).',
|
||||
plistPath: '/tmp/kimi.plist',
|
||||
};
|
||||
},
|
||||
uninstall: async () => ({ ok: true, message: 'unused' }),
|
||||
start: async () => ({ ok: true, message: 'unused' }),
|
||||
stop: async () => ({ ok: true, message: 'unused' }),
|
||||
restart: async () => ({ ok: true, message: 'unused' }),
|
||||
status: async () => ({
|
||||
platform: 'darwin',
|
||||
installed: true,
|
||||
running: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9999,
|
||||
logPath: '/tmp/server.log',
|
||||
label: 'ai.moonshot.kimi-server',
|
||||
}),
|
||||
}),
|
||||
openUrl,
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'kimi',
|
||||
'server',
|
||||
'install',
|
||||
'--force',
|
||||
'--port',
|
||||
'9999',
|
||||
]);
|
||||
|
||||
expect(stderr).toBe('');
|
||||
expect(installArgs).toMatchObject({ port: 9999, force: true });
|
||||
expect(stdout).toContain('URL: http://127.0.0.1:9999');
|
||||
expect(stdout).toContain('Status: running');
|
||||
expect(stdout).toContain('Log: /tmp/server.log');
|
||||
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999');
|
||||
});
|
||||
|
||||
it('start prints URL and diagnostics when launchd did not keep the service running', async () => {
|
||||
const program = new Command('kimi').exitOverride();
|
||||
const server = program.command('server');
|
||||
let stdout = '';
|
||||
const openUrl = vi.fn();
|
||||
|
||||
addLifecycleCommands(server, {
|
||||
resolveManager: () => ({
|
||||
install: async () => ({ status: 'installed', message: 'unused' }),
|
||||
uninstall: async () => ({ ok: true, message: 'unused' }),
|
||||
start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }),
|
||||
stop: async () => ({ ok: true, message: 'unused' }),
|
||||
restart: async () => ({ ok: true, message: 'unused' }),
|
||||
status: async () => ({
|
||||
platform: 'darwin',
|
||||
installed: true,
|
||||
running: false,
|
||||
host: '127.0.0.1',
|
||||
port: 7878,
|
||||
logPath: '/tmp/server.log',
|
||||
label: 'ai.moonshot.kimi-server',
|
||||
notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'],
|
||||
}),
|
||||
}),
|
||||
openUrl,
|
||||
stdout: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
stdout += String(chunk);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await program.parseAsync(['node', 'kimi', 'server', 'start']);
|
||||
|
||||
expect(stdout).toContain('URL: http://127.0.0.1:7878');
|
||||
expect(stdout).toContain('Status: not running');
|
||||
expect(stdout).toContain('launchd state: spawn scheduled');
|
||||
expect(stdout).toContain('last exit code: 78 EX_CONFIG');
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('`kimi server` does not register a legacy `daemon` command', () => {
|
||||
it('hard-deletes the old name', () => {
|
||||
const program = makeProgram();
|
||||
|
|
@ -120,5 +291,17 @@ describe('shared parsers stay strict', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('server web asset directory resolution', () => {
|
||||
it('uses extracted SEA web assets when available', async () => {
|
||||
const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run');
|
||||
expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web');
|
||||
});
|
||||
|
||||
it('falls back to package dist-web outside SEA mode', async () => {
|
||||
const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run');
|
||||
expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/);
|
||||
});
|
||||
});
|
||||
|
||||
// Silence vi import for cases where the file is built before tests reference vi.
|
||||
void vi;
|
||||
|
|
|
|||
113
apps/kimi-code/test/native/web-assets.test.ts
Normal file
113
apps/kimi-code/test/native/web-assets.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getNativeWebAssetsDir,
|
||||
getWebAssetCacheRoot,
|
||||
WEB_ASSET_MANIFEST_VERSION,
|
||||
type WebAssetManifest,
|
||||
type WebAssetSource,
|
||||
} from '#/native/web-assets';
|
||||
|
||||
function sha256(bytes: Buffer | string): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function fakeWebAssets(files: Record<string, string>): {
|
||||
manifest: WebAssetManifest;
|
||||
source: WebAssetSource;
|
||||
} {
|
||||
const manifest: WebAssetManifest = {
|
||||
version: WEB_ASSET_MANIFEST_VERSION,
|
||||
target: 'test-target',
|
||||
root: 'dist-web',
|
||||
files: Object.entries(files).map(([relativePath, content]) => ({
|
||||
assetKey: `web/test-target/dist-web/${relativePath}`,
|
||||
relativePath,
|
||||
sha256: sha256(content),
|
||||
})),
|
||||
};
|
||||
const assets = new Map<string, Buffer>([
|
||||
['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))],
|
||||
...Object.entries(files).map(([relativePath, content]) => [
|
||||
`web/test-target/dist-web/${relativePath}`,
|
||||
Buffer.from(content),
|
||||
] as const),
|
||||
]);
|
||||
return {
|
||||
manifest,
|
||||
source: {
|
||||
getAssetKeys: () => [...assets.keys()],
|
||||
getRawAsset: (assetKey) => {
|
||||
const asset = assets.get(assetKey);
|
||||
if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`);
|
||||
return asset;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('web assets', () => {
|
||||
it('extracts embedded web assets into a dist-web cache directory', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-'));
|
||||
try {
|
||||
const { manifest, source } = fakeWebAssets({
|
||||
'index.html': '<div id="app"></div>\n',
|
||||
'assets/app.js': 'console.log("ok");\n',
|
||||
});
|
||||
|
||||
const webDir = getNativeWebAssetsDir({
|
||||
cacheBase: dir,
|
||||
manifest,
|
||||
source,
|
||||
version: 'test',
|
||||
});
|
||||
|
||||
expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' }));
|
||||
expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('<div id="app"></div>\n');
|
||||
expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe(
|
||||
'console.log("ok");\n',
|
||||
);
|
||||
expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('repairs corrupted extracted files on the next lookup', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-'));
|
||||
try {
|
||||
const { manifest, source } = fakeWebAssets({
|
||||
'index.html': '<html></html>',
|
||||
});
|
||||
|
||||
const webDir = getNativeWebAssetsDir({
|
||||
cacheBase: dir,
|
||||
manifest,
|
||||
source,
|
||||
version: 'test',
|
||||
});
|
||||
writeFileSync(join(webDir ?? '', 'index.html'), 'broken');
|
||||
|
||||
const repairedDir = getNativeWebAssetsDir({
|
||||
cacheBase: dir,
|
||||
manifest,
|
||||
source,
|
||||
version: 'test',
|
||||
});
|
||||
|
||||
expect(repairedDir).toBe(webDir);
|
||||
expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe('<html></html>');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no SEA web asset source is available', () => {
|
||||
expect(getNativeWebAssetsDir({ source: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
89
apps/kimi-code/test/scripts/native/web-assets.test.ts
Normal file
89
apps/kimi-code/test/scripts/native/web-assets.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectWebAssets,
|
||||
webAssetManifestKey,
|
||||
WEB_ASSET_MANIFEST_VERSION,
|
||||
} from '../../../scripts/native/web-assets.mjs';
|
||||
|
||||
function sha256(bytes: Buffer | string): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
describe('collectWebAssets', () => {
|
||||
it('collects dist-web files into deterministic SEA asset keys', async () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-'));
|
||||
try {
|
||||
mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true });
|
||||
writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<div id="app"></div>\n');
|
||||
writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n');
|
||||
|
||||
const { manifest, manifestJson, assets } = await collectWebAssets({
|
||||
appRoot,
|
||||
target: 'test-target',
|
||||
});
|
||||
|
||||
expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json');
|
||||
expect(manifest).toEqual({
|
||||
version: WEB_ASSET_MANIFEST_VERSION,
|
||||
target: 'test-target',
|
||||
root: 'dist-web',
|
||||
files: [
|
||||
{
|
||||
assetKey: 'web/test-target/dist-web/assets/app.js',
|
||||
relativePath: 'assets/app.js',
|
||||
sha256: sha256('console.log("ok");\n'),
|
||||
},
|
||||
{
|
||||
assetKey: 'web/test-target/dist-web/index.html',
|
||||
relativePath: 'index.html',
|
||||
sha256: sha256('<div id="app"></div>\n'),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.parse(manifestJson) as unknown).toEqual(manifest);
|
||||
expect(assets).toEqual({
|
||||
'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'),
|
||||
'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'),
|
||||
});
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails clearly when dist-web has not been built', async () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-'));
|
||||
try {
|
||||
await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow(
|
||||
/Kimi web build output was not found/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps manifest JSON parseable and stable', async () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-'));
|
||||
try {
|
||||
mkdirSync(join(appRoot, 'dist-web'), { recursive: true });
|
||||
writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<html></html>');
|
||||
|
||||
const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' });
|
||||
|
||||
expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe('<html></html>');
|
||||
expect(manifestJson.endsWith('\n')).toBe(true);
|
||||
expect(JSON.parse(manifestJson)).toMatchObject({
|
||||
version: WEB_ASSET_MANIFEST_VERSION,
|
||||
target: 'test-target',
|
||||
root: 'dist-web',
|
||||
});
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -17,10 +17,15 @@ const builtins = new Set([
|
|||
...builtinModules.map((name) => `node:${name}`),
|
||||
]);
|
||||
const optionalNativeDependencies = new Set(['cpu-features']);
|
||||
const nativeExternalDependencies = new Set([
|
||||
...optionalNativeDependencies,
|
||||
'@fastify/swagger',
|
||||
'@fastify/swagger-ui',
|
||||
]);
|
||||
|
||||
function shouldAlwaysBundle(id: string): boolean {
|
||||
if (builtins.has(id) || id.startsWith('node:')) return false;
|
||||
if (optionalNativeDependencies.has(id)) return false;
|
||||
if (nativeExternalDependencies.has(id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +58,7 @@ export default defineConfig({
|
|||
},
|
||||
deps: {
|
||||
alwaysBundle: shouldAlwaysBundle,
|
||||
neverBundle: [...optionalNativeDependencies],
|
||||
neverBundle: [...nativeExternalDependencies],
|
||||
onlyBundle: false,
|
||||
},
|
||||
outputOptions: {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export type {
|
|||
export { acquireLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock';
|
||||
export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock';
|
||||
|
||||
export { resolveServiceManager, ServiceUnsupportedError } from './svc';
|
||||
export { resolveServiceManager, ServiceUnavailableError, ServiceUnsupportedError } from './svc';
|
||||
export type {
|
||||
InstallArgs,
|
||||
InstallResult,
|
||||
|
|
|
|||
|
|
@ -39,10 +39,12 @@ import {
|
|||
} from '@moonshot-ai/services';
|
||||
import { ErrorCode } from '@moonshot-ai/protocol';
|
||||
import Fastify from 'fastify';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
import { promises as fspPromises } from 'node:fs';
|
||||
import { sep as nodePathSep, relative as nodePathRelativeNative } from 'node:path';
|
||||
import {
|
||||
join as nodePathJoin,
|
||||
sep as nodePathSep,
|
||||
relative as nodePathRelativeNative,
|
||||
} from 'node:path';
|
||||
|
||||
import { installErrorHandler } from './error-handler';
|
||||
import { transformOpenApiDocument } from './openapi/transforms';
|
||||
|
|
@ -83,6 +85,10 @@ export interface ServerStartOptions {
|
|||
|
||||
webAssetsDir?: string;
|
||||
|
||||
swagger?: boolean;
|
||||
|
||||
swaggerUiAssetsDir?: string;
|
||||
|
||||
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
|
||||
}
|
||||
|
||||
|
|
@ -123,39 +129,69 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
installErrorHandler(app);
|
||||
|
||||
const serverVersion = getServerVersion();
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Kimi Code Server API',
|
||||
description:
|
||||
'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.',
|
||||
version: serverVersion,
|
||||
const swaggerEnabled = opts.swagger === true;
|
||||
|
||||
async function registerSwagger(): Promise<void> {
|
||||
const { default: swagger } = await import('@fastify/swagger');
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Kimi Code Server API',
|
||||
description:
|
||||
'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.',
|
||||
version: serverVersion,
|
||||
},
|
||||
tags: [
|
||||
{ name: 'meta', description: 'Server metadata' },
|
||||
{ name: 'auth', description: 'Auth readiness & login state' },
|
||||
{ name: 'models', description: 'Configured model aliases' },
|
||||
{ name: 'providers', description: 'Configured providers' },
|
||||
{ name: 'sessions', description: 'Session lifecycle' },
|
||||
{ name: 'workspaces', description: 'Workspace registry + folder picker' },
|
||||
{ name: 'messages', description: 'Message history' },
|
||||
{ name: 'prompts', description: 'Prompt submission & abort' },
|
||||
{ name: 'approvals', description: 'Approval resolution' },
|
||||
{ name: 'questions', description: 'Question resolution & dismiss' },
|
||||
{ name: 'tools', description: 'Tool & MCP server management' },
|
||||
{ name: 'tasks', description: 'Background tasks' },
|
||||
{ name: 'terminals', description: 'PTY terminal sessions' },
|
||||
{ name: 'fs', description: 'Filesystem operations' },
|
||||
{ name: 'files', description: 'File upload & download' },
|
||||
],
|
||||
},
|
||||
tags: [
|
||||
{ name: 'meta', description: 'Server metadata' },
|
||||
{ name: 'auth', description: 'Auth readiness & login state' },
|
||||
{ name: 'models', description: 'Configured model aliases' },
|
||||
{ name: 'providers', description: 'Configured providers' },
|
||||
{ name: 'sessions', description: 'Session lifecycle' },
|
||||
{ name: 'workspaces', description: 'Workspace registry + folder picker' },
|
||||
{ name: 'messages', description: 'Message history' },
|
||||
{ name: 'prompts', description: 'Prompt submission & abort' },
|
||||
{ name: 'approvals', description: 'Approval resolution' },
|
||||
{ name: 'questions', description: 'Question resolution & dismiss' },
|
||||
{ name: 'tools', description: 'Tool & MCP server management' },
|
||||
{ name: 'tasks', description: 'Background tasks' },
|
||||
{ name: 'terminals', description: 'PTY terminal sessions' },
|
||||
{ name: 'fs', description: 'Filesystem operations' },
|
||||
{ name: 'files', description: 'File upload & download' },
|
||||
],
|
||||
},
|
||||
transformObject: (documentObject) => {
|
||||
if (!('openapiObject' in documentObject)) {
|
||||
return documentObject.swaggerObject;
|
||||
}
|
||||
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
transformObject: (documentObject) => {
|
||||
if (!('openapiObject' in documentObject)) {
|
||||
return documentObject.swaggerObject;
|
||||
}
|
||||
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function registerSwaggerUi(assetsDir: string | undefined): Promise<void> {
|
||||
const { default: swaggerUi } = await import('@fastify/swagger-ui');
|
||||
const logo =
|
||||
assetsDir === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: 'image/svg+xml',
|
||||
content: await fspPromises.readFile(nodePathJoin(assetsDir, 'logo.svg')),
|
||||
};
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/documentation',
|
||||
baseDir: assetsDir,
|
||||
logo,
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (swaggerEnabled) {
|
||||
await registerSwagger();
|
||||
}
|
||||
|
||||
const envService: IEnvironmentService = {
|
||||
_serviceBrand: undefined,
|
||||
|
|
@ -179,13 +215,9 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
|
|||
debugEndpoints: opts.debugEndpoints,
|
||||
});
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/documentation',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: true,
|
||||
},
|
||||
});
|
||||
if (swaggerEnabled) {
|
||||
await registerSwaggerUi(opts.swaggerUiAssetsDir);
|
||||
}
|
||||
|
||||
if (opts.webAssetsDir !== undefined) {
|
||||
await registerWebAssetRoutes(app, opts.webAssetsDir);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
KIMI_SERVER_TASK_NAME,
|
||||
} from './paths';
|
||||
export {
|
||||
ServiceUnavailableError,
|
||||
ServiceUnsupportedError,
|
||||
type InstallArgs,
|
||||
type InstallResult,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
launchAgentPlistPath as defaultLaunchAgentPlistPath,
|
||||
supervisorLogPath as defaultSupervisorLogPath,
|
||||
} from './paths';
|
||||
import { resolveSupervisorProgram } from './program';
|
||||
import type {
|
||||
InstallArgs,
|
||||
InstallResult,
|
||||
|
|
@ -55,12 +56,14 @@ export interface LaunchdManagerDeps {
|
|||
|
||||
const DEFAULT_DEPS: LaunchdManagerDeps = {
|
||||
execLaunchctl: (args, options) => execFileUtf8('launchctl', args, options),
|
||||
resolveProgram: () => process.argv[1] ?? 'kimi',
|
||||
resolveProgram: () => resolveSupervisorProgram(),
|
||||
plistPath: defaultLaunchAgentPlistPath,
|
||||
logPath: defaultSupervisorLogPath,
|
||||
guiDomain: () => defaultGuiDomain(),
|
||||
};
|
||||
|
||||
export { resolveSupervisorProgram };
|
||||
|
||||
/** Construct the launchd backend. Pass `deps` overrides in tests. */
|
||||
export function createLaunchdManager(
|
||||
overrides: Partial<LaunchdManagerDeps> = {},
|
||||
|
|
@ -217,14 +220,18 @@ export function createLaunchdManager(
|
|||
};
|
||||
}
|
||||
const info = parseLaunchctlPrint(print.stdout);
|
||||
const notes =
|
||||
info.state !== undefined
|
||||
? [`launchd state: ${info.state}`]
|
||||
: ['launchd state: unknown'];
|
||||
if (info.lastExitCode !== undefined) {
|
||||
notes.push(`last exit code: ${info.lastExitCode}`);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
running: info.state === 'running' || info.pid !== undefined,
|
||||
...(info.pid !== undefined ? { pid: info.pid } : {}),
|
||||
notes:
|
||||
info.state !== undefined
|
||||
? [`launchd state: ${info.state}`]
|
||||
: ['launchd state: unknown'],
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -232,8 +239,12 @@ export function createLaunchdManager(
|
|||
}
|
||||
|
||||
/** Pure parser for `launchctl print <domain>/<label>` output. Exported for tests. */
|
||||
export function parseLaunchctlPrint(output: string): { state?: string; pid?: number } {
|
||||
const result: { state?: string; pid?: number } = {};
|
||||
export function parseLaunchctlPrint(output: string): {
|
||||
state?: string;
|
||||
pid?: number;
|
||||
lastExitCode?: string;
|
||||
} {
|
||||
const result: { state?: string; pid?: number; lastExitCode?: string } = {};
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
const equalsIdx = line.indexOf('=');
|
||||
|
|
@ -247,6 +258,8 @@ export function parseLaunchctlPrint(output: string): { state?: string; pid?: num
|
|||
if (Number.isFinite(n) && n > 0) {
|
||||
result.pid = n;
|
||||
}
|
||||
} else if ((key === 'last exit code' || key === 'last exit status') && result.lastExitCode === undefined) {
|
||||
result.lastExitCode = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
10
packages/server/src/svc/program.ts
Normal file
10
packages/server/src/svc/program.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { isAbsolute, resolve } from 'node:path';
|
||||
|
||||
export function resolveSupervisorProgram(
|
||||
argv: readonly string[] = process.argv,
|
||||
cwd: string = process.cwd(),
|
||||
execPath: string = process.execPath,
|
||||
): string {
|
||||
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
|
||||
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type InstallPlan,
|
||||
} from './install-plan';
|
||||
import { KIMI_SERVER_TASK_NAME, supervisorLogPath as defaultSupervisorLogPath } from './paths';
|
||||
import { resolveSupervisorProgram } from './program';
|
||||
import { buildScheduledTaskXml, parseSchtasksQuery } from './schtasks-xml';
|
||||
import type {
|
||||
InstallArgs,
|
||||
|
|
@ -48,7 +49,7 @@ export interface SchtasksManagerDeps {
|
|||
const DEFAULT_DEPS: SchtasksManagerDeps = {
|
||||
execSchtasks: (args, options) =>
|
||||
execFileUtf8('schtasks', args, { windowsHide: true, ...options }),
|
||||
resolveProgram: () => process.argv[1] ?? 'kimi.exe',
|
||||
resolveProgram: () => resolveSupervisorProgram(process.argv, process.cwd(), 'kimi.exe'),
|
||||
logPath: defaultSupervisorLogPath,
|
||||
writeTaskXml: defaultWriteTaskXml,
|
||||
taskExists: defaultTaskExists,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import {
|
|||
supervisorLogPath as defaultSupervisorLogPath,
|
||||
systemdUnitPath as defaultSystemdUnitPath,
|
||||
} from './paths';
|
||||
import { resolveSupervisorProgram } from './program';
|
||||
import { buildSystemdUnit, parseSystemctlShow } from './systemd-unit';
|
||||
import { ServiceUnavailableError } from './types';
|
||||
import type {
|
||||
InstallArgs,
|
||||
InstallResult,
|
||||
|
|
@ -54,7 +56,7 @@ export interface SystemdManagerDeps {
|
|||
|
||||
const DEFAULT_DEPS: SystemdManagerDeps = {
|
||||
execSystemctl: (args, options) => execFileUtf8('systemctl', ['--user', ...args], options),
|
||||
resolveProgram: () => process.argv[1] ?? 'kimi',
|
||||
resolveProgram: () => resolveSupervisorProgram(),
|
||||
unitPath: defaultSystemdUnitPath,
|
||||
logPath: defaultSupervisorLogPath,
|
||||
};
|
||||
|
|
@ -79,6 +81,8 @@ export function createSystemdManager(
|
|||
};
|
||||
}
|
||||
|
||||
await assertUserSystemdAvailable(deps);
|
||||
|
||||
writeUnit(unitPath, plan);
|
||||
writeInstallPlan(plan);
|
||||
|
||||
|
|
@ -210,6 +214,16 @@ export function createSystemdManager(
|
|||
return { install, uninstall, start, stop, restart, status };
|
||||
}
|
||||
|
||||
async function assertUserSystemdAvailable(deps: SystemdManagerDeps): Promise<void> {
|
||||
const probe = await deps.execSystemctl(['show-environment']);
|
||||
if (probe.code === 0) return;
|
||||
|
||||
throw new ServiceUnavailableError(
|
||||
'linux',
|
||||
`systemd --user is not available in this environment: ${detail(probe) ?? 'systemctl --user show-environment failed'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeUnit(unitPath: string, plan: InstallPlan): void {
|
||||
const text = buildSystemdUnit({
|
||||
description: 'Kimi Code local server (managed by `kimi server install`)',
|
||||
|
|
|
|||
|
|
@ -95,3 +95,18 @@ export class ServiceUnsupportedError extends Error {
|
|||
this.platform = platform;
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when the platform is supported but the current environment cannot host the service. */
|
||||
export class ServiceUnavailableError extends Error {
|
||||
override readonly name = 'ServiceUnavailableError';
|
||||
readonly code = 'ESERVICE_UNAVAILABLE' as const;
|
||||
readonly exitCode = 2 as const;
|
||||
readonly platform: string;
|
||||
|
||||
constructor(platform: string, reason: string) {
|
||||
super(
|
||||
`${reason} Run \`kimi server run --host 0.0.0.0 --port <port>\` directly when running inside Docker or another container supervisor.`,
|
||||
);
|
||||
this.platform = platform;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,42 @@ describe('startServer — web assets', () => {
|
|||
const health = await fetch(`${r.address}/api/v1/healthz`);
|
||||
await expect(health.json()).resolves.toMatchObject({ code: 0 });
|
||||
});
|
||||
|
||||
it('does not expose Swagger documentation by default', async () => {
|
||||
const r = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: silentLogger(),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
});
|
||||
running.push(r);
|
||||
|
||||
const res = await fetch(`${r.address}/documentation`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('serves Swagger UI static assets from an explicit directory when enabled', async () => {
|
||||
const staticDir = join(tmpDir, 'swagger-static');
|
||||
rmSync(staticDir, { recursive: true, force: true });
|
||||
mkdirSync(staticDir);
|
||||
writeFileSync(join(staticDir, 'logo.svg'), '<svg id="custom-logo"></svg>', 'utf8');
|
||||
|
||||
const r = await startServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lockPath,
|
||||
logger: silentLogger(),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
swagger: true,
|
||||
swaggerUiAssetsDir: staticDir,
|
||||
});
|
||||
running.push(r);
|
||||
|
||||
await expect(
|
||||
fetch(`${r.address}/documentation/static/logo.svg`).then((res) => res.text()),
|
||||
).resolves.toBe('<svg id="custom-logo"></svg>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startServer — DI container wiring', () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { buildLaunchAgentPlist } from '../../src/svc/launchd-plist';
|
||||
import {
|
||||
createLaunchdManager,
|
||||
resolveSupervisorProgram,
|
||||
parseLaunchctlPrint,
|
||||
type LaunchdManagerDeps,
|
||||
} from '../../src/svc/launchd';
|
||||
|
|
@ -119,13 +120,14 @@ describe('parseLaunchctlPrint', () => {
|
|||
`${'gui/501/' + KIMI_SERVER_LABEL} = {`,
|
||||
'\tstate = running',
|
||||
'\tpid = 4711',
|
||||
'\tlast exit status = 0',
|
||||
'\tlast exit code = 78: EX_CONFIG',
|
||||
'\tprogram = /usr/local/bin/kimi',
|
||||
'}',
|
||||
].join('\n');
|
||||
const parsed = parseLaunchctlPrint(sample);
|
||||
expect(parsed.state).toBe('running');
|
||||
expect(parsed.pid).toBe(4711);
|
||||
expect(parsed.lastExitCode).toBe('78: EX_CONFIG');
|
||||
});
|
||||
|
||||
it('returns undefined fields when keys are missing', () => {
|
||||
|
|
@ -135,6 +137,12 @@ describe('parseLaunchctlPrint', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('resolveSupervisorProgram', () => {
|
||||
it('normalizes a relative executable path to an absolute path', () => {
|
||||
expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe('/tmp/kimi-bin/kimi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchd manager — install', () => {
|
||||
it('writes the plist and bootstraps via launchctl', async () => {
|
||||
const { deps, calls, plistPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from '../../src/svc/systemd-unit';
|
||||
import { KIMI_SERVER_SYSTEMD_UNIT } from '../../src/svc/paths';
|
||||
import { readInstallPlan, writeInstallPlan } from '../../src/svc/install-plan';
|
||||
import { ServiceUnavailableError } from '../../src/svc/types';
|
||||
import type { ExecOptions, ExecResult } from '../../src/svc/exec';
|
||||
|
||||
interface StubCall {
|
||||
|
|
@ -135,6 +136,7 @@ describe('systemd manager — install', () => {
|
|||
it('writes the unit, daemon-reloads, enables --now', async () => {
|
||||
const { deps, calls, unitPath } = makeDeps(
|
||||
[
|
||||
{ stdout: '', stderr: '', code: 0 }, // show-environment
|
||||
{ stdout: '', stderr: '', code: 0 }, // daemon-reload
|
||||
{ stdout: '', stderr: '', code: 0 }, // enable --now
|
||||
],
|
||||
|
|
@ -149,9 +151,10 @@ describe('systemd manager — install', () => {
|
|||
const text = readFileSync(unitPath, 'utf8');
|
||||
expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --host 127.0.0.1 --port 7878 --log-level info');
|
||||
|
||||
expect(calls.length).toBe(2);
|
||||
expect(calls[0]?.args).toEqual(['daemon-reload']);
|
||||
expect(calls[1]?.args).toEqual(['enable', '--now', KIMI_SERVER_SYSTEMD_UNIT]);
|
||||
expect(calls.length).toBe(3);
|
||||
expect(calls[0]?.args).toEqual(['show-environment']);
|
||||
expect(calls[1]?.args).toEqual(['daemon-reload']);
|
||||
expect(calls[2]?.args).toEqual(['enable', '--now', KIMI_SERVER_SYSTEMD_UNIT]);
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing install without --force', async () => {
|
||||
|
|
@ -168,6 +171,7 @@ describe('systemd manager — install', () => {
|
|||
it('overwrites + replaces when force=true', async () => {
|
||||
const { deps, unitPath } = makeDeps(
|
||||
[
|
||||
{ stdout: '', stderr: '', code: 0 }, // show-environment
|
||||
{ stdout: '', stderr: '', code: 0 }, // daemon-reload
|
||||
{ stdout: '', stderr: '', code: 0 }, // enable --now
|
||||
],
|
||||
|
|
@ -183,8 +187,36 @@ describe('systemd manager — install', () => {
|
|||
expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --host 0.0.0.0 --port 9999 --log-level debug');
|
||||
});
|
||||
|
||||
it('fails before writing files when user systemd is unavailable', async () => {
|
||||
const { deps, calls, unitPath } = makeDeps(
|
||||
[
|
||||
{
|
||||
stdout: '',
|
||||
stderr: 'System has not been booted with systemd as init system (PID 1).',
|
||||
code: 1,
|
||||
},
|
||||
],
|
||||
workDir,
|
||||
);
|
||||
const mgr = createSystemdManager(deps);
|
||||
|
||||
await expect(
|
||||
mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableError);
|
||||
|
||||
expect(calls).toEqual([{ args: ['show-environment'], options: undefined }]);
|
||||
expect(existsSync(unitPath)).toBe(false);
|
||||
expect(readInstallPlan()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces daemon-reload failure as a thrown error', async () => {
|
||||
const { deps } = makeDeps([{ stdout: '', stderr: 'unit not loaded', code: 1 }], workDir);
|
||||
const { deps } = makeDeps(
|
||||
[
|
||||
{ stdout: '', stderr: '', code: 0 },
|
||||
{ stdout: '', stderr: 'unit not loaded', code: 1 },
|
||||
],
|
||||
workDir,
|
||||
);
|
||||
const mgr = createSystemdManager(deps);
|
||||
await expect(
|
||||
mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }),
|
||||
|
|
@ -194,6 +226,7 @@ describe('systemd manager — install', () => {
|
|||
it('surfaces enable --now failure as a thrown error', async () => {
|
||||
const { deps } = makeDeps(
|
||||
[
|
||||
{ stdout: '', stderr: '', code: 0 },
|
||||
{ stdout: '', stderr: '', code: 0 },
|
||||
{ stdout: '', stderr: 'unit failed to start', code: 1 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ async function bootDaemon(): Promise<RunningServer> {
|
|||
lockPath,
|
||||
logger: pino({ level: 'silent' }),
|
||||
coreProcessOptions: { homeDir: bridgeHome },
|
||||
swagger: true,
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,9 +323,10 @@ export class PromptService
|
|||
return item;
|
||||
}
|
||||
|
||||
await this._startPrompt(sid, state);
|
||||
const item = toPromptItem(state, 'running');
|
||||
this._publishSubmitted(sid, item);
|
||||
await this._startPrompt(sid, state, () => {
|
||||
this._publishSubmitted(sid, item);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
|
|
@ -377,7 +378,11 @@ export class PromptService
|
|||
return { steered: true, prompt_ids: [...promptIds] };
|
||||
}
|
||||
|
||||
private async _startPrompt(sid: string, state: PromptState): Promise<void> {
|
||||
private async _startPrompt(
|
||||
sid: string,
|
||||
state: PromptState,
|
||||
onStarted?: () => void,
|
||||
): Promise<void> {
|
||||
const overridePatch = pickAgentStatePatch(state.body);
|
||||
if (overridePatch !== undefined) {
|
||||
await this._ensureAgentStateBootstrapped(sid);
|
||||
|
|
@ -386,6 +391,7 @@ export class PromptService
|
|||
|
||||
this._active.set(sid, state);
|
||||
const input = contentToCoreParts(state.body.content);
|
||||
onStarted?.();
|
||||
|
||||
// Fire-and-forget. agent-core streams events via the SDK side of the
|
||||
// RPC pair which lands on `BridgeClientAPI.emitEvent → IEventService.publish`.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ interface BridgeStubOptions {
|
|||
permission?: { mode: 'manual' | 'yolo' | 'auto' };
|
||||
plan?: null | { id: string; content: string; path: string };
|
||||
sessions?: SessionSummary[];
|
||||
onPrompt?: (payload: unknown) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function makeBridge(
|
||||
|
|
@ -148,6 +149,7 @@ function makeBridge(
|
|||
resumeSession: vi.fn().mockResolvedValue(undefined as unknown as never),
|
||||
prompt: vi.fn().mockImplementation(async (payload) => {
|
||||
record.promptCalls.push(payload);
|
||||
await opts.onPrompt?.(payload);
|
||||
}),
|
||||
steer: vi.fn().mockImplementation(async (payload) => {
|
||||
record.steerCalls.push(payload);
|
||||
|
|
@ -376,6 +378,34 @@ describe('PromptService.submit', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('publishes prompt.submitted before core prompt events can start the turn', async () => {
|
||||
const { bus, events } = makeBus();
|
||||
const { bridge } = makeBridge({
|
||||
onPrompt: () => {
|
||||
bus.publish({
|
||||
type: 'turn.started',
|
||||
turnId: 7,
|
||||
origin: { kind: 'user' },
|
||||
sessionId: SID,
|
||||
agentId: 'main',
|
||||
} as unknown as Event);
|
||||
},
|
||||
});
|
||||
const impl = newSvc(bridge, bus);
|
||||
|
||||
const result = await impl.submit(SID, mkBodyMinimal());
|
||||
|
||||
expect(events.map((event) => event.type).slice(0, 2)).toEqual([
|
||||
'prompt.submitted',
|
||||
'turn.started',
|
||||
]);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'prompt.submitted',
|
||||
promptId: result.prompt_id,
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
|
||||
it('queues a second prompt when a non-terminal prompt is already active', async () => {
|
||||
const { bridge } = makeBridge();
|
||||
const { bus } = makeBus();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue