diff --git a/apps/cli/src/import/stream-import.test.ts b/apps/cli/src/import/stream-import.test.ts index e5beb8a5..8dc856b0 100644 --- a/apps/cli/src/import/stream-import.test.ts +++ b/apps/cli/src/import/stream-import.test.ts @@ -4,8 +4,13 @@ import os from 'node:os' import path from 'node:path' import test from 'node:test' import type { PathProvider } from '@openchatlab/core' -import { DatabaseManager, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime' -import { incrementalImport } from './stream-import' +import { + DatabaseManager, + DataDirCompatibilityError, + raiseDataDirMinRuntimeVersion, + readDataDirCompatibilityMeta, +} from '@openchatlab/node-runtime' +import { analyzeIncrementalImport, incrementalImport } from './stream-import' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') @@ -79,3 +84,29 @@ test('incrementalImport raises the data directory gate after successful writes', assert.equal(meta?.dataCompatibilityVersion, 1) assert.deepEqual(meta?.reasons, ['segment-schema']) }) + +test('analyzeIncrementalImport propagates data directory compatibility errors', async () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + fs.mkdirSync(pathProvider.getDatabaseDir(), { recursive: true }) + fs.writeFileSync(path.join(pathProvider.getDatabaseDir(), 'existing.db'), 'not opened before compatibility check') + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + const manager = new DatabaseManager(pathProvider, { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + const filePath = path.join(root, 'incremental.jsonl') + writeIncrementalJsonl(filePath) + + await assert.rejects( + () => analyzeIncrementalImport(manager, 'existing', filePath), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + ) +}) diff --git a/apps/cli/src/import/stream-import.ts b/apps/cli/src/import/stream-import.ts index 3d8ca633..5a572f66 100644 --- a/apps/cli/src/import/stream-import.ts +++ b/apps/cli/src/import/stream-import.ts @@ -223,7 +223,16 @@ export async function analyzeIncrementalImport( filePath: string, onProgress?: ImportProgressCallback ): Promise { - return sharedAnalyzeIncremental(sessionId, filePath, buildIncrementalDeps(dbManager, onProgress)) + let compatibilityError: DataDirCompatibilityError | null = null + const result = await sharedAnalyzeIncremental( + sessionId, + filePath, + buildIncrementalDeps(dbManager, onProgress, (error) => { + compatibilityError = error + }) + ) + if (compatibilityError) throw compatibilityError + return result } export async function analyzeNewImport( diff --git a/apps/desktop/main/api/errors.test.ts b/apps/desktop/main/api/errors.test.ts new file mode 100644 index 00000000..3b33e5f9 --- /dev/null +++ b/apps/desktop/main/api/errors.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' +import { ApiErrorCode, apiErrorFromUnknown } from './errors' + +test('apiErrorFromUnknown maps wrapped data directory compatibility errors to 409', () => { + const cause = new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + 'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.', + { + userDataDir: '/tmp/chatlab-data', + metaPath: '/tmp/chatlab-data/.chatlab-meta.json', + currentVersion: '0.25.0', + minRuntimeVersion: '0.25.1', + } + ) + + const apiError = apiErrorFromUnknown(new Error('Desktop startup formatted message', { cause })) + + assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE) + assert.equal(apiError?.statusCode, 409) + assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/) +}) diff --git a/apps/desktop/main/api/errors.ts b/apps/desktop/main/api/errors.ts index 00060835..34f6b530 100644 --- a/apps/desktop/main/api/errors.ts +++ b/apps/desktop/main/api/errors.ts @@ -2,6 +2,8 @@ * ChatLab API — Error codes and factory functions */ +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' + export enum ApiErrorCode { UNAUTHORIZED = 'UNAUTHORIZED', SESSION_NOT_FOUND = 'SESSION_NOT_FOUND', @@ -15,6 +17,7 @@ export enum ApiErrorCode { IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT', IDEMPOTENCY_PENDING = 'IDEMPOTENCY_PENDING', IMPORT_FAILED = 'IMPORT_FAILED', + DATA_DIR_INCOMPATIBLE = 'DATA_DIR_INCOMPATIBLE', SERVER_ERROR = 'SERVER_ERROR', } @@ -31,6 +34,7 @@ const HTTP_STATUS: Record = { [ApiErrorCode.IDEMPOTENCY_CONFLICT]: 409, [ApiErrorCode.IDEMPOTENCY_PENDING]: 409, [ApiErrorCode.IMPORT_FAILED]: 500, + [ApiErrorCode.DATA_DIR_INCOMPATIBLE]: 409, [ApiErrorCode.SERVER_ERROR]: 500, } @@ -89,10 +93,27 @@ export function importFailed(message: string): ApiError { return new ApiError(ApiErrorCode.IMPORT_FAILED, message) } +export function dataDirIncompatible(message: string): ApiError { + return new ApiError(ApiErrorCode.DATA_DIR_INCOMPATIBLE, message) +} + export function serverError(message = 'Internal server error'): ApiError { return new ApiError(ApiErrorCode.SERVER_ERROR, message) } +export function apiErrorFromUnknown(error: unknown): ApiError | null { + if (error instanceof ApiError) return error + const compatibilityError = findDataDirCompatibilityError(error) + if (compatibilityError) return dataDirIncompatible(compatibilityError.message) + return null +} + +function findDataDirCompatibilityError(error: unknown): DataDirCompatibilityError | null { + if (error instanceof DataDirCompatibilityError) return error + if (error instanceof Error && error.cause) return findDataDirCompatibilityError(error.cause) + return null +} + export function successResponse(data: T, meta?: Record) { return { success: true as const, diff --git a/apps/desktop/main/api/routes/import.ts b/apps/desktop/main/api/routes/import.ts index 7b4057a8..58bf2f4e 100644 --- a/apps/desktop/main/api/routes/import.ts +++ b/apps/desktop/main/api/routes/import.ts @@ -24,6 +24,7 @@ import { ApiError, ApiErrorCode, successResponse, + apiErrorFromUnknown, importInProgress, importFailed, invalidFormat, @@ -328,6 +329,11 @@ async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, } catch (error: any) { idempotencyFail(cacheKey) apiLogger.error('Import error', error) + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } const err = importFailed(error.message || 'Import process error') reply.code(err.statusCode).send(errorResponse(err)) } finally { @@ -421,6 +427,11 @@ async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, } } catch (error: any) { apiLogger.error('Import error', error) + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } const err = importFailed(error.message || 'Import process error') reply.code(err.statusCode).send(errorResponse(err)) } finally { diff --git a/apps/desktop/main/api/server.ts b/apps/desktop/main/api/server.ts index 482f2534..8670170d 100644 --- a/apps/desktop/main/api/server.ts +++ b/apps/desktop/main/api/server.ts @@ -4,7 +4,7 @@ import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' import { authHook } from './auth' -import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors' +import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from './errors' import { apiLogger } from './logger' const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB @@ -23,6 +23,12 @@ export function createServer(): FastifyInstance { return } + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + if (error.statusCode === 413) { const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') reply.code(413).send(errorResponse(bodyErr)) diff --git a/packages/mcp-server/src/bin.test.ts b/packages/mcp-server/src/bin.test.ts new file mode 100644 index 00000000..252e9df1 --- /dev/null +++ b/packages/mcp-server/src/bin.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime' +import { initStandaloneMcpRuntime } from './standalone-runtime' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-mcp-bin-')) +} + +test('initStandaloneMcpRuntime rejects incompatible data directories before stdio startup', () => { + const userDataDir = makeTempDir() + const pathProvider = initStandaloneMcpRuntime('0.26.0', userDataDir).pathProvider + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + + assert.throws( + () => initStandaloneMcpRuntime('0.25.1', userDataDir), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + ) +}) diff --git a/packages/mcp-server/src/bin.ts b/packages/mcp-server/src/bin.ts index 9e820f9c..9d83e0bf 100644 --- a/packages/mcp-server/src/bin.ts +++ b/packages/mcp-server/src/bin.ts @@ -8,17 +8,15 @@ */ import { loadConfig } from '@openchatlab/config' -import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime' import { startMcpServer } from './server' +import { initStandaloneMcpRuntime } from './standalone-runtime' import { getMcpPackageVersion } from './runtime-version' function main(): void { const config = loadConfig() const userDataDir = config.data.user_data_dir || undefined - const pathProvider = new NodePathProvider(userDataDir) - pathProvider.ensureAllDirs() const version = getMcpPackageVersion() - const dbManager = new DatabaseManager(pathProvider, { runtime: { version, kind: 'mcp' } }) + const { dbManager } = initStandaloneMcpRuntime(version, userDataDir) startMcpServer({ version, dbManager }).catch((err) => { console.error('[chatlab-mcp] Fatal error:', err) diff --git a/packages/mcp-server/src/standalone-runtime.ts b/packages/mcp-server/src/standalone-runtime.ts new file mode 100644 index 00000000..7324c545 --- /dev/null +++ b/packages/mcp-server/src/standalone-runtime.ts @@ -0,0 +1,18 @@ +import { + assertDataDirCompatible, + DatabaseManager, + NodePathProvider, + type RuntimeIdentity, +} from '@openchatlab/node-runtime' + +export function initStandaloneMcpRuntime( + version: string, + userDataDir?: string +): { dbManager: DatabaseManager; pathProvider: NodePathProvider; runtime: RuntimeIdentity } { + const pathProvider = new NodePathProvider(userDataDir) + pathProvider.ensureAllDirs() + const runtime: RuntimeIdentity = { version, kind: 'mcp' } + assertDataDirCompatible(pathProvider, runtime) + const dbManager = new DatabaseManager(pathProvider, { runtime }) + return { dbManager, pathProvider, runtime } +}