358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
const readline = require('readline');
|
||
const { spawn } = require('child_process');
|
||
const {
|
||
acquireCodexHomeLock,
|
||
} = require('./codex-home-lock');
|
||
|
||
const DEFAULT_START_RETRY_ATTEMPTS = 4;
|
||
const DEFAULT_START_RETRY_DELAY_MS = 500;
|
||
const DEFAULT_START_RETRY_MAX_DELAY_MS = 5000;
|
||
|
||
function positiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||
if (!Number.isFinite(parsed) || parsed < min) return fallback;
|
||
return Math.min(parsed, max);
|
||
}
|
||
|
||
function isCodexHomeBusyError(value) {
|
||
const parts = [];
|
||
const visit = (item, depth = 0) => {
|
||
if (!item || depth > 3) return;
|
||
if (typeof item === 'string') parts.push(item);
|
||
else if (typeof item === 'object') {
|
||
for (const key of ['message', 'stderr', 'cause', 'code', 'details']) visit(item[key], depth + 1);
|
||
}
|
||
};
|
||
visit(value);
|
||
const text = parts.join(' ');
|
||
return /database\s+(?:is\s+)?locked|database\s+table\s+is\s+locked|SQLITE_BUSY(?:_TIMEOUT)?|failed\s+to\s+(?:open|initialize)\s+(?:state|log|database)|failed\s+to\s+initialize\s+state\s+runtime/i.test(text);
|
||
}
|
||
|
||
function retryDelayMs(attempt, base, max) {
|
||
const exponential = Math.min(max, base * (2 ** Math.max(0, attempt - 1)));
|
||
const jitter = Math.floor(Math.random() * Math.max(1, Math.min(100, exponential * 0.2)));
|
||
return Math.min(max, exponential + jitter);
|
||
}
|
||
|
||
function createCodexAppServerClient(options = {}) {
|
||
const command = options.command || 'codex';
|
||
const args = Array.isArray(options.args) && options.args.length > 0
|
||
? options.args.slice()
|
||
: ['app-server', '--stdio'];
|
||
const env = options.env || process.env;
|
||
const cwd = options.cwd || process.cwd();
|
||
const clientInfo = options.clientInfo || {
|
||
name: 'ccweb_codexapp',
|
||
title: 'CC-Web Codex App',
|
||
version: '0.1.0',
|
||
};
|
||
const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {};
|
||
const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null;
|
||
const onExit = typeof options.onExit === 'function' ? options.onExit : () => {};
|
||
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
|
||
const postInitialize = typeof options.postInitialize === 'function' ? options.postInitialize : null;
|
||
|
||
let proc = null;
|
||
let rl = null;
|
||
let nextId = 1;
|
||
let initPromise = null;
|
||
let exited = false;
|
||
let processExitPromise = null;
|
||
let resolveProcessExit = null;
|
||
let lockHandle = null;
|
||
let lastStartError = null;
|
||
let stopRequested = false;
|
||
const pending = new Map();
|
||
|
||
function rejectAllPending(err) {
|
||
for (const [, pendingRequest] of pending) {
|
||
clearTimeout(pendingRequest.timer);
|
||
pendingRequest.reject(err);
|
||
}
|
||
pending.clear();
|
||
}
|
||
|
||
function sendRaw(message) {
|
||
if (!proc || !proc.stdin || proc.stdin.destroyed) {
|
||
throw new Error('Codex app-server 未启动。');
|
||
}
|
||
proc.stdin.write(`${JSON.stringify(message)}\n`);
|
||
}
|
||
|
||
function respondToServerRequest(id, result, error) {
|
||
try {
|
||
if (error) {
|
||
sendRaw({ id, error });
|
||
} else {
|
||
sendRaw({ id, result: result || {} });
|
||
}
|
||
} catch (err) {
|
||
onLog('WARN', 'codex_app_server_response_failed', { error: err.message });
|
||
}
|
||
}
|
||
|
||
function handleServerRequest(message) {
|
||
const id = message.id;
|
||
const method = message.method;
|
||
const params = message.params || {};
|
||
if (onServerRequest) {
|
||
Promise.resolve()
|
||
.then(() => onServerRequest({ method, params, id }))
|
||
.then((result) => respondToServerRequest(id, result || {}))
|
||
.catch((err) => respondToServerRequest(id, null, {
|
||
code: -32603,
|
||
message: err?.message || 'cc-web 无法处理 Codex app-server 请求。',
|
||
}));
|
||
return;
|
||
}
|
||
respondToServerRequest(id, null, {
|
||
code: -32601,
|
||
message: `cc-web 暂不支持 Codex app-server 请求: ${method}`,
|
||
});
|
||
}
|
||
|
||
function handleMessage(line) {
|
||
let message;
|
||
try {
|
||
message = JSON.parse(line);
|
||
} catch {
|
||
onLog('WARN', 'codex_app_server_invalid_json', { line: String(line || '').slice(0, 200) });
|
||
return;
|
||
}
|
||
|
||
if (Object.prototype.hasOwnProperty.call(message, 'id')) {
|
||
const pendingRequest = pending.get(message.id);
|
||
if (pendingRequest) {
|
||
pending.delete(message.id);
|
||
clearTimeout(pendingRequest.timer);
|
||
if (message.error) {
|
||
const err = new Error(message.error.message || 'Codex app-server 请求失败。');
|
||
err.code = message.error.code;
|
||
err.data = message.error.data;
|
||
pendingRequest.reject(err);
|
||
} else {
|
||
pendingRequest.resolve(message.result || {});
|
||
}
|
||
return;
|
||
}
|
||
if (message.method) {
|
||
handleServerRequest(message);
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (message.method) {
|
||
onNotification(message);
|
||
}
|
||
}
|
||
|
||
function request(method, params = {}, timeoutMs = 300000) {
|
||
const id = nextId++;
|
||
const message = { id, method, params };
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
pending.delete(id);
|
||
reject(new Error(`Codex app-server 请求超时: ${method}`));
|
||
}, timeoutMs);
|
||
pending.set(id, { resolve, reject, timer, method });
|
||
try {
|
||
sendRaw(message);
|
||
} catch (err) {
|
||
clearTimeout(timer);
|
||
pending.delete(id);
|
||
reject(err);
|
||
}
|
||
});
|
||
}
|
||
|
||
function notification(method, params = {}) {
|
||
sendRaw({ method, params });
|
||
}
|
||
|
||
function reloadMcpServers() {
|
||
return request('config/mcpServer/reload', {}, 30000);
|
||
}
|
||
|
||
function releaseLock() {
|
||
const current = lockHandle;
|
||
lockHandle = null;
|
||
if (current) Promise.resolve(current.release()).catch(() => {});
|
||
}
|
||
|
||
function spawnAndInitialize(lock) {
|
||
lockHandle = lock;
|
||
exited = false;
|
||
proc = spawn(command, args, {
|
||
env,
|
||
cwd,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
});
|
||
|
||
processExitPromise = new Promise((resolve) => { resolveProcessExit = resolve; });
|
||
|
||
let stderr = '';
|
||
proc.stderr.on('data', (chunk) => {
|
||
stderr += chunk.toString();
|
||
if (stderr.length > 4000) stderr = stderr.slice(-4000);
|
||
});
|
||
|
||
rl = readline.createInterface({ input: proc.stdout });
|
||
rl.on('line', handleMessage);
|
||
|
||
proc.on('exit', (code, signal) => {
|
||
exited = true;
|
||
if (rl) rl.close();
|
||
const err = new Error(`Codex app-server 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`);
|
||
err.exitCode = code;
|
||
err.signal = signal;
|
||
err.stderr = stderr;
|
||
rejectAllPending(err);
|
||
releaseLock();
|
||
if (resolveProcessExit) resolveProcessExit({ code, signal, stderr });
|
||
resolveProcessExit = null;
|
||
onExit({ code, signal, stderr });
|
||
});
|
||
|
||
proc.on('error', (err) => {
|
||
rejectAllPending(err);
|
||
releaseLock();
|
||
if (resolveProcessExit) resolveProcessExit({ code: null, signal: null, stderr: err.message });
|
||
resolveProcessExit = null;
|
||
onExit({ code: null, signal: null, stderr: err.message });
|
||
});
|
||
|
||
return request('initialize', {
|
||
clientInfo,
|
||
capabilities: { experimentalApi: true },
|
||
}, 30000)
|
||
.then(async (result) => {
|
||
notification('initialized', {});
|
||
if (postInitialize) await postInitialize({ request, notification, onLog });
|
||
return result;
|
||
})
|
||
.catch((err) => {
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
async function terminateProcessAndWait() {
|
||
const child = proc;
|
||
if (!child || exited) {
|
||
releaseLock();
|
||
return;
|
||
}
|
||
try { child.kill('SIGTERM'); } catch {}
|
||
const timer = setTimeout(() => {
|
||
try { if (!child.killed) child.kill('SIGKILL'); } catch {}
|
||
}, 3000);
|
||
try {
|
||
await processExitPromise;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function startWithRetry() {
|
||
const env = options.env || process.env;
|
||
const attempts = positiveInt(
|
||
options.startRetryAttempts ?? env.CC_WEB_CODEX_START_RETRY_ATTEMPTS,
|
||
DEFAULT_START_RETRY_ATTEMPTS,
|
||
{ min: 1, max: 8 },
|
||
);
|
||
const baseDelay = positiveInt(
|
||
options.startRetryDelayMs ?? env.CC_WEB_CODEX_START_RETRY_DELAY_MS,
|
||
DEFAULT_START_RETRY_DELAY_MS,
|
||
{ min: 0, max: 30_000 },
|
||
);
|
||
const maxDelay = positiveInt(
|
||
options.startRetryMaxDelayMs ?? env.CC_WEB_CODEX_START_RETRY_MAX_DELAY_MS,
|
||
DEFAULT_START_RETRY_MAX_DELAY_MS,
|
||
{ min: baseDelay || 1, max: 60_000 },
|
||
);
|
||
|
||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||
if (stopRequested) {
|
||
const aborted = new Error('Codex app-server 启动已取消。');
|
||
aborted.code = 'CODEX_APP_START_CANCELLED';
|
||
throw aborted;
|
||
}
|
||
const lock = await acquireCodexHomeLock({
|
||
env,
|
||
waitMs: options.lockWaitMs,
|
||
lockPath: options.lockPath,
|
||
});
|
||
if (stopRequested) {
|
||
await lock.release();
|
||
const aborted = new Error('Codex app-server 启动已取消。');
|
||
aborted.code = 'CODEX_APP_START_CANCELLED';
|
||
throw aborted;
|
||
}
|
||
try {
|
||
const result = await spawnAndInitialize(lock);
|
||
lastStartError = null;
|
||
return result;
|
||
} catch (error) {
|
||
lastStartError = error;
|
||
await terminateProcessAndWait();
|
||
if (!isCodexHomeBusyError(error) || attempt >= attempts) {
|
||
const detail = error?.stderr || error?.message || String(error || '');
|
||
const finalError = new Error(`Codex app-server 启动失败(尝试 ${attempt}/${attempts}):${detail}`);
|
||
finalError.code = error?.code || 'CODEX_APP_START_FAILED';
|
||
finalError.cause = error;
|
||
finalError.stderr = error?.stderr || '';
|
||
finalError.lockPath = lock.path;
|
||
throw finalError;
|
||
}
|
||
const delay = retryDelayMs(attempt, baseDelay, maxDelay);
|
||
onLog('WARN', 'codex_app_server_start_retry', {
|
||
attempt,
|
||
nextAttempt: attempt + 1,
|
||
delayMs: delay,
|
||
reason: 'sqlite_busy',
|
||
lockPath: lock.path,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||
}
|
||
}
|
||
throw lastStartError || new Error('Codex app-server 启动失败。');
|
||
}
|
||
|
||
function start() {
|
||
if (initPromise) return initPromise;
|
||
stopRequested = false;
|
||
initPromise = startWithRetry();
|
||
|
||
return initPromise;
|
||
}
|
||
|
||
function stop() {
|
||
stopRequested = true;
|
||
initPromise = null;
|
||
if (rl) {
|
||
try { rl.close(); } catch {}
|
||
rl = null;
|
||
}
|
||
terminateProcessAndWait().catch(() => {});
|
||
rejectAllPending(new Error('Codex app-server 已停止。'));
|
||
}
|
||
|
||
function isRunning() {
|
||
return !!proc && !exited;
|
||
}
|
||
|
||
return {
|
||
start,
|
||
stop,
|
||
request,
|
||
notification,
|
||
reloadMcpServers,
|
||
isRunning,
|
||
pid: () => proc?.pid || null,
|
||
};
|
||
}
|
||
|
||
module.exports = { createCodexAppServerClient };
|