527 lines
21 KiB
JavaScript
527 lines
21 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import mysql from 'mysql2/promise';
|
|
import pg from 'pg';
|
|
import { parse as parsePgConnectionString } from 'pg-connection-string';
|
|
|
|
import { loadMemindEnvFiles } from '../scripts/memind-runtime-profile.mjs';
|
|
import { assertNonProductionTarget } from './safety.mjs';
|
|
|
|
const { Client: PgClient } = pg;
|
|
const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/;
|
|
|
|
function assertLoopbackHost(host, label) {
|
|
const normalized = String(host ?? '').toLowerCase();
|
|
if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) {
|
|
throw new Error(`${label} must use a local loopback or Unix socket`);
|
|
}
|
|
}
|
|
|
|
function quoteMysqlIdentifier(identifier) {
|
|
if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe MySQL database name: ${identifier}`);
|
|
return `\`${identifier}\``;
|
|
}
|
|
|
|
function quotePgIdentifier(identifier) {
|
|
if (!SAFE_DB_NAME.test(identifier)) throw new Error(`Unsafe PostgreSQL database name: ${identifier}`);
|
|
return `"${identifier}"`;
|
|
}
|
|
|
|
export function makeIsolatedDatabaseName(runId, prefix) {
|
|
const suffix = String(runId)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 38);
|
|
const name = `${prefix}_${suffix || 'local'}`;
|
|
if (!SAFE_DB_NAME.test(name)) throw new Error(`Unsafe generated database name: ${name}`);
|
|
return name;
|
|
}
|
|
|
|
export function buildPgConnectionString(baseConnectionString, database) {
|
|
if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`);
|
|
const config = parsePgConnectionString(baseConnectionString);
|
|
assertLoopbackHost(config.host, 'PostgreSQL');
|
|
const user = encodeURIComponent(config.user ?? '');
|
|
const password = config.password ? `:${encodeURIComponent(config.password)}` : '';
|
|
const authorityHost = config.host?.startsWith('/') ? 'localhost' : config.host;
|
|
const port = config.port ? `:${config.port}` : '';
|
|
const query = config.host?.startsWith('/')
|
|
? `?host=${encodeURIComponent(config.host)}${config.port ? `&port=${encodeURIComponent(config.port)}` : ''}`
|
|
: '';
|
|
return `postgresql://${user}${password}@${authorityHost}${port}/${database}${query}`;
|
|
}
|
|
|
|
export function buildContainerPgConnectionString(baseConnectionString, database) {
|
|
const raw = String(baseConnectionString ?? '').trim();
|
|
if (!raw) return '';
|
|
const parsed = new URL(raw);
|
|
if (!SAFE_DB_NAME.test(database)) throw new Error(`Unsafe PostgreSQL database name: ${database}`);
|
|
parsed.pathname = `/${database}`;
|
|
return parsed.toString();
|
|
}
|
|
|
|
/**
|
|
* Copy only the selected local provider metadata into an isolated gate DB.
|
|
* The encrypted key material stays inside the local database boundary and is
|
|
* never logged; production/remote DSNs are rejected by assertNonProductionTarget.
|
|
*/
|
|
export async function seedSelectedProviderKeys({ sourceUrl, targetUrl }) {
|
|
assertNonProductionTarget(sourceUrl, 'source provider database');
|
|
assertNonProductionTarget(targetUrl, 'isolated provider database');
|
|
const source = await mysql.createConnection(sourceUrl);
|
|
const target = await mysql.createConnection(targetUrl);
|
|
try {
|
|
const [rows] = await source.query(
|
|
`SELECT * FROM h5_llm_provider_keys
|
|
ORDER BY is_selected DESC, is_vision_selected DESC, updated_at DESC`,
|
|
);
|
|
for (const row of rows) {
|
|
await target.execute(
|
|
`INSERT INTO h5_llm_provider_keys
|
|
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id,
|
|
engine, relay_provider, name, api_key_ciphertext, api_key_iv, api_key_tag,
|
|
default_model, status, is_selected, is_vision_selected, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
provider_id = VALUES(provider_id), provider_kind = VALUES(provider_kind),
|
|
api_url = VALUES(api_url), base_path = VALUES(base_path), models_json = VALUES(models_json),
|
|
goosed_provider_id = VALUES(goosed_provider_id), engine = VALUES(engine),
|
|
relay_provider = VALUES(relay_provider), api_key_ciphertext = VALUES(api_key_ciphertext),
|
|
api_key_iv = VALUES(api_key_iv), api_key_tag = VALUES(api_key_tag),
|
|
default_model = VALUES(default_model), status = VALUES(status),
|
|
is_selected = VALUES(is_selected), is_vision_selected = VALUES(is_vision_selected),
|
|
updated_at = VALUES(updated_at)`,
|
|
[
|
|
row.id, row.provider_id, row.provider_kind, row.api_url, row.base_path,
|
|
row.models_json, row.goosed_provider_id, row.engine, row.relay_provider,
|
|
row.name, row.api_key_ciphertext, row.api_key_iv, row.api_key_tag,
|
|
row.default_model, row.status, row.is_selected, row.is_vision_selected,
|
|
row.created_at, row.updated_at,
|
|
],
|
|
);
|
|
}
|
|
const [bindings] = await source.query('SELECT * FROM h5_llm_executor_bindings');
|
|
for (const binding of bindings) {
|
|
await target.execute(
|
|
`INSERT INTO h5_llm_executor_bindings
|
|
(id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE provider_key_id = VALUES(provider_key_id),
|
|
model = VALUES(model), enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
|
|
[
|
|
binding.id, binding.executor, binding.purpose, binding.provider_key_id,
|
|
binding.model, binding.enabled, binding.created_at, binding.updated_at,
|
|
],
|
|
);
|
|
}
|
|
return { copied: rows.length, bindings: bindings.length };
|
|
} finally {
|
|
await Promise.all([source.end(), target.end()]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select the backend direct-LLM provider inside an isolated gate database.
|
|
* This deliberately never mutates the source database or any production DSN.
|
|
*/
|
|
export async function selectBackendLlmProvider({
|
|
targetUrl,
|
|
providerId = 'custom_deepseek',
|
|
model = 'deepseek-v4-pro',
|
|
}) {
|
|
assertNonProductionTarget(targetUrl, 'isolated backend LLM database');
|
|
const target = await mysql.createConnection(targetUrl);
|
|
try {
|
|
const [rows] = await target.query(
|
|
`SELECT id, provider_id, provider_kind, models_json, status
|
|
FROM h5_llm_provider_keys
|
|
WHERE provider_id = ? AND status = 'active'
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1`,
|
|
[providerId],
|
|
);
|
|
const row = rows[0];
|
|
if (!row) throw new Error(`isolated backend LLM provider not found: ${providerId}`);
|
|
const supportedModels = row.provider_kind === 'custom'
|
|
? (() => {
|
|
try { return JSON.parse(row.models_json ?? '[]'); } catch { return []; }
|
|
})()
|
|
: ['deepseek-v4-pro', 'deepseek-v4-flash'];
|
|
if (!supportedModels.includes(model)) {
|
|
throw new Error(`backend LLM model is not supported by ${providerId}: ${model}`);
|
|
}
|
|
await target.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [Date.now()]);
|
|
await target.execute(
|
|
`UPDATE h5_llm_provider_keys
|
|
SET is_selected = 1, default_model = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[model, Date.now(), row.id],
|
|
);
|
|
await target.execute(
|
|
`UPDATE h5_llm_executor_bindings
|
|
SET provider_key_id = ?, model = ?, updated_at = ?
|
|
WHERE executor IN ('goose', 'aider', 'openhands') AND purpose = 'default' AND enabled = 1`,
|
|
[row.id, model, Date.now()],
|
|
);
|
|
return { providerId: row.provider_id, providerKeyId: row.id, model };
|
|
} finally {
|
|
await target.end();
|
|
}
|
|
}
|
|
|
|
async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`isolated Portal exited with code ${child.exitCode}`);
|
|
try {
|
|
const response = await fetch(`${baseUrl}/auth/status`, { signal: AbortSignal.timeout(1_000) });
|
|
if (response.ok) return;
|
|
} catch {
|
|
// Startup can take several seconds while the isolated schema is initialized.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
throw new Error(`isolated Portal did not become ready: ${baseUrl}`);
|
|
}
|
|
|
|
async function waitForHttpHealth(baseUrl, child, label, timeoutMs = 30_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`${label} exited with code ${child.exitCode}`);
|
|
try {
|
|
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
|
|
if (response.ok) return;
|
|
} catch {
|
|
// The child can take a moment to bind its listener.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
}
|
|
throw new Error(`${label} did not become ready: ${baseUrl}`);
|
|
}
|
|
|
|
async function createMysqlDatabase(baseUrl, database) {
|
|
const parsed = new URL(baseUrl);
|
|
assertNonProductionTarget(baseUrl, 'MySQL URL');
|
|
assertLoopbackHost(parsed.hostname, 'MySQL');
|
|
const adminUrl = new URL(baseUrl);
|
|
adminUrl.pathname = '/mysql';
|
|
const connection = await mysql.createConnection(adminUrl.toString());
|
|
try {
|
|
await connection.query(`CREATE DATABASE ${quoteMysqlIdentifier(database)}
|
|
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
parsed.pathname = `/${database}`;
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function dropMysqlDatabase(baseUrl, database) {
|
|
const adminUrl = new URL(baseUrl);
|
|
adminUrl.pathname = '/mysql';
|
|
const connection = await mysql.createConnection(adminUrl.toString());
|
|
try {
|
|
await connection.query(`DROP DATABASE IF EXISTS ${quoteMysqlIdentifier(database)}`);
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
|
|
async function createPgDatabase(baseUrl, database) {
|
|
const config = parsePgConnectionString(baseUrl);
|
|
assertLoopbackHost(config.host, 'PostgreSQL');
|
|
const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' });
|
|
await admin.connect();
|
|
try {
|
|
await admin.query(`CREATE DATABASE ${quotePgIdentifier(database)}`);
|
|
} finally {
|
|
await admin.end();
|
|
}
|
|
return buildPgConnectionString(baseUrl, database);
|
|
}
|
|
|
|
async function dropPgDatabase(baseUrl, database) {
|
|
const admin = new PgClient({ connectionString: baseUrl, database: 'postgres' });
|
|
await admin.connect();
|
|
try {
|
|
await admin.query(
|
|
`SELECT pg_terminate_backend(pid)
|
|
FROM pg_stat_activity
|
|
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
[database],
|
|
);
|
|
await admin.query(`DROP DATABASE IF EXISTS ${quotePgIdentifier(database)}`);
|
|
} finally {
|
|
await admin.end();
|
|
}
|
|
}
|
|
|
|
export async function createLocalGateStack({
|
|
root = path.resolve(new URL('..', import.meta.url).pathname),
|
|
runId = `gate-${Date.now()}`,
|
|
port = 19081,
|
|
portalRoot = root,
|
|
nodeEnv = 'test',
|
|
runtimeProfile = 'local',
|
|
beforePortalStart = null,
|
|
} = {}) {
|
|
const resolvedPortalRoot = path.resolve(portalRoot);
|
|
if (
|
|
resolvedPortalRoot !== path.resolve(root)
|
|
&& resolvedPortalRoot !== path.resolve(root, '.runtime', 'portal')
|
|
) {
|
|
throw new Error('isolated Portal root must be the repository or .runtime/portal');
|
|
}
|
|
const baseEnv = { ...process.env };
|
|
loadMemindEnvFiles(root, baseEnv);
|
|
if (!baseEnv.DATABASE_URL) throw new Error('Local release gate requires DATABASE_URL');
|
|
if (!baseEnv.MINDSPACE_USERDATA_PG_URL) {
|
|
throw new Error('Local release gate requires MINDSPACE_USERDATA_PG_URL');
|
|
}
|
|
assertNonProductionTarget(baseEnv.DATABASE_URL, 'database');
|
|
assertNonProductionTarget(baseEnv.MINDSPACE_USERDATA_PG_URL, 'Page Data database');
|
|
|
|
const mysqlDatabase = makeIsolatedDatabaseName(runId, 'memind_gate');
|
|
const pgDatabase = makeIsolatedDatabaseName(runId, 'mindspace_gate');
|
|
const runRoot = path.join(root, '.release-gate', 'local', runId);
|
|
const sandboxRoot = path.join(runRoot, 'sandbox');
|
|
const usersRoot = path.join(sandboxRoot, 'users');
|
|
const storageRoot = path.join(sandboxRoot, 'data', 'mindspace');
|
|
const publishRoot = path.join(sandboxRoot, 'MindSpace');
|
|
const logPath = path.join(runRoot, 'portal.log');
|
|
const deepseekProxyLogPath = path.join(runRoot, 'deepseek-no-think.log');
|
|
// Each isolated stack gets its own compatibility-proxy port so parallel
|
|
// suites never share mutable provider state. The proxy talks directly to
|
|
// the configured backend API; it is not the H5 relay service.
|
|
const deepseekNoThinkPort = Number(
|
|
process.env.MEMIND_RELEASE_GATE_DEEPSEEK_PROXY_PORT
|
|
?? (18000 + (Number(port) % 1000)),
|
|
);
|
|
await Promise.all([
|
|
fsp.mkdir(usersRoot, { recursive: true }),
|
|
fsp.mkdir(storageRoot, { recursive: true }),
|
|
fsp.mkdir(publishRoot, { recursive: true }),
|
|
]);
|
|
// Keep the H5 root writable and isolated so packaged-runtime smoke tests
|
|
// cannot materialize MindSpace pages inside the candidate artifact. The
|
|
// platform skill catalog still comes from the packaged runtime via a
|
|
// read-only symlink.
|
|
await fsp.symlink(
|
|
path.join(resolvedPortalRoot, 'skills'),
|
|
path.join(sandboxRoot, 'skills'),
|
|
'dir',
|
|
);
|
|
const wechatBundle = path.join(resolvedPortalRoot, 'wechat-mp.bundle.mjs');
|
|
if (await fsp.stat(wechatBundle).catch(() => null)) {
|
|
await fsp.copyFile(wechatBundle, path.join(sandboxRoot, 'wechat-mp.bundle.mjs'));
|
|
}
|
|
|
|
let mysqlUrl;
|
|
let pgUrl;
|
|
let child;
|
|
let deepseekProxyChild;
|
|
let logFd;
|
|
let deepseekProxyLogFd;
|
|
let childEnv;
|
|
let baseUrl;
|
|
|
|
async function stopPortal() {
|
|
const proxyToStop = deepseekProxyChild;
|
|
const waitForProxyExit = async (timeoutMs) => {
|
|
if (!proxyToStop || proxyToStop.exitCode !== null) return true;
|
|
return Promise.race([
|
|
new Promise((resolve) => proxyToStop.once('exit', () => resolve(true))),
|
|
new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
|
]);
|
|
};
|
|
if (proxyToStop && proxyToStop.exitCode === null) {
|
|
proxyToStop.kill('SIGTERM');
|
|
const exited = await waitForProxyExit(3_000);
|
|
if (!exited && proxyToStop.exitCode === null) proxyToStop.kill('SIGKILL');
|
|
}
|
|
deepseekProxyChild = undefined;
|
|
if (deepseekProxyLogFd !== undefined) {
|
|
fs.closeSync(deepseekProxyLogFd);
|
|
deepseekProxyLogFd = undefined;
|
|
}
|
|
const processToStop = child;
|
|
const waitForExit = async (timeoutMs) => {
|
|
if (!processToStop || processToStop.exitCode !== null) return true;
|
|
return Promise.race([
|
|
new Promise((resolve) => processToStop.once('exit', () => resolve(true))),
|
|
new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
|
]);
|
|
};
|
|
if (processToStop && processToStop.exitCode === null) {
|
|
processToStop.kill('SIGTERM');
|
|
const exited = await waitForExit(5_000);
|
|
if (!exited && processToStop.exitCode === null) {
|
|
processToStop.kill('SIGKILL');
|
|
const killed = await waitForExit(5_000);
|
|
if (!killed && processToStop.exitCode === null) {
|
|
throw new Error('isolated Portal did not stop after SIGKILL');
|
|
}
|
|
}
|
|
}
|
|
child = undefined;
|
|
if (logFd !== undefined) {
|
|
fs.closeSync(logFd);
|
|
logFd = undefined;
|
|
}
|
|
}
|
|
|
|
async function startPortal() {
|
|
if (!childEnv || !baseUrl) throw new Error('isolated Portal environment is not initialized');
|
|
if (child && child.exitCode === null) throw new Error('isolated Portal is already running');
|
|
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
|
|
deepseekProxyChild = spawn(
|
|
process.execPath,
|
|
[path.join(resolvedPortalRoot, 'deepseek-no-think-proxy.mjs')],
|
|
{
|
|
cwd: root,
|
|
env: {
|
|
...childEnv,
|
|
MEMIND_DEEPSEEK_PROXY_ENTRYPOINT: '1',
|
|
MEMIND_DEEPSEEK_NO_THINK_PORT: String(deepseekNoThinkPort),
|
|
},
|
|
stdio: ['ignore', deepseekProxyLogFd, deepseekProxyLogFd],
|
|
},
|
|
);
|
|
await waitForHttpHealth(
|
|
`http://127.0.0.1:${deepseekNoThinkPort}`,
|
|
deepseekProxyChild,
|
|
'DeepSeek no-thinking proxy',
|
|
);
|
|
logFd = fs.openSync(logPath, 'a');
|
|
child = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: resolvedPortalRoot,
|
|
env: childEnv,
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
await waitForPortal(baseUrl, child);
|
|
}
|
|
|
|
try {
|
|
mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase);
|
|
pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase);
|
|
childEnv = {
|
|
...baseEnv,
|
|
NODE_ENV: nodeEnv,
|
|
...(runtimeProfile ? { MEMIND_RUNTIME_PROFILE: runtimeProfile } : {}),
|
|
H5_HOST: '127.0.0.1',
|
|
H5_PORT: String(port),
|
|
H5_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
DATABASE_URL: mysqlUrl,
|
|
MINDSPACE_USERDATA_PG_URL: pgUrl,
|
|
MINDSPACE_USERDATA_MCP_PG_URL: buildContainerPgConnectionString(
|
|
baseEnv.MINDSPACE_USERDATA_MCP_PG_URL,
|
|
pgDatabase,
|
|
),
|
|
// Keep code and packaged skills available while isolating all writable
|
|
// H5/MindSpace state from the candidate runtime artifact.
|
|
MEMIND_PORTAL_H5_ROOT: sandboxRoot,
|
|
H5_USERS_ROOT: usersRoot,
|
|
MINDSPACE_STORAGE_ROOT: storageRoot,
|
|
MEMIND_SHARED_PUBLISH_ROOT: publishRoot,
|
|
MEMIND_WORKSPACE_MAINTENANCE: '0',
|
|
H5_RELAY_BOOTSTRAP_DISABLED: '1',
|
|
// Keep the selected backend provider direct while enabling the local
|
|
// DeepSeek protocol compatibility shim. This is not the H5 relay: it
|
|
// only prevents dropped reasoning_content from turning tool rounds into
|
|
// provider HTTP 400s.
|
|
MEMIND_DEEPSEEK_DISABLE_THINKING: '1',
|
|
MEMIND_DEEPSEEK_NO_THINK_PORT: String(deepseekNoThinkPort),
|
|
// The isolated gate must exercise the same shadow validation contract
|
|
// used by production release checks; the database remains isolated.
|
|
MEMIND_ORCHESTRATOR_MODE: 'shadow',
|
|
MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED: '1',
|
|
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
|
MEMIND_RUNTIME_REDIS_NAMESPACE: `memind:release-gate:${runId}`,
|
|
H5_ADMIN_USERNAME: 'gate_admin',
|
|
H5_ADMIN_PASSWORD: `Gate-${runId}-Admin!`,
|
|
H5_ACCESS_PASSWORD: `Gate-${runId}-Access!`,
|
|
H5_WECHAT_MP_ENABLED: '0',
|
|
H5_REMINDER_WORKER_ENABLED: '0',
|
|
MEMIND_ANALYTICS_ENABLED: '0',
|
|
MINDSPACE_ANALYTICS_ENABLED: '0',
|
|
MEMIND_RYBBIT_ENABLED: 'false',
|
|
};
|
|
baseUrl = `http://127.0.0.1:${port}`;
|
|
if (beforePortalStart) {
|
|
await beforePortalStart({
|
|
mysqlUrl,
|
|
pgUrl,
|
|
mysqlDatabase,
|
|
pgDatabase,
|
|
sandboxRoot,
|
|
});
|
|
}
|
|
await startPortal();
|
|
await fsp.writeFile(
|
|
path.join(runRoot, 'stack.json'),
|
|
`${JSON.stringify({
|
|
run_id: runId,
|
|
portal_base: baseUrl,
|
|
portal_root: path.relative(root, resolvedPortalRoot) || '.',
|
|
node_env: nodeEnv,
|
|
mysql_database: mysqlDatabase,
|
|
postgres_database: pgDatabase,
|
|
sandbox_root: path.relative(root, sandboxRoot),
|
|
production_connected: false,
|
|
}, null, 2)}\n`,
|
|
);
|
|
return {
|
|
runId,
|
|
runRoot,
|
|
sandboxRoot,
|
|
baseUrl,
|
|
mysqlUrl,
|
|
pgUrl,
|
|
mysqlDatabase,
|
|
pgDatabase,
|
|
sourceDatabaseUrl: baseEnv.DATABASE_URL,
|
|
async restartPortal() {
|
|
await stopPortal();
|
|
await startPortal();
|
|
},
|
|
async cleanup() {
|
|
await stopPortal();
|
|
const keepSandbox = process.env.MEMIND_RELEASE_GATE_KEEP_SANDBOX === '1';
|
|
const cleanupResults = await Promise.allSettled([
|
|
dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase),
|
|
dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase),
|
|
keepSandbox ? Promise.resolve() : fsp.rm(sandboxRoot, { recursive: true, force: true }),
|
|
]);
|
|
const cleanupState = {
|
|
mysql_dropped: cleanupResults[1].status === 'fulfilled',
|
|
postgres_dropped: cleanupResults[0].status === 'fulfilled',
|
|
sandbox_removed: !keepSandbox && cleanupResults[2].status === 'fulfilled',
|
|
sandbox_preserved_for_debug: keepSandbox,
|
|
};
|
|
await fsp.writeFile(
|
|
path.join(runRoot, 'cleanup.json'),
|
|
`${JSON.stringify(cleanupState, null, 2)}\n`,
|
|
);
|
|
if (cleanupResults.some((result) => result.status === 'rejected')) {
|
|
throw new Error('isolated release gate cleanup failed');
|
|
}
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (child && child.exitCode === null) child.kill('SIGKILL');
|
|
if (deepseekProxyChild && deepseekProxyChild.exitCode === null) {
|
|
deepseekProxyChild.kill('SIGKILL');
|
|
}
|
|
if (logFd !== undefined) fs.closeSync(logFd);
|
|
if (deepseekProxyLogFd !== undefined) fs.closeSync(deepseekProxyLogFd);
|
|
if (pgUrl) await dropPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase).catch(() => {});
|
|
if (mysqlUrl) await dropMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase).catch(() => {});
|
|
await fsp.rm(sandboxRoot, { recursive: true, force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|