Files
Genarrative/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/process.mjs
T
AIGameCreator App be89296492 拆分 AI 游戏创作客户端大型模块
拆分 App 认证、壳层、运行配置与项目摘要模块
拆分 Tauri 项目能力与 Rust 测试领域模块
拆分界面测试与 Agent Runtime 真实 E2E 套件
补充源码扫描和客户端模块化文档约定
2026-07-21 22:53:29 +08:00

338 lines
9.9 KiB
JavaScript

import {
assert,
codedError,
hashValue,
sleep,
throwIfShutdownRequested,
} from '../assertions/core.mjs';
import { fs, path, spawn, withLoopbackNoProxy } from '../dependencies.mjs';
import {
activeCommandChildren,
appRoot,
commandOutputLimit,
manifestPath,
state,
} from '../runtime-state.mjs';
import {
isSupervisorSwarmTransientRetrySuite,
recordSupervisorSwarmChatSessionFailureDiagnostic,
} from '../suites/supervisor-swarm.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
export async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
cargo,
['build', '--quiet', '--manifest-path', manifestPath],
{
cwd: appRoot,
timeoutMs: 15 * 60 * 1000,
},
);
const metadata = await runProcess(
cargo,
[
'metadata',
'--format-version',
'1',
'--no-deps',
'--manifest-path',
manifestPath,
],
{ cwd: appRoot, timeoutMs: 120_000 },
);
const parsed = JSON.parse(metadata.stdout);
const executable = path.join(
parsed.target_directory,
'debug',
`genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`,
);
const binary = await fs.stat(executable).catch(() => null);
assert(binary?.isFile(), 'cli-binary-missing');
return executable;
}
export async function runCli(args, options = {}) {
assert(Boolean(state.cliBinary), 'cli-binary-not-ready');
assert(Boolean(state.runtimeConfigDir), 'runtime-config-dir-not-ready');
if (
isIsolatedRunnerSuite() &&
state.options?.configDir &&
path.resolve(state.runtimeConfigDir) ===
path.resolve(state.options.configDir)
) {
state.isolatedRunner.sourceConfigCliCallCount += 1;
}
return runProcess(
state.cliBinary,
[...args, '--config-dir', state.runtimeConfigDir],
{
cwd: appRoot,
timeoutMs: options.timeoutMs ?? 60_000,
stdin: options.stdin,
allowNonZero: options.allowNonZero ?? false,
env: buildCliChildEnvironment(),
},
);
}
export function buildCliChildEnvironment() {
const environment = {
...process.env,
NO_COLOR: '1',
RUST_BACKTRACE: '0',
};
return isSupervisorSwarmTransientRetrySuite()
? withLoopbackNoProxy(environment)
: environment;
}
export function safeProcessFailureDiagnostic({
code = null,
signal = null,
error = null,
stdout = '',
stderr = '',
} = {}) {
const stdoutText = Buffer.isBuffer(stdout)
? stdout.toString('utf8')
: String(stdout);
const stderrText = Buffer.isBuffer(stderr)
? stderr.toString('utf8')
: String(stderr);
const combined = `${error?.message ?? ''}\n${stdoutText}\n${stderrText}`;
const processErrorCode = /^[A-Z0-9_]+$/u.test(error?.code ?? '')
? error.code
: 'none';
const closeSignal = /^[A-Z0-9]+$/u.test(signal ?? '') ? signal : 'none';
let failureKind = 'closed';
if (
processErrorCode === 'ENOSPC' ||
/(?:\bENOSPC\b|No space left on device|os error 28)/iu.test(combined)
) {
failureKind = 'enospc';
} else if (processErrorCode !== 'none') {
failureKind = 'process-error';
} else if (closeSignal !== 'none') {
failureKind = 'signal';
} else if (Number.isInteger(code) && code !== 0) {
failureKind = 'nonzero-exit';
}
return {
failureKind,
exitCode: Number.isInteger(code) ? String(code) : 'none',
signal: closeSignal,
processErrorCode,
stderrChars: [...stderrText].length,
stderrSha256: hashValue(stderrText) ?? 'none',
};
}
export function codedProcessError(code, details) {
const error = codedError(code, details?.error);
error.safeFailureDiagnostic = safeProcessFailureDiagnostic(details);
return error;
}
export function startInteractiveCli(args) {
assert(Boolean(state.cliBinary), 'interactive-cli-binary-not-ready');
assert(Boolean(state.runtimeConfigDir), 'interactive-config-dir-not-ready');
if (
isIsolatedRunnerSuite() &&
state.options?.configDir &&
path.resolve(state.runtimeConfigDir) ===
path.resolve(state.options.configDir)
) {
state.isolatedRunner.sourceConfigCliCallCount += 1;
}
const child = spawn(
state.cliBinary,
[...args, '--config-dir', state.runtimeConfigDir],
{
cwd: appRoot,
env: buildCliChildEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
},
);
activeCommandChildren.add(child);
const session = {
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
closed: false,
closeInfo: null,
closePromise: null,
};
session.closePromise = new Promise((resolve) => {
child.on('error', (error) => {
activeCommandChildren.delete(child);
session.closed = true;
session.closeInfo = { code: null, signal: null, error };
resolve(session.closeInfo);
});
child.on('close', (code, signal) => {
activeCommandChildren.delete(child);
session.closed = true;
session.closeInfo = { code, signal, error: null };
resolve(session.closeInfo);
});
});
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk);
session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk);
session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit);
});
return session;
}
export function interactiveCliOutput(session) {
return `${session.stdout.toString('utf8')}\n${session.stderr.toString('utf8')}`;
}
export function writeInteractiveCliLine(session, line) {
assert(!session.closed, 'interactive-cli-already-closed');
assert(session.child.stdin.writable, 'interactive-cli-stdin-not-writable');
session.child.stdin.write(`${line}\n`);
}
export async function waitForInteractiveCliOutput(
session,
predicate,
code,
timeoutMs,
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (predicate(output)) return output;
if (session.closed) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-closed`);
}
await sleep(50);
}
throw codedError(code);
}
export async function waitForInteractiveCliExit(session, timeoutMs) {
const result = await Promise.race([
session.closePromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-exit-timeout');
if (result.error) throw codedError('interactive-cli-process-error');
assert(
result.code === 0 && result.signal === null,
'interactive-cli-exit-invalid',
);
return result;
}
export async function closeInteractiveCli(session) {
if (!session || session.closed) return;
if (session.child.stdin.writable) {
session.child.stdin.write('/quit\n');
}
let result = await Promise.race([
session.closePromise,
sleep(3_000).then(() => null),
]);
if (!result && !session.closed) {
session.child.kill('SIGTERM');
result = await Promise.race([
session.closePromise,
sleep(2_000).then(() => null),
]);
}
if (!result && !session.closed) {
session.child.kill('SIGKILL');
result = await session.closePromise;
}
assert(Boolean(result), 'interactive-cli-cleanup-timeout');
}
export async function runProcess(
program,
args,
{ cwd, timeoutMs, stdin, allowNonZero = false, env = null },
) {
throwIfShutdownRequested();
return new Promise((resolve, reject) => {
const child = spawn(program, args, {
cwd,
env: env ?? { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
});
activeCommandChildren.add(child);
let stdout = Buffer.alloc(0);
let stderr = Buffer.alloc(0);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, timeoutMs);
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('stdout', chunk);
state.projectPathTranscriptScanner?.scan('stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('stdout', chunk);
stdout = appendBounded(stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('stderr', chunk);
state.projectPathTranscriptScanner?.scan('stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('stderr', chunk);
stderr = appendBounded(stderr, chunk, commandOutputLimit);
});
child.on('error', (error) => {
clearTimeout(timer);
activeCommandChildren.delete(child);
reject(
codedProcessError('process-spawn-failed', {
error,
stdout,
stderr,
}),
);
});
child.on('close', (code, signal) => {
clearTimeout(timer);
activeCommandChildren.delete(child);
const result = {
stdout: stdout.toString('utf8'),
stderr: stderr.toString('utf8'),
code,
signal,
};
if (timedOut) {
reject(codedProcessError('process-timeout', result));
} else if (state.shutdownSignal && !state.cleanupInProgress) {
reject(
codedProcessError(
`interrupted-${state.shutdownSignal.toLowerCase()}`,
result,
),
);
} else if (code !== 0 && !allowNonZero) {
reject(codedProcessError('cli-command-failed', result));
} else {
resolve(result);
}
});
if (stdin !== undefined) child.stdin.end(stdin);
});
}
export function appendBounded(current, chunk, limit) {
const combined = Buffer.concat([current, chunk]);
return combined.length <= limit
? combined
: combined.subarray(combined.length - limit);
}