mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix: enforce data directory compatibility gate at runtime boundaries
MCP server checks the gate at startup and exits before opening stdio if the data directory requires a newer runtime. Desktop HTTP API maps DataDirCompatibilityError (including wrapped causes) to a 409 response so the renderer can surface a clear upgrade prompt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7561e224e
commit
c3a5d22a43
9 files changed
+155
-8
No files matched your search
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -223,7 +223,16 @@ export async function analyzeIncrementalImport(
|
||||
filePath: string,
|
||||
onProgress?: ImportProgressCallback
|
||||
): Promise<IncrementalAnalyzeResult> {
|
||||
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(
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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, number> = {
|
||||
[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<T>(data: T, meta?: Record<string, unknown>) {
|
||||
return {
|
||||
success: true as const,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user