2fa967a93a
删除 agent-swarm-test-chat.mjs 的 --plan 模式、自动 GDD 审批回路与 planning 产物检查 删除根与应用 package.json 的 test:plan / test:plan:manual / agc:test:plan* 四条脚本 同步删除 agentSwarmTestEntry.test.ts 中只覆盖 V1 审批回路与 --plan 参数的用例 GddApprovalCard.tsx 注释改指 planning_gdd_model.rs 的现行路径权威定义 立项策划Agent(Fast GDD)方案文档头部标注已退役,仅作历史推导记录 策划会话 Runtime V2 方案文档状态更新为 P5 已完成并记录源码删除执行清单 Provider 兼容性缺陷文档的缺陷 4 标注相关代码已随 V1 退役删除 decision-log 新增 2026-09-08 策划 V1 链路源码整体退役决策记录
2131 lines
66 KiB
JavaScript
2131 lines
66 KiB
JavaScript
import { spawn } from 'node:child_process';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { constants as fsConstants } from 'node:fs';
|
||
import {
|
||
chmod,
|
||
copyFile,
|
||
lstat,
|
||
mkdir,
|
||
mkdtemp,
|
||
open,
|
||
readdir,
|
||
readFile,
|
||
realpath,
|
||
rm,
|
||
writeFile,
|
||
} from 'node:fs/promises';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { createInterface } from 'node:readline/promises';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { inflateSync } from 'node:zlib';
|
||
|
||
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||
export const configFileName = 'game-creator.config.json';
|
||
export const localConfigFileName = 'game-creator.config.local.json';
|
||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||
export const testProjectPrefix = 'genarrative-agc-swarm-test-';
|
||
export const testProjectSentinelName = '.agc-swarm-test.json';
|
||
export const testProjectSentinelSchema =
|
||
'genarrative-agc-swarm-test-project.v1';
|
||
export const testRuntimeConfigPrefix = 'genarrative-agc-swarm-config-';
|
||
export const testRuntimeConfigSentinelName = '.agc-swarm-config.json';
|
||
export const testRuntimeConfigSentinelSchema =
|
||
'genarrative-agc-swarm-test-config.v1';
|
||
export const ungeneratedGameEntryMarker =
|
||
'还没有生成游戏。回到聊天输入创意并确认生成后';
|
||
export const defaultRealSwarmTestTask =
|
||
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
|
||
export const swarmTurnReportPrefix = '[turn.report] ';
|
||
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
|
||
|
||
const swarmTurnReportKeys = [
|
||
'schemaVersion',
|
||
'outcome',
|
||
'parentAgentId',
|
||
'sessionId',
|
||
'parentRunId',
|
||
'runtimeCount',
|
||
'busyRuntimeCount',
|
||
'pendingTaskCount',
|
||
'runningTaskCount',
|
||
'waitingForConfirmationCount',
|
||
'waitingForUserInputCount',
|
||
'newAssistantMessageCount',
|
||
'finalReplyChars',
|
||
'reconciliationAgentCount',
|
||
].sort();
|
||
const settledZeroCountFields = [
|
||
'busyRuntimeCount',
|
||
'pendingTaskCount',
|
||
'runningTaskCount',
|
||
'waitingForConfirmationCount',
|
||
'waitingForUserInputCount',
|
||
'reconciliationAgentCount',
|
||
];
|
||
|
||
const requiredFormalArtifactSpecs = [
|
||
{ path: 'memory/project.md', kind: 'file' },
|
||
{ path: 'game/game_design.md', kind: 'file' },
|
||
{ path: 'game/balance.json', kind: 'json' },
|
||
{ path: 'assets/manifest.art.json', kind: 'json' },
|
||
{ path: 'assets/manifest.audio.json', kind: 'json' },
|
||
{ path: 'game/index.html', kind: 'game-entry' },
|
||
{ path: 'exports/README.md', kind: 'file' },
|
||
];
|
||
const editorImageArtifactSpecs = [
|
||
{ path: 'assets/ui-prototype.png', kind: 'image', aspectRatio: 16 / 9 },
|
||
{ path: 'assets/art-spritesheet.png', kind: 'image', aspectRatio: 1 },
|
||
];
|
||
export const requiredSwarmManifestTaskIds = Object.freeze([
|
||
'design-director',
|
||
'design-foundation',
|
||
'balance-director',
|
||
'balance-seed',
|
||
'art-director',
|
||
'art-asset-plan',
|
||
'art-polish',
|
||
'audio-director',
|
||
'audio-asset-plan',
|
||
'code-director',
|
||
'code-prototype',
|
||
'quality-review',
|
||
'preview-readiness',
|
||
'preview-playtest',
|
||
'publish-strategy',
|
||
'publish-package',
|
||
]);
|
||
|
||
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||
const configWizardPath = path.join(
|
||
appRoot,
|
||
'scripts',
|
||
'game-creator-config-wizard.mjs',
|
||
);
|
||
const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
||
const childTerminationGraceMs = 10_000;
|
||
const childForceTerminationWaitMs = 5_000;
|
||
const runnerShutdownTimeoutMs = 20_000;
|
||
const cleanupDirectoryTimeoutMs = 10_000;
|
||
const maximumValidatedPngBytes = 64 * 1024 * 1024;
|
||
const maximumValidatedPngPixels = 100_000_000;
|
||
const maximumInflatedPngBytes = 256 * 1024 * 1024;
|
||
const minimumMarkdownBodyCharacters = 24;
|
||
const minimumHtmlCharacters = 120;
|
||
const incompleteArtifactTextPattern =
|
||
/\b(?:todo|tbd|placeholder|coming[\t ]+soon|lorem[\t ]+ipsum)\b|待补充|待完善|占位|尚未完成|稍后补充|待填写|待验证|待复核|待确认|待定/iu;
|
||
const uncheckedMarkdownChecklistPattern =
|
||
/^[\t ]*(?:>[\t ]*)*(?:[-+*]|\d+[.)])[\t ]+\[[\t ]\](?:[\t ]|$)/mu;
|
||
|
||
export function hasIncompleteArtifactMarker(content) {
|
||
return (
|
||
incompleteArtifactTextPattern.test(content) ||
|
||
uncheckedMarkdownChecklistPattern.test(content)
|
||
);
|
||
}
|
||
|
||
export const usage = `用法:
|
||
npm run agc:test:chat
|
||
npm run agc:test:chat:manual -- [选项]
|
||
|
||
自动读取客户端 AppData 配置并复制到隔离目录,创建一次性项目后进入 Project Supervisor 自主测试。
|
||
带 --task 时完成正式产物验收后自动退出;手工聊天模式完成后启动持续预览。
|
||
|
||
选项:
|
||
--config-dir <绝对路径> 显式指定客户端 AppData 配置来源目录
|
||
--project-dir <绝对路径> 使用已有项目或空目录,不自动删除
|
||
--keep-project 保留自动创建的一次性项目
|
||
--no-open 手工模式启动预览但不自动打开浏览器
|
||
--task <需求> 通过 manual 入口非交互提交自定义需求
|
||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
|
||
--dry-run 只检查目录发现和项目准备,不启动 LLM
|
||
-h, --help 显示帮助
|
||
`;
|
||
|
||
function readOptionValue(args, index, option) {
|
||
const value = args[index + 1]?.trim();
|
||
if (!value || value.startsWith('--')) {
|
||
throw new Error(`${option} 缺少路径`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function readTimeoutMinutes(args, index, option) {
|
||
const value = readOptionValue(args, index, option);
|
||
if (!/^[1-9]\d*$/u.test(value)) {
|
||
throw new Error(`${option} 必须是 1-1440 的整数分钟`);
|
||
}
|
||
const minutes = Number(value);
|
||
if (!Number.isSafeInteger(minutes) || minutes > 1_440) {
|
||
throw new Error(`${option} 必须是 1-1440 的整数分钟`);
|
||
}
|
||
return minutes;
|
||
}
|
||
|
||
export function parseSwarmTestArguments(args) {
|
||
const options = {
|
||
configDir: null,
|
||
projectDir: null,
|
||
keepProject: false,
|
||
openBrowser: true,
|
||
task: null,
|
||
timeoutMinutes: null,
|
||
dryRun: false,
|
||
help: false,
|
||
};
|
||
for (let index = 0; index < args.length; index += 1) {
|
||
const argument = args[index];
|
||
if (argument === '--config-dir') {
|
||
if (options.configDir) throw new Error('--config-dir 只能指定一次');
|
||
options.configDir = readOptionValue(args, index, argument);
|
||
index += 1;
|
||
} else if (argument === '--project-dir') {
|
||
if (options.projectDir) throw new Error('--project-dir 只能指定一次');
|
||
options.projectDir = readOptionValue(args, index, argument);
|
||
index += 1;
|
||
} else if (argument === '--keep-project') {
|
||
options.keepProject = true;
|
||
} else if (argument === '--no-open') {
|
||
options.openBrowser = false;
|
||
} else if (argument === '--task') {
|
||
if (options.task) throw new Error('--task 只能指定一次');
|
||
const task = readOptionValue(args, index, argument);
|
||
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
|
||
options.task = task;
|
||
index += 1;
|
||
} else if (argument === '--timeout-minutes') {
|
||
if (options.timeoutMinutes !== null) {
|
||
throw new Error('--timeout-minutes 只能指定一次');
|
||
}
|
||
options.timeoutMinutes = readTimeoutMinutes(args, index, argument);
|
||
index += 1;
|
||
} else if (argument === '--dry-run') {
|
||
options.dryRun = true;
|
||
} else if (argument === '--help' || argument === '-h') {
|
||
options.help = true;
|
||
} else {
|
||
throw new Error(`未知选项:${argument}`);
|
||
}
|
||
}
|
||
return options;
|
||
}
|
||
|
||
export function shouldStartPersistentPreview(options) {
|
||
return !options.task;
|
||
}
|
||
|
||
export function resolveSwarmTestTimeoutMs(options) {
|
||
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
|
||
return minutes === null ? null : minutes * 60_000;
|
||
}
|
||
|
||
function pushUnique(values, value) {
|
||
if (value && !values.includes(value)) values.push(value);
|
||
}
|
||
|
||
export function defaultRuntimeConfigDirCandidates({
|
||
platform = process.platform,
|
||
environment = process.env,
|
||
homeDirectory = os.homedir(),
|
||
} = {}) {
|
||
const candidates = [];
|
||
if (platform === 'win32') {
|
||
pushUnique(
|
||
candidates,
|
||
environment.APPDATA
|
||
? path.win32.join(environment.APPDATA, appIdentifier)
|
||
: path.win32.join(homeDirectory, 'AppData', 'Roaming', appIdentifier),
|
||
);
|
||
if (environment.LOCALAPPDATA) {
|
||
pushUnique(
|
||
candidates,
|
||
path.win32.join(environment.LOCALAPPDATA, appIdentifier),
|
||
);
|
||
}
|
||
} else if (platform === 'darwin') {
|
||
pushUnique(
|
||
candidates,
|
||
path.posix.join(
|
||
homeDirectory,
|
||
'Library',
|
||
'Application Support',
|
||
appIdentifier,
|
||
),
|
||
);
|
||
} else {
|
||
const configuredRoot = environment.XDG_CONFIG_HOME;
|
||
const posixAbsoluteConfiguredRoot =
|
||
configuredRoot && path.posix.isAbsolute(configuredRoot);
|
||
const hostAbsoluteConfiguredRoot =
|
||
configuredRoot &&
|
||
!posixAbsoluteConfiguredRoot &&
|
||
path.isAbsolute(configuredRoot);
|
||
const configRoot =
|
||
configuredRoot &&
|
||
(posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot)
|
||
? configuredRoot
|
||
: path.posix.join(homeDirectory, '.config');
|
||
pushUnique(
|
||
candidates,
|
||
hostAbsoluteConfiguredRoot
|
||
? path.join(configRoot, appIdentifier)
|
||
: path.posix.join(configRoot, appIdentifier),
|
||
);
|
||
}
|
||
return candidates;
|
||
}
|
||
|
||
async function isRegularFileWithoutSymlink(filePath) {
|
||
const metadata = await lstat(filePath).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
return Boolean(metadata?.isFile() && !metadata.isSymbolicLink());
|
||
}
|
||
|
||
export async function discoverRuntimeConfigDir(
|
||
explicitConfigDir,
|
||
platformContext,
|
||
) {
|
||
if (explicitConfigDir && !path.isAbsolute(explicitConfigDir)) {
|
||
throw new Error('--config-dir 必须是绝对路径');
|
||
}
|
||
const candidates = explicitConfigDir
|
||
? [explicitConfigDir]
|
||
: defaultRuntimeConfigDirCandidates(platformContext);
|
||
for (const candidate of candidates) {
|
||
const resolved = path.resolve(candidate);
|
||
const metadata = await lstat(resolved).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (!metadata?.isDirectory() || metadata.isSymbolicLink()) continue;
|
||
if (
|
||
!(await isRegularFileWithoutSymlink(path.join(resolved, configFileName)))
|
||
) {
|
||
continue;
|
||
}
|
||
return realpath(resolved);
|
||
}
|
||
if (explicitConfigDir) {
|
||
throw new Error(
|
||
`指定目录中没有可用的 ${configFileName}:${explicitConfigDir}`,
|
||
);
|
||
}
|
||
throw new Error(
|
||
`未找到客户端 AppData 配置。请运行 npm run agc:config,或启动 npm run agc 后在“运行时配置”中保存 LLM Provider。`,
|
||
);
|
||
}
|
||
|
||
export function canPromptForMissingRuntimeConfig(
|
||
stdinIsTty = process.stdin.isTTY,
|
||
stdoutIsTty = process.stdout.isTTY,
|
||
) {
|
||
return Boolean(stdinIsTty && stdoutIsTty);
|
||
}
|
||
|
||
async function askToConfigureMissingRuntime() {
|
||
if (!canPromptForMissingRuntimeConfig()) return false;
|
||
const readline = createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
});
|
||
try {
|
||
const answer = (
|
||
await readline.question(
|
||
'未找到客户端 AppData 配置,是否现在进入安全配置向导? [Y/n]: ',
|
||
)
|
||
)
|
||
.trim()
|
||
.toLowerCase();
|
||
return !answer || ['y', 'yes', '是'].includes(answer);
|
||
} finally {
|
||
readline.close();
|
||
}
|
||
}
|
||
|
||
export function buildMissingConfigWizardArguments(explicitConfigDir) {
|
||
return [
|
||
configWizardPath,
|
||
'--configure-only',
|
||
...(explicitConfigDir ? ['--config-dir', explicitConfigDir] : []),
|
||
];
|
||
}
|
||
|
||
async function runMissingConfigWizard(setActiveChild, explicitConfigDir) {
|
||
const child = spawnChild(
|
||
process.execPath,
|
||
buildMissingConfigWizardArguments(explicitConfigDir),
|
||
{
|
||
stdio: 'inherit',
|
||
},
|
||
);
|
||
setActiveChild(child);
|
||
const result = await childExit(child);
|
||
setActiveChild(null);
|
||
if (result.code !== 0 || result.signal) {
|
||
throw new Error(
|
||
`配置向导未正常完成:code=${result.code ?? ''} signal=${result.signal ?? ''}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function secureWindowsPrivateRuntimePath(targetPath, options) {
|
||
const { secureWindowsGameCreatorPathForCurrentUser } = await import(
|
||
'./game-creator-config-wizard.mjs'
|
||
);
|
||
await secureWindowsGameCreatorPathForCurrentUser(targetPath, options);
|
||
}
|
||
|
||
async function copyPrivateRuntimeConfigEntry(
|
||
sourceConfigDir,
|
||
runtimeConfigDir,
|
||
fileName,
|
||
required,
|
||
) {
|
||
const sourcePath = path.join(sourceConfigDir, fileName);
|
||
const sourceMetadata = await lstat(sourcePath).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (!sourceMetadata) {
|
||
if (required) throw new Error(`配置来源缺少 ${fileName}`);
|
||
return false;
|
||
}
|
||
if (!sourceMetadata.isFile() || sourceMetadata.isSymbolicLink()) {
|
||
throw new Error(`配置来源必须是无符号链接普通文件:${fileName}`);
|
||
}
|
||
|
||
const destinationPath = path.join(runtimeConfigDir, fileName);
|
||
if (process.platform === 'win32') {
|
||
const sourceBytes = await readFile(sourcePath);
|
||
const destinationFile = await open(destinationPath, 'wx', 0o600);
|
||
try {
|
||
await secureWindowsPrivateRuntimePath(destinationPath, {
|
||
isDirectory: false,
|
||
});
|
||
await destinationFile.writeFile(sourceBytes);
|
||
await destinationFile.sync();
|
||
} finally {
|
||
await destinationFile.close();
|
||
}
|
||
} else {
|
||
await copyFile(sourcePath, destinationPath, fsConstants.COPYFILE_EXCL);
|
||
await chmod(destinationPath, 0o600);
|
||
}
|
||
const [sourceMetadataAfterCopy, destinationMetadata] = await Promise.all([
|
||
lstat(sourcePath),
|
||
lstat(destinationPath),
|
||
]);
|
||
if (
|
||
!sourceMetadataAfterCopy.isFile() ||
|
||
sourceMetadataAfterCopy.isSymbolicLink() ||
|
||
sourceMetadataAfterCopy.dev !== sourceMetadata.dev ||
|
||
sourceMetadataAfterCopy.ino !== sourceMetadata.ino ||
|
||
sourceMetadataAfterCopy.size !== sourceMetadata.size ||
|
||
sourceMetadataAfterCopy.mtimeMs !== sourceMetadata.mtimeMs ||
|
||
!destinationMetadata.isFile() ||
|
||
destinationMetadata.isSymbolicLink() ||
|
||
(process.platform !== 'win32' &&
|
||
((destinationMetadata.mode & 0o077) !== 0 ||
|
||
(destinationMetadata.dev === sourceMetadata.dev &&
|
||
destinationMetadata.ino === sourceMetadata.ino)))
|
||
) {
|
||
throw new Error(`隔离配置副本身份或权限无效:${fileName}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export async function prepareSwarmTestRuntimeConfig(sourceConfigDir, tempRoot) {
|
||
if (!path.isAbsolute(sourceConfigDir)) {
|
||
throw new Error('配置来源目录必须是绝对路径');
|
||
}
|
||
const sourceMetadata = await lstat(sourceConfigDir).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (!sourceMetadata?.isDirectory() || sourceMetadata.isSymbolicLink()) {
|
||
throw new Error(`配置来源必须是无符号链接普通目录:${sourceConfigDir}`);
|
||
}
|
||
const canonicalSourceConfigDir = await realpath(sourceConfigDir);
|
||
const canonicalTempRoot = await realpath(
|
||
path.resolve(tempRoot ?? os.tmpdir()),
|
||
);
|
||
const runtimeConfigDir = await mkdtemp(
|
||
path.join(canonicalTempRoot, testRuntimeConfigPrefix),
|
||
);
|
||
try {
|
||
if (process.platform === 'win32') {
|
||
await secureWindowsPrivateRuntimePath(runtimeConfigDir, {
|
||
isDirectory: true,
|
||
});
|
||
} else {
|
||
await chmod(runtimeConfigDir, 0o700);
|
||
}
|
||
const sentinelToken = randomUUID();
|
||
await writeFile(
|
||
path.join(runtimeConfigDir, testRuntimeConfigSentinelName),
|
||
`${JSON.stringify({
|
||
schemaVersion: testRuntimeConfigSentinelSchema,
|
||
token: sentinelToken,
|
||
})}\n`,
|
||
{ flag: 'wx', mode: 0o600 },
|
||
);
|
||
await copyPrivateRuntimeConfigEntry(
|
||
canonicalSourceConfigDir,
|
||
runtimeConfigDir,
|
||
configFileName,
|
||
true,
|
||
);
|
||
await copyPrivateRuntimeConfigEntry(
|
||
canonicalSourceConfigDir,
|
||
runtimeConfigDir,
|
||
localConfigFileName,
|
||
false,
|
||
);
|
||
return {
|
||
path: await realpath(runtimeConfigDir),
|
||
sourcePath: canonicalSourceConfigDir,
|
||
owned: true,
|
||
sentinelToken,
|
||
};
|
||
} catch (error) {
|
||
await rm(runtimeConfigDir, { recursive: true, force: true });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const cleanupDirectoryChildProgram = String.raw`
|
||
const { rm } = require('node:fs/promises');
|
||
const target = process.argv[1];
|
||
rm(target, { recursive: true, force: false }).catch((error) => {
|
||
process.stderr.write(String(error && error.message || error));
|
||
process.exitCode = 1;
|
||
});
|
||
`;
|
||
|
||
export async function removeDirectoryWithTimeout(
|
||
directoryPath,
|
||
{
|
||
timeoutMs = cleanupDirectoryTimeoutMs,
|
||
childProgram = cleanupDirectoryChildProgram,
|
||
} = {},
|
||
) {
|
||
const child = spawnChild(
|
||
process.execPath,
|
||
['-e', childProgram, directoryPath],
|
||
{
|
||
stdio: ['ignore', 'ignore', 'ignore'],
|
||
},
|
||
);
|
||
const result = await childExitWithTimeout(
|
||
child,
|
||
timeoutMs,
|
||
`清理目录 ${path.basename(directoryPath)}`,
|
||
{ graceMs: 1_000, forceWaitMs: 2_000 },
|
||
);
|
||
if (result.code !== 0 || result.signal) {
|
||
throw new Error(
|
||
`清理目录失败:${path.basename(directoryPath)} code=${result.code ?? ''} signal=${result.signal ?? ''}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
export async function cleanupSwarmTestRuntimeConfig(runtimeConfig) {
|
||
if (!runtimeConfig?.owned) return false;
|
||
const sentinelPath = path.join(
|
||
runtimeConfig.path,
|
||
testRuntimeConfigSentinelName,
|
||
);
|
||
if (!(await isRegularFileWithoutSymlink(sentinelPath))) {
|
||
throw new Error('拒绝清理:隔离配置哨兵缺失或类型无效');
|
||
}
|
||
let sentinel;
|
||
try {
|
||
sentinel = JSON.parse(await readFile(sentinelPath, 'utf8'));
|
||
} catch (error) {
|
||
throw new Error(`拒绝清理:隔离配置哨兵无效:${error.message}`);
|
||
}
|
||
if (
|
||
sentinel.schemaVersion !== testRuntimeConfigSentinelSchema ||
|
||
sentinel.token !== runtimeConfig.sentinelToken
|
||
) {
|
||
throw new Error('拒绝清理:隔离配置哨兵身份不匹配');
|
||
}
|
||
const canonical = await realpath(runtimeConfig.path);
|
||
if (
|
||
canonical !== runtimeConfig.path ||
|
||
canonical === runtimeConfig.sourcePath ||
|
||
!path.basename(canonical).startsWith(testRuntimeConfigPrefix)
|
||
) {
|
||
throw new Error('拒绝清理:隔离配置目录身份不匹配');
|
||
}
|
||
const endpointMetadata = await lstat(
|
||
path.join(canonical, runnerEndpointFileName),
|
||
).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (endpointMetadata) {
|
||
throw new Error('拒绝清理:隔离 Agent Runner 尚未退出');
|
||
}
|
||
await removeDirectoryWithTimeout(canonical);
|
||
return true;
|
||
}
|
||
|
||
async function ensureExplicitProject(projectDir) {
|
||
if (!path.isAbsolute(projectDir)) {
|
||
throw new Error('--project-dir 必须是绝对路径');
|
||
}
|
||
const resolved = path.resolve(projectDir);
|
||
const metadata = await lstat(resolved).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (!metadata) {
|
||
await mkdir(resolved, { recursive: true, mode: 0o700 });
|
||
} else if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
||
throw new Error(`测试项目路径必须是普通目录:${resolved}`);
|
||
}
|
||
const canonical = await realpath(resolved);
|
||
const entries = await readdir(canonical);
|
||
const initialized = await isRegularFileWithoutSymlink(
|
||
path.join(canonical, '.agent', 'manifest.json'),
|
||
);
|
||
if (entries.length > 0 && !initialized) {
|
||
throw new Error(`--project-dir 只能指向空目录或已初始化项目:${canonical}`);
|
||
}
|
||
return {
|
||
path: canonical,
|
||
owned: false,
|
||
sentinelToken: null,
|
||
};
|
||
}
|
||
|
||
export async function prepareSwarmTestProject(explicitProjectDir, tempRoot) {
|
||
if (explicitProjectDir) return ensureExplicitProject(explicitProjectDir);
|
||
const root = path.resolve(tempRoot ?? os.tmpdir());
|
||
const projectPath = await mkdtemp(path.join(root, testProjectPrefix));
|
||
try {
|
||
if (process.platform !== 'win32') await chmod(projectPath, 0o700);
|
||
const sentinelToken = randomUUID();
|
||
await writeFile(
|
||
path.join(projectPath, testProjectSentinelName),
|
||
`${JSON.stringify({
|
||
schemaVersion: testProjectSentinelSchema,
|
||
token: sentinelToken,
|
||
})}\n`,
|
||
{ flag: 'wx', mode: 0o600 },
|
||
);
|
||
return {
|
||
path: await realpath(projectPath),
|
||
owned: true,
|
||
sentinelToken,
|
||
};
|
||
} catch (error) {
|
||
await rm(projectPath, { recursive: true, force: true });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function cleanupSwarmTestProject(project) {
|
||
if (!project?.owned) return false;
|
||
const sentinelPath = path.join(project.path, testProjectSentinelName);
|
||
if (!(await isRegularFileWithoutSymlink(sentinelPath))) {
|
||
throw new Error('拒绝清理:一次性项目哨兵缺失或类型无效');
|
||
}
|
||
let sentinel;
|
||
try {
|
||
sentinel = JSON.parse(await readFile(sentinelPath, 'utf8'));
|
||
} catch (error) {
|
||
throw new Error(`拒绝清理:一次性项目哨兵无效:${error.message}`);
|
||
}
|
||
if (
|
||
sentinel.schemaVersion !== testProjectSentinelSchema ||
|
||
sentinel.token !== project.sentinelToken
|
||
) {
|
||
throw new Error('拒绝清理:一次性项目哨兵身份不匹配');
|
||
}
|
||
const canonical = await realpath(project.path);
|
||
if (
|
||
canonical !== project.path ||
|
||
!path.basename(canonical).startsWith(testProjectPrefix)
|
||
) {
|
||
throw new Error('拒绝清理:一次性项目目录身份不匹配');
|
||
}
|
||
await removeDirectoryWithTimeout(canonical);
|
||
return true;
|
||
}
|
||
|
||
export function buildCargoCliArguments(cliArguments) {
|
||
// `--quiet` only silences cargo's own build chatter; compiler errors and the
|
||
// CLI's stdout still come through. Without it the crate's several hundred
|
||
// dead-code warnings are reprinted on every spawn and bury the run output
|
||
// this script exists to show.
|
||
return [
|
||
'run',
|
||
'--quiet',
|
||
'--manifest-path',
|
||
cargoManifestPath,
|
||
'--',
|
||
...cliArguments,
|
||
];
|
||
}
|
||
|
||
function spawnChild(command, args, options = {}) {
|
||
return spawn(command, args, {
|
||
cwd: appRoot,
|
||
env: process.env,
|
||
detached: process.platform !== 'win32',
|
||
...options,
|
||
});
|
||
}
|
||
|
||
const childExitPromises = new WeakMap();
|
||
const closedChildren = new WeakSet();
|
||
|
||
function childExit(child) {
|
||
const existing = childExitPromises.get(child);
|
||
if (existing) return existing;
|
||
const exitPromise = new Promise((resolve, reject) => {
|
||
child.once('error', reject);
|
||
child.once('close', (code, signal) => {
|
||
closedChildren.add(child);
|
||
resolve({ code, signal });
|
||
});
|
||
});
|
||
childExitPromises.set(child, exitPromise);
|
||
return exitPromise;
|
||
}
|
||
|
||
async function childExitWithin(exitPromise, timeoutMs) {
|
||
let timeoutHandle;
|
||
const timeoutPromise = new Promise((resolve) => {
|
||
timeoutHandle = setTimeout(() => resolve(null), timeoutMs);
|
||
});
|
||
try {
|
||
return await Promise.race([exitPromise, timeoutPromise]);
|
||
} finally {
|
||
clearTimeout(timeoutHandle);
|
||
}
|
||
}
|
||
|
||
export async function terminateChildTree(
|
||
child,
|
||
signal = 'SIGTERM',
|
||
force = false,
|
||
) {
|
||
if (!child || closedChildren.has(child) || !Number.isInteger(child.pid)) {
|
||
return;
|
||
}
|
||
if (process.platform === 'win32') {
|
||
const taskkill = spawn(
|
||
'taskkill.exe',
|
||
['/PID', String(child.pid), '/T', ...(force ? ['/F'] : [])],
|
||
{
|
||
stdio: 'ignore',
|
||
windowsHide: true,
|
||
},
|
||
);
|
||
const taskkillExit = childExit(taskkill).catch(() => null);
|
||
if (!(await childExitWithin(taskkillExit, childForceTerminationWaitMs))) {
|
||
try {
|
||
taskkill.kill('SIGKILL');
|
||
} catch {
|
||
// The taskkill helper may have exited at the timeout boundary.
|
||
}
|
||
await childExitWithin(taskkillExit, childForceTerminationWaitMs);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
process.kill(-child.pid, force ? 'SIGKILL' : signal);
|
||
} catch (error) {
|
||
try {
|
||
child.kill(force ? 'SIGKILL' : signal);
|
||
} catch {
|
||
if (error?.code !== 'ESRCH') throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function terminateChildTreeAndWait(
|
||
child,
|
||
exitPromise,
|
||
signal,
|
||
label,
|
||
{
|
||
graceMs = childTerminationGraceMs,
|
||
forceWaitMs = childForceTerminationWaitMs,
|
||
} = {},
|
||
) {
|
||
await terminateChildTree(child, signal, false);
|
||
const gracefulResult = await childExitWithin(exitPromise, graceMs);
|
||
if (gracefulResult) return gracefulResult;
|
||
|
||
await terminateChildTree(child, 'SIGKILL', true);
|
||
const forcedResult = await childExitWithin(exitPromise, forceWaitMs);
|
||
if (forcedResult) return forcedResult;
|
||
throw new Error(`${label} 无法在强制终止进程树后关闭 stdio`);
|
||
}
|
||
|
||
export async function childExitWithTimeout(
|
||
child,
|
||
timeoutMs,
|
||
label,
|
||
terminationOptions,
|
||
) {
|
||
const exitPromise = childExit(child);
|
||
if (timeoutMs === null) return exitPromise;
|
||
let timeoutHandle;
|
||
const timeoutPromise = new Promise((resolve) => {
|
||
timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs);
|
||
});
|
||
try {
|
||
const first = await Promise.race([
|
||
exitPromise.then((result) => ({ kind: 'exit', result })),
|
||
timeoutPromise,
|
||
]);
|
||
if (first.kind === 'exit') return first.result;
|
||
try {
|
||
await terminateChildTreeAndWait(
|
||
child,
|
||
exitPromise,
|
||
'SIGTERM',
|
||
label,
|
||
terminationOptions,
|
||
);
|
||
} catch (error) {
|
||
error.code = 'AGC_CHILD_TIMEOUT';
|
||
throw error;
|
||
}
|
||
throw Object.assign(
|
||
new Error(`${label} 超过 ${Math.ceil(timeoutMs / 1000)} 秒期限`),
|
||
{ code: 'AGC_CHILD_TIMEOUT' },
|
||
);
|
||
} finally {
|
||
clearTimeout(timeoutHandle);
|
||
}
|
||
}
|
||
|
||
function isPlainObject(value) {
|
||
return (
|
||
value !== null &&
|
||
typeof value === 'object' &&
|
||
!Array.isArray(value) &&
|
||
Object.getPrototypeOf(value) === Object.prototype
|
||
);
|
||
}
|
||
|
||
function isNonNegativeSafeInteger(value) {
|
||
return Number.isSafeInteger(value) && value >= 0;
|
||
}
|
||
|
||
export function parseSettledSwarmTurnReport(output) {
|
||
const reportLines = String(output)
|
||
.split(/\r?\n/u)
|
||
.filter((line) => line.startsWith(swarmTurnReportPrefix));
|
||
if (reportLines.length !== 1) {
|
||
throw new Error(
|
||
`Agent Swarm 终态报告数量无效:expected=1 actual=${reportLines.length}`,
|
||
);
|
||
}
|
||
|
||
let report;
|
||
try {
|
||
report = JSON.parse(reportLines[0].slice(swarmTurnReportPrefix.length));
|
||
} catch {
|
||
throw new Error('Agent Swarm 终态报告不是有效 JSON');
|
||
}
|
||
if (
|
||
!isPlainObject(report) ||
|
||
JSON.stringify(Object.keys(report).sort()) !==
|
||
JSON.stringify(swarmTurnReportKeys)
|
||
) {
|
||
throw new Error('Agent Swarm 终态报告结构无效');
|
||
}
|
||
if (report.schemaVersion !== swarmTurnReportSchema) {
|
||
throw new Error('Agent Swarm 终态报告 schema 无效');
|
||
}
|
||
if (
|
||
typeof report.parentAgentId !== 'string' ||
|
||
!report.parentAgentId.trim() ||
|
||
typeof report.sessionId !== 'string' ||
|
||
!report.sessionId.trim() ||
|
||
typeof report.parentRunId !== 'string' ||
|
||
!report.parentRunId.trim()
|
||
) {
|
||
throw new Error('Agent Swarm 终态报告运行身份无效');
|
||
}
|
||
const countFields = [
|
||
'runtimeCount',
|
||
...settledZeroCountFields,
|
||
'newAssistantMessageCount',
|
||
'finalReplyChars',
|
||
];
|
||
if (countFields.some((field) => !isNonNegativeSafeInteger(report[field]))) {
|
||
throw new Error('Agent Swarm 终态报告计数无效');
|
||
}
|
||
if (
|
||
!['settled', 'failed', 'incomplete', 'needs-reconciliation'].includes(
|
||
report.outcome,
|
||
)
|
||
) {
|
||
throw new Error('Agent Swarm 终态报告 outcome 无效');
|
||
}
|
||
if (report.outcome !== 'settled') {
|
||
throw new Error(`Agent Swarm 本轮未收束:outcome=${report.outcome}`);
|
||
}
|
||
const unsettledField = settledZeroCountFields.find(
|
||
(field) => report[field] !== 0,
|
||
);
|
||
if (unsettledField) {
|
||
throw new Error(
|
||
`Agent Swarm 本轮仍有未收束工作:${unsettledField}=${report[unsettledField]}`,
|
||
);
|
||
}
|
||
if (report.runtimeCount < 1) {
|
||
throw new Error('Agent Swarm 终态报告没有 Runtime');
|
||
}
|
||
if (
|
||
report.newAssistantMessageCount !== 1 ||
|
||
report.finalReplyChars < 1 ||
|
||
report.finalReplyChars > 1_000_000
|
||
) {
|
||
throw new Error('Agent Swarm 最终回复无效');
|
||
}
|
||
return report;
|
||
}
|
||
|
||
async function runCapturedCargo(
|
||
cliArguments,
|
||
setActiveChild,
|
||
{ timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {},
|
||
) {
|
||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||
stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||
});
|
||
setActiveChild(child);
|
||
if (stdin !== null) {
|
||
child.stdin.end(stdin);
|
||
}
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
child.stdout.on('data', (chunk) => {
|
||
stdout += chunk;
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk;
|
||
});
|
||
try {
|
||
const result = await childExitWithTimeout(child, timeoutMs, label);
|
||
return { ...result, stdout, stderr };
|
||
} finally {
|
||
setActiveChild(null);
|
||
}
|
||
}
|
||
|
||
async function runInteractiveCargo(cliArguments, setActiveChild) {
|
||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||
stdio: 'inherit',
|
||
});
|
||
setActiveChild(child);
|
||
const result = await childExit(child);
|
||
setActiveChild(null);
|
||
return result;
|
||
}
|
||
|
||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||
|
||
export function nextSwarmAutoPilotReply(output) {
|
||
if (swarmConfirmationPromptPattern.test(output)) return 'approve';
|
||
if (swarmUserInputPromptPattern.test(output)) return '1';
|
||
return null;
|
||
}
|
||
|
||
// CLI 的 REPL 是「先打印提示符再读行」,所以第一个「你>」出现时本轮还没开始跑:
|
||
// 它就是用来读我们这条任务的。收 stdin 必须等到投递之后的下一个提示符——那才是
|
||
// 本轮结束、CLI 回到待输入状态。绝大多数情况下此前已经打印过 turn 回执,但总控也
|
||
// 可能判定直接回复而不起持久 Run,那条路径没有回执,只等回执会一直干等到超时。
|
||
const swarmChatPromptPattern = /(^|\n)你> $/u;
|
||
|
||
export function swarmAutoPilotSitsAtPrompt(output) {
|
||
return swarmChatPromptPattern.test(output);
|
||
}
|
||
|
||
export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
|
||
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
|
||
}
|
||
|
||
async function runTaskCargo(
|
||
cliArguments,
|
||
task,
|
||
setActiveChild,
|
||
timeoutMs,
|
||
autoPilot = false,
|
||
) {
|
||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||
stdio: ['pipe', 'pipe', 'inherit'],
|
||
});
|
||
setActiveChild(child);
|
||
const reportLines = [];
|
||
let pendingLine = '';
|
||
let settled = false;
|
||
let taskSubmitted = false;
|
||
let promptsSeen = 0;
|
||
let sittingAtPrompt = false;
|
||
child.stdout.setEncoding('utf8');
|
||
child.stdout.on('data', (chunk) => {
|
||
process.stdout.write(chunk);
|
||
pendingLine += chunk;
|
||
const lines = pendingLine.split('\n');
|
||
pendingLine = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||
if (normalizedLine.startsWith(swarmTurnReportPrefix)) {
|
||
reportLines.push(normalizedLine);
|
||
settled = true;
|
||
}
|
||
}
|
||
if (!autoPilot || child.stdin.writableEnded) return;
|
||
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
|
||
if (atPrompt && !sittingAtPrompt) promptsSeen += 1;
|
||
sittingAtPrompt = atPrompt;
|
||
// turn 已给出回执、或 CLI 回到了投递之后的下一个提示符,都说明本轮结束。
|
||
if (
|
||
settled ||
|
||
(taskSubmitted &&
|
||
swarmAutoPilotShouldCloseInput(pendingLine, promptsSeen - 1))
|
||
) {
|
||
child.stdin.end();
|
||
return;
|
||
}
|
||
const reply = nextSwarmAutoPilotReply(pendingLine);
|
||
if (reply === null) return;
|
||
console.log(`[自动应答] ${reply}`);
|
||
pendingLine = '';
|
||
child.stdin.write(`${reply}\n`);
|
||
});
|
||
if (autoPilot) {
|
||
child.stdin.write(`${task}\n`);
|
||
taskSubmitted = true;
|
||
} else {
|
||
child.stdin.end(`${task}\n`);
|
||
}
|
||
try {
|
||
const result = await childExitWithTimeout(
|
||
child,
|
||
timeoutMs,
|
||
'Agent Swarm 自动任务',
|
||
);
|
||
const normalizedPendingLine = pendingLine.endsWith('\r')
|
||
? pendingLine.slice(0, -1)
|
||
: pendingLine;
|
||
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
|
||
reportLines.push(normalizedPendingLine);
|
||
}
|
||
return {
|
||
...result,
|
||
turnReportOutput: reportLines.join('\n'),
|
||
};
|
||
} finally {
|
||
setActiveChild(null);
|
||
}
|
||
}
|
||
|
||
export function parseRunnerShutdownOutput(output) {
|
||
const match = output.match(/^runner\.stopped=(true|false)$/m);
|
||
if (!match) throw new Error('Runner 收束命令缺少 stopped 状态');
|
||
return match[1] === 'true';
|
||
}
|
||
|
||
async function shutdownSwarmTestRunner(runtimeConfig, setActiveChild) {
|
||
const result = await runCapturedCargo(
|
||
['--config-dir', runtimeConfig.path, '--runner-shutdown-if-idle'],
|
||
setActiveChild,
|
||
{
|
||
timeoutMs: runnerShutdownTimeoutMs,
|
||
label: '隔离 Agent Runner 收束命令',
|
||
},
|
||
);
|
||
if (result.code !== 0 || result.signal) {
|
||
throw new Error(
|
||
`隔离 Agent Runner 收束失败:${result.stderr.trim() || result.stdout.trim() || `code=${result.code ?? ''} signal=${result.signal ?? ''}`}`,
|
||
);
|
||
}
|
||
return parseRunnerShutdownOutput(result.stdout);
|
||
}
|
||
|
||
export function validatePreviewUrl(value) {
|
||
const url = new URL(value);
|
||
if (
|
||
url.protocol !== 'http:' ||
|
||
url.hostname !== '127.0.0.1' ||
|
||
!url.port ||
|
||
url.username ||
|
||
url.password ||
|
||
url.pathname !== '/' ||
|
||
url.search ||
|
||
url.hash
|
||
) {
|
||
throw new Error('预览命令返回了非 loopback URL');
|
||
}
|
||
return url.toString();
|
||
}
|
||
|
||
export async function hasGeneratedGameEntry(projectPath) {
|
||
const gameEntryPath = path.join(projectPath, 'game', 'index.html');
|
||
if (!(await isRegularFileWithoutSymlink(gameEntryPath))) return false;
|
||
const html = await readFile(gameEntryPath, 'utf8');
|
||
return html.trim().length > 0 && !html.includes(ungeneratedGameEntryMarker);
|
||
}
|
||
|
||
const pngSignature = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
]);
|
||
const pngCrcTable = Uint32Array.from({ length: 256 }, (_unused, index) => {
|
||
let value = index;
|
||
for (let bit = 0; bit < 8; bit += 1) {
|
||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||
}
|
||
return value >>> 0;
|
||
});
|
||
|
||
function pngCrc32(bytes) {
|
||
let value = 0xffffffff;
|
||
for (const byte of bytes) {
|
||
value = pngCrcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||
}
|
||
return (value ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
export function validatePngBytes(bytes) {
|
||
if (
|
||
!Buffer.isBuffer(bytes) ||
|
||
bytes.length < 57 ||
|
||
bytes.length > maximumValidatedPngBytes ||
|
||
!bytes.subarray(0, pngSignature.length).equals(pngSignature)
|
||
) {
|
||
throw new Error('PNG 签名或文件大小无效');
|
||
}
|
||
|
||
let offset = pngSignature.length;
|
||
let ihdr = null;
|
||
let ihdrCount = 0;
|
||
let idatSeen = false;
|
||
let idatEnded = false;
|
||
let iendSeen = false;
|
||
let plteCount = 0;
|
||
let plteEntries = 0;
|
||
const idatChunks = [];
|
||
while (offset < bytes.length) {
|
||
if (bytes.length - offset < 12) throw new Error('PNG chunk 被截断');
|
||
const length = bytes.readUInt32BE(offset);
|
||
const typeOffset = offset + 4;
|
||
const dataOffset = typeOffset + 4;
|
||
const dataEnd = dataOffset + length;
|
||
const chunkEnd = dataEnd + 4;
|
||
if (dataEnd > bytes.length - 4 || chunkEnd > bytes.length) {
|
||
throw new Error('PNG chunk 长度越界');
|
||
}
|
||
const type = bytes.subarray(typeOffset, dataOffset).toString('ascii');
|
||
if (!/^[A-Za-z]{4}$/u.test(type)) throw new Error('PNG chunk 类型无效');
|
||
const expectedCrc = bytes.readUInt32BE(dataEnd);
|
||
const actualCrc = pngCrc32(bytes.subarray(typeOffset, dataEnd));
|
||
if (expectedCrc !== actualCrc) throw new Error(`${type} CRC 无效`);
|
||
|
||
const data = bytes.subarray(dataOffset, dataEnd);
|
||
if (offset === pngSignature.length && type !== 'IHDR') {
|
||
throw new Error('IHDR 必须是第一个 chunk');
|
||
}
|
||
if (type === 'IHDR') {
|
||
ihdrCount += 1;
|
||
if (ihdrCount !== 1 || length !== 13) {
|
||
throw new Error('IHDR 数量或长度无效');
|
||
}
|
||
ihdr = {
|
||
width: data.readUInt32BE(0),
|
||
height: data.readUInt32BE(4),
|
||
bitDepth: data[8],
|
||
colorType: data[9],
|
||
compression: data[10],
|
||
filter: data[11],
|
||
interlace: data[12],
|
||
};
|
||
} else if (type === 'PLTE') {
|
||
plteCount += 1;
|
||
if (
|
||
!ihdr ||
|
||
idatSeen ||
|
||
iendSeen ||
|
||
plteCount !== 1 ||
|
||
length < 3 ||
|
||
length > 768 ||
|
||
length % 3 !== 0
|
||
) {
|
||
throw new Error('PLTE 数量、长度或顺序无效');
|
||
}
|
||
plteEntries = length / 3;
|
||
} else if (type === 'IDAT') {
|
||
if (!ihdr || idatEnded || iendSeen) {
|
||
throw new Error('IDAT 顺序无效');
|
||
}
|
||
idatSeen = true;
|
||
idatChunks.push(data);
|
||
} else if (type === 'IEND') {
|
||
if (!ihdr || !idatSeen || iendSeen || length !== 0) {
|
||
throw new Error('IEND 数量、长度或顺序无效');
|
||
}
|
||
iendSeen = true;
|
||
if (chunkEnd !== bytes.length) throw new Error('IEND 后存在额外数据');
|
||
} else {
|
||
if ((type.charCodeAt(0) & 0x20) === 0) {
|
||
throw new Error(`不支持的 PNG critical chunk:${type}`);
|
||
}
|
||
if (idatSeen) idatEnded = true;
|
||
}
|
||
offset = chunkEnd;
|
||
}
|
||
|
||
if (!ihdr || ihdrCount !== 1 || !idatSeen || !iendSeen) {
|
||
throw new Error('PNG 缺少唯一 IHDR、IDAT 或 IEND');
|
||
}
|
||
const validBitDepths = {
|
||
0: [1, 2, 4, 8, 16],
|
||
2: [8, 16],
|
||
3: [1, 2, 4, 8],
|
||
4: [8, 16],
|
||
6: [8, 16],
|
||
};
|
||
if (
|
||
ihdr.width < 1 ||
|
||
ihdr.height < 1 ||
|
||
ihdr.width * ihdr.height > maximumValidatedPngPixels ||
|
||
!validBitDepths[ihdr.colorType]?.includes(ihdr.bitDepth) ||
|
||
ihdr.compression !== 0 ||
|
||
ihdr.filter !== 0 ||
|
||
ihdr.interlace !== 0
|
||
) {
|
||
throw new Error('IHDR 参数无效或不支持交错 PNG');
|
||
}
|
||
if (
|
||
(ihdr.colorType === 3 &&
|
||
(plteCount !== 1 || plteEntries > 2 ** ihdr.bitDepth)) ||
|
||
([0, 4].includes(ihdr.colorType) && plteCount !== 0)
|
||
) {
|
||
throw new Error('PLTE 与 PNG color type 或 bit depth 不匹配');
|
||
}
|
||
|
||
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ihdr.colorType];
|
||
const rowBytes = Math.ceil((ihdr.width * channels * ihdr.bitDepth) / 8);
|
||
const expectedInflatedBytes = ihdr.height * (rowBytes + 1);
|
||
if (
|
||
!Number.isSafeInteger(expectedInflatedBytes) ||
|
||
expectedInflatedBytes > maximumInflatedPngBytes
|
||
) {
|
||
throw new Error('PNG scanline 大小无效');
|
||
}
|
||
let inflated;
|
||
try {
|
||
const compressed = Buffer.concat(idatChunks);
|
||
const result = inflateSync(compressed, {
|
||
maxOutputLength: expectedInflatedBytes + 1,
|
||
info: true,
|
||
});
|
||
if (result.engine.bytesWritten !== compressed.length) {
|
||
throw new Error('trailing compressed bytes');
|
||
}
|
||
inflated = result.buffer;
|
||
} catch {
|
||
throw new Error('IDAT zlib 数据无法完整解压');
|
||
}
|
||
if (inflated.length !== expectedInflatedBytes) {
|
||
throw new Error('非交错 PNG scanline 长度无效');
|
||
}
|
||
for (let row = 0; row < ihdr.height; row += 1) {
|
||
if (inflated[row * (rowBytes + 1)] > 4) {
|
||
throw new Error(`第 ${row + 1} 行 filter byte 无效`);
|
||
}
|
||
}
|
||
return { width: ihdr.width, height: ihdr.height };
|
||
}
|
||
|
||
async function validatePngFile(filePath) {
|
||
const metadata = await lstat(filePath);
|
||
if (
|
||
!metadata.isFile() ||
|
||
metadata.isSymbolicLink() ||
|
||
metadata.size < 1_024 ||
|
||
metadata.size > maximumValidatedPngBytes
|
||
) {
|
||
throw new Error('PNG 文件缺失、类型无效或大小超限');
|
||
}
|
||
const noFollowFlag =
|
||
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
||
const file = await open(filePath, fsConstants.O_RDONLY | noFollowFlag);
|
||
try {
|
||
return validatePngBytes(await file.readFile());
|
||
} finally {
|
||
await file.close();
|
||
}
|
||
}
|
||
|
||
async function inspectFormalArtifact(projectPath, spec) {
|
||
const artifactPath = path.join(projectPath, ...spec.path.split('/'));
|
||
let metadata;
|
||
try {
|
||
metadata = await lstat(artifactPath);
|
||
} catch (error) {
|
||
return {
|
||
path: spec.path,
|
||
reason:
|
||
error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
|
||
? '缺失'
|
||
: '无法安全读取',
|
||
};
|
||
}
|
||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||
return { path: spec.path, reason: '不是无符号链接普通文件' };
|
||
}
|
||
if (metadata.size === 0) return { path: spec.path, reason: '空文件' };
|
||
|
||
const noFollowFlag =
|
||
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
||
let file;
|
||
try {
|
||
file = await open(artifactPath, fsConstants.O_RDONLY | noFollowFlag);
|
||
} catch {
|
||
return { path: spec.path, reason: '无法安全读取' };
|
||
}
|
||
try {
|
||
const openedMetadata = await file.stat();
|
||
if (!openedMetadata.isFile()) {
|
||
return { path: spec.path, reason: '不是无符号链接普通文件' };
|
||
}
|
||
if (openedMetadata.size === 0) {
|
||
return { path: spec.path, reason: '空文件' };
|
||
}
|
||
|
||
if (spec.kind === 'json') {
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(await file.readFile('utf8'));
|
||
} catch {
|
||
return { path: spec.path, reason: 'JSON 无法解析' };
|
||
}
|
||
if (!isPlainObject(parsed) || Object.keys(parsed).length === 0) {
|
||
return { path: spec.path, reason: 'JSON 必须是非空对象' };
|
||
}
|
||
} else if (spec.kind === 'file' || spec.kind === 'game-entry') {
|
||
const content = await file.readFile('utf8');
|
||
if (content.trim().length === 0) {
|
||
return { path: spec.path, reason: '空文件' };
|
||
}
|
||
if (hasIncompleteArtifactMarker(content)) {
|
||
return { path: spec.path, reason: '仍包含占位标记' };
|
||
}
|
||
const compactContent = content.replace(/\s/gu, '');
|
||
if (
|
||
spec.kind === 'file' &&
|
||
compactContent.length < minimumMarkdownBodyCharacters
|
||
) {
|
||
return { path: spec.path, reason: 'Markdown 正文过短' };
|
||
}
|
||
if (
|
||
spec.kind === 'game-entry' &&
|
||
content.includes(ungeneratedGameEntryMarker)
|
||
) {
|
||
return { path: spec.path, reason: '仍是初始化占位页' };
|
||
}
|
||
if (
|
||
spec.kind === 'game-entry' &&
|
||
(compactContent.length < minimumHtmlCharacters ||
|
||
!/<html\b[^>]*>[\s\S]*<\/html>/iu.test(content) ||
|
||
!/<script\b/iu.test(content) ||
|
||
!/<(?:canvas|button)\b/iu.test(content))
|
||
) {
|
||
return { path: spec.path, reason: 'HTML 正文或交互结构不完整' };
|
||
}
|
||
} else if (spec.kind === 'image') {
|
||
if (openedMetadata.size < 1_024) {
|
||
return { path: spec.path, reason: '图片文件小于 1 KiB' };
|
||
}
|
||
if (openedMetadata.size > maximumValidatedPngBytes) {
|
||
return { path: spec.path, reason: '图片文件大小超限' };
|
||
}
|
||
let dimensions;
|
||
try {
|
||
dimensions = validatePngBytes(await file.readFile());
|
||
} catch (error) {
|
||
return {
|
||
path: spec.path,
|
||
reason: `PNG 文件无效:${error.message}`,
|
||
};
|
||
}
|
||
const ratio = dimensions.width / dimensions.height;
|
||
if (Math.abs(ratio - spec.aspectRatio) > 0.03) {
|
||
return {
|
||
path: spec.path,
|
||
reason: `图片比例无效:${dimensions.width}x${dimensions.height}`,
|
||
};
|
||
}
|
||
}
|
||
} catch {
|
||
return { path: spec.path, reason: '无法安全读取' };
|
||
} finally {
|
||
await file.close();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveProjectEvidencePath(projectPath, relativePath) {
|
||
if (
|
||
typeof relativePath !== 'string' ||
|
||
!relativePath ||
|
||
path.isAbsolute(relativePath) ||
|
||
relativePath.includes('\\')
|
||
) {
|
||
throw new Error('证据路径无效');
|
||
}
|
||
const root = path.resolve(projectPath);
|
||
const targetPath = path.resolve(root, ...relativePath.split('/'));
|
||
if (!targetPath.startsWith(`${root}${path.sep}`)) {
|
||
throw new Error('证据路径越界');
|
||
}
|
||
return targetPath;
|
||
}
|
||
|
||
async function readSafeJson(projectPath, relativePath, maximumBytes) {
|
||
const targetPath = resolveProjectEvidencePath(projectPath, relativePath);
|
||
const metadata = await lstat(targetPath).catch(() => null);
|
||
if (
|
||
!metadata?.isFile() ||
|
||
metadata.isSymbolicLink() ||
|
||
metadata.size < 2 ||
|
||
metadata.size > maximumBytes
|
||
) {
|
||
throw new Error('文件缺失、类型无效或大小超限');
|
||
}
|
||
const noFollowFlag =
|
||
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
||
const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag);
|
||
try {
|
||
return JSON.parse(await file.readFile('utf8'));
|
||
} finally {
|
||
await file.close();
|
||
}
|
||
}
|
||
|
||
async function readSafeJsonLines(projectPath, relativePath, maximumBytes) {
|
||
const targetPath = resolveProjectEvidencePath(projectPath, relativePath);
|
||
const metadata = await lstat(targetPath);
|
||
if (
|
||
!metadata.isFile() ||
|
||
metadata.isSymbolicLink() ||
|
||
metadata.size < 2 ||
|
||
metadata.size > maximumBytes
|
||
) {
|
||
throw new Error('JSONL 文件缺失、类型无效或大小超限');
|
||
}
|
||
const noFollowFlag =
|
||
process.platform === 'win32' ? 0 : (fsConstants.O_NOFOLLOW ?? 0);
|
||
const file = await open(targetPath, fsConstants.O_RDONLY | noFollowFlag);
|
||
try {
|
||
const content = await file.readFile('utf8');
|
||
if (!content.endsWith('\n')) throw new Error('JSONL 尾记录不完整');
|
||
return content
|
||
.split(/\r?\n/u)
|
||
.filter(Boolean)
|
||
.map((line) => JSON.parse(line));
|
||
} finally {
|
||
await file.close();
|
||
}
|
||
}
|
||
|
||
async function inspectReadyTaskExactlyOnce(
|
||
projectPath,
|
||
parentRunId,
|
||
agentDbRecords,
|
||
) {
|
||
const issues = [];
|
||
for (const taskId of requiredSwarmManifestTaskIds) {
|
||
const journalPath = `.agent/runtime/tasks/${taskId}.jsonl`;
|
||
try {
|
||
const journal = await readSafeJsonLines(
|
||
projectPath,
|
||
journalPath,
|
||
8 * 1024 * 1024,
|
||
);
|
||
const currentRunRecords = journal.filter(
|
||
(record) =>
|
||
record?.agentId === taskId &&
|
||
record?.taskId === taskId &&
|
||
record?.source === 'agent-ready-task-scheduler' &&
|
||
record?.parentAgentId === 'project-supervisor' &&
|
||
record?.parentRunId === parentRunId,
|
||
);
|
||
const runIds = [
|
||
...new Set(currentRunRecords.map((record) => record?.runId)),
|
||
].filter((runId) => typeof runId === 'string' && runId.length > 0);
|
||
if (runIds.length !== 1) throw new Error('logical-run-count');
|
||
const runId = runIds[0];
|
||
const latest = currentRunRecords
|
||
.filter((record) => record?.runId === runId)
|
||
.at(-1);
|
||
if (
|
||
latest?.status !== 'completed' ||
|
||
latest?.phase !== 'completed' ||
|
||
latest?.runProfile !== 'autonomous-game-build'
|
||
) {
|
||
throw new Error('logical-run-terminal');
|
||
}
|
||
const count = (recordType) =>
|
||
agentDbRecords.filter(
|
||
(record) =>
|
||
record?.recordType === recordType &&
|
||
record?.agentId === taskId &&
|
||
record?.taskId === taskId &&
|
||
record?.runId === runId &&
|
||
record?.source === 'agent-ready-task-scheduler',
|
||
).length;
|
||
const projections = agentDbRecords.filter(
|
||
(record) =>
|
||
record?.recordType ===
|
||
'agent.runtime.autonomous_ready_task.manifest_projected' &&
|
||
record?.agentId === taskId &&
|
||
record?.taskId === taskId &&
|
||
record?.runId === runId &&
|
||
record?.source === 'agent-ready-task-scheduler' &&
|
||
record?.parentAgentId === 'project-supervisor' &&
|
||
record?.parentRunId === parentRunId &&
|
||
record?.terminalPhase === 'completed' &&
|
||
record?.manifestStatus === 'completed',
|
||
);
|
||
if (
|
||
count('agent.runtime.background_task') !== 1 ||
|
||
count('agent.runtime.background_task.completed') !== 1 ||
|
||
count('agent.runtime.background_task.failed') !== 0 ||
|
||
count('agent.runtime.background_task.cancelled') !== 0 ||
|
||
projections.length !== 1
|
||
) {
|
||
throw new Error('lifecycle-count');
|
||
}
|
||
} catch {
|
||
issues.push({
|
||
path: journalPath,
|
||
reason: `正式任务 ${taskId} 未在当前父 Run 中恰好启动并完成一次`,
|
||
});
|
||
}
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
async function inspectSwarmRuntimeAcceptance(projectPath, parentRunId = null) {
|
||
const issues = [];
|
||
let manifest;
|
||
try {
|
||
manifest = await readSafeJson(
|
||
projectPath,
|
||
'.agent/manifest.json',
|
||
2 * 1024 * 1024,
|
||
);
|
||
} catch {
|
||
issues.push({
|
||
path: '.agent/manifest.json',
|
||
reason: '无法读取正式任务图',
|
||
});
|
||
}
|
||
if (manifest) {
|
||
const tasks = Array.isArray(manifest.tasks) ? manifest.tasks : [];
|
||
const taskIds = tasks.map((task) => task?.id);
|
||
const expectedIds = [...requiredSwarmManifestTaskIds].sort();
|
||
const actualIds = [...taskIds].sort();
|
||
if (
|
||
tasks.length !== requiredSwarmManifestTaskIds.length ||
|
||
new Set(taskIds).size !== taskIds.length ||
|
||
JSON.stringify(actualIds) !== JSON.stringify(expectedIds) ||
|
||
tasks.some((task) => task?.status !== 'completed')
|
||
) {
|
||
issues.push({
|
||
path: '.agent/manifest.json',
|
||
reason: '固定 16 个正式任务未全部且仅完成一次',
|
||
});
|
||
}
|
||
}
|
||
|
||
let revision;
|
||
try {
|
||
const revisionRecord = await readSafeJson(
|
||
projectPath,
|
||
'.agent/runtime/project-revision.json',
|
||
64 * 1024,
|
||
);
|
||
revision = revisionRecord?.revision;
|
||
if (!Number.isSafeInteger(revision) || revision < 1) throw new Error();
|
||
} catch {
|
||
issues.push({
|
||
path: '.agent/runtime/project-revision.json',
|
||
reason: '当前项目 revision 无效',
|
||
});
|
||
}
|
||
|
||
let records = [];
|
||
let recordsValid = true;
|
||
try {
|
||
const databasePath = path.join(projectPath, '.agent', 'agent.db');
|
||
const metadata = await lstat(databasePath);
|
||
if (
|
||
!metadata.isFile() ||
|
||
metadata.isSymbolicLink() ||
|
||
metadata.size < 2 ||
|
||
metadata.size > 64 * 1024 * 1024
|
||
) {
|
||
throw new Error();
|
||
}
|
||
records = (await readFile(databasePath, 'utf8'))
|
||
.split(/\r?\n/u)
|
||
.filter(Boolean)
|
||
.map((line) => JSON.parse(line));
|
||
} catch {
|
||
recordsValid = false;
|
||
issues.push({ path: '.agent/agent.db', reason: 'Runtime 证据库无效' });
|
||
}
|
||
if (!recordsValid) return issues;
|
||
if (parentRunId) {
|
||
issues.push(
|
||
...(await inspectReadyTaskExactlyOnce(projectPath, parentRunId, records)),
|
||
);
|
||
}
|
||
const staticSmoke = records.some(
|
||
(record) =>
|
||
record?.recordType === 'agent.runtime.command.run_limited' &&
|
||
record?.commandId === 'game.static_smoke' &&
|
||
record?.status === 'completed' &&
|
||
record?.revision === revision,
|
||
);
|
||
if (!staticSmoke) {
|
||
issues.push({
|
||
path: '.agent/agent.db',
|
||
reason: '缺少当前 revision 的静态检查通过凭证',
|
||
});
|
||
}
|
||
const browserEvidence = records
|
||
.filter(
|
||
(record) =>
|
||
record?.recordType === 'agent.runtime.preview.validation' &&
|
||
record?.passed === true &&
|
||
record?.playtestPassed === true &&
|
||
record?.revision === revision,
|
||
)
|
||
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0];
|
||
if (!browserEvidence) {
|
||
issues.push({
|
||
path: '.agent/agent.db',
|
||
reason: '缺少当前 revision 的桌面与移动试玩通过凭证',
|
||
});
|
||
return issues;
|
||
}
|
||
const screenshots = Array.isArray(browserEvidence.screenshots)
|
||
? browserEvidence.screenshots
|
||
: [];
|
||
for (const viewport of ['desktop', 'mobile']) {
|
||
const screenshot = screenshots.find((item) =>
|
||
String(item).endsWith(`/${viewport}.png`),
|
||
);
|
||
let screenshotValid = false;
|
||
try {
|
||
if (screenshot) {
|
||
await validatePngFile(
|
||
resolveProjectEvidencePath(projectPath, screenshot),
|
||
);
|
||
screenshotValid = true;
|
||
}
|
||
} catch {
|
||
screenshotValid = false;
|
||
}
|
||
if (!screenshotValid) {
|
||
issues.push({
|
||
path: '.agent/agent.db',
|
||
reason: `缺少 ${viewport} 试玩截图`,
|
||
});
|
||
}
|
||
}
|
||
try {
|
||
const report = await readSafeJson(
|
||
projectPath,
|
||
browserEvidence.reportPath,
|
||
2 * 1024 * 1024,
|
||
);
|
||
const viewportResults = Array.isArray(report?.viewportResults)
|
||
? report.viewportResults
|
||
: [];
|
||
if (
|
||
report?.passed !== true ||
|
||
report?.playtest?.passed !== true ||
|
||
!['desktop', 'mobile'].every((viewport) =>
|
||
viewportResults.some(
|
||
(result) => result?.viewport === viewport && result?.passed === true,
|
||
),
|
||
)
|
||
) {
|
||
throw new Error();
|
||
}
|
||
} catch {
|
||
issues.push({
|
||
path: String(
|
||
browserEvidence.reportPath ?? '.agent/runtime/browser-validations',
|
||
),
|
||
reason: '双视口试玩报告无效或已过期',
|
||
});
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
export async function inspectSwarmProjectArtifacts(
|
||
projectPath,
|
||
{ requireEditorImages = false, parentRunId = null } = {},
|
||
) {
|
||
const specs = requireEditorImages
|
||
? [...requiredFormalArtifactSpecs, ...editorImageArtifactSpecs]
|
||
: requiredFormalArtifactSpecs;
|
||
const issues = (
|
||
await Promise.all(
|
||
specs.map((spec) => inspectFormalArtifact(projectPath, spec)),
|
||
)
|
||
).filter(Boolean);
|
||
issues.push(
|
||
...(await inspectSwarmRuntimeAcceptance(projectPath, parentRunId)),
|
||
);
|
||
return {
|
||
valid: issues.length === 0,
|
||
requireEditorImages,
|
||
invalidPaths: issues.map((issue) => issue.path),
|
||
issues,
|
||
};
|
||
}
|
||
|
||
export async function validateSwarmProjectArtifacts(projectPath, options) {
|
||
const inspection = await inspectSwarmProjectArtifacts(projectPath, options);
|
||
if (!inspection.valid) {
|
||
throw new Error(
|
||
`Agent Swarm 已退出,但最小正式产物检查失败:\n${inspection.issues
|
||
.map((issue) => `- ${issue.path}:${issue.reason}`)
|
||
.join('\n')}`,
|
||
);
|
||
}
|
||
return inspection;
|
||
}
|
||
|
||
export async function hasConfiguredEditorApiKey(configDir) {
|
||
let configured = false;
|
||
for (const fileName of [configFileName, localConfigFileName]) {
|
||
const configPath = path.join(configDir, fileName);
|
||
const metadata = await lstat(configPath).catch((error) => {
|
||
if (error?.code === 'ENOENT') return null;
|
||
throw error;
|
||
});
|
||
if (!metadata) continue;
|
||
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
||
throw new Error(`运行配置必须是无符号链接普通文件:${fileName}`);
|
||
}
|
||
let config;
|
||
try {
|
||
config = JSON.parse(await readFile(configPath, 'utf8'));
|
||
} catch (error) {
|
||
throw new Error(`解析运行配置失败:${fileName}:${error.message}`);
|
||
}
|
||
if (
|
||
config?.editorApi &&
|
||
Object.prototype.hasOwnProperty.call(config.editorApi, 'apiKey') &&
|
||
typeof config.editorApi.apiKey === 'string'
|
||
) {
|
||
configured = config.editorApi.apiKey.trim().length > 0;
|
||
}
|
||
}
|
||
return configured;
|
||
}
|
||
|
||
export async function openPreviewUrl(url, platform = process.platform) {
|
||
const validated = validatePreviewUrl(url);
|
||
const command =
|
||
platform === 'win32'
|
||
? 'cmd.exe'
|
||
: platform === 'darwin'
|
||
? 'open'
|
||
: 'xdg-open';
|
||
const args =
|
||
platform === 'win32'
|
||
? ['/d', '/s', '/c', 'start', '', validated]
|
||
: [validated];
|
||
await new Promise((resolve, reject) => {
|
||
const child = spawn(command, args, {
|
||
detached: true,
|
||
stdio: 'ignore',
|
||
windowsHide: true,
|
||
});
|
||
child.once('error', reject);
|
||
child.once('spawn', () => {
|
||
child.unref();
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runPreview(
|
||
projectPath,
|
||
openBrowser,
|
||
setActiveChild,
|
||
stopRequested,
|
||
) {
|
||
const child = spawnChild(
|
||
cargoCommand,
|
||
buildCargoCliArguments(['--preview-serve', projectPath]),
|
||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||
);
|
||
setActiveChild(child);
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
child.stdout.pipe(process.stdout);
|
||
child.stderr.pipe(process.stderr);
|
||
|
||
let pending = '';
|
||
let previewUrl = null;
|
||
let resolvePreviewUrl;
|
||
let rejectPreviewUrl;
|
||
const previewUrlReady = new Promise((resolve, reject) => {
|
||
resolvePreviewUrl = resolve;
|
||
rejectPreviewUrl = reject;
|
||
});
|
||
child.stdout.on('data', (chunk) => {
|
||
pending += chunk;
|
||
const lines = pending.split(/\r?\n/);
|
||
pending = lines.pop() ?? '';
|
||
for (const line of lines) {
|
||
if (!line.startsWith('previewUrl=')) continue;
|
||
try {
|
||
previewUrl = validatePreviewUrl(
|
||
line.slice('previewUrl='.length).trim(),
|
||
);
|
||
resolvePreviewUrl(previewUrl);
|
||
} catch (error) {
|
||
rejectPreviewUrl(error);
|
||
}
|
||
}
|
||
});
|
||
|
||
const exitPromise = childExit(child);
|
||
try {
|
||
exitPromise.then(({ code, signal }) => {
|
||
if (!previewUrl) {
|
||
rejectPreviewUrl(
|
||
new Error(
|
||
`预览进程提前退出:code=${code ?? ''} signal=${signal ?? ''}`,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
const url = await previewUrlReady;
|
||
console.log(`\n试玩地址:${url}`);
|
||
console.log('按 Ctrl+C 结束预览。');
|
||
if (openBrowser) {
|
||
await openPreviewUrl(url).catch((error) => {
|
||
console.warn(
|
||
`无法自动打开浏览器,请手动访问上面的地址:${error.message}`,
|
||
);
|
||
});
|
||
}
|
||
const result = await exitPromise;
|
||
if (!stopRequested() && (result.code !== 0 || result.signal)) {
|
||
throw new Error(
|
||
`预览进程异常退出:code=${result.code ?? ''} signal=${result.signal ?? ''}`,
|
||
);
|
||
}
|
||
} finally {
|
||
if (!closedChildren.has(child)) {
|
||
await terminateChildTreeAndWait(
|
||
child,
|
||
exitPromise,
|
||
'SIGINT',
|
||
'持续预览进程',
|
||
);
|
||
}
|
||
setActiveChild(null);
|
||
}
|
||
}
|
||
|
||
export async function runSwarmTestChat(options) {
|
||
let sourceConfigDir = null;
|
||
let runtimeConfig = null;
|
||
let project = null;
|
||
let activeChild = null;
|
||
let receivedSignal = null;
|
||
let timedOut = false;
|
||
let phase = 'setup';
|
||
let runnerMayHaveStarted = false;
|
||
let turnReport = null;
|
||
let primaryError = null;
|
||
let forceTerminationHandle = null;
|
||
const setActiveChild = (child) => {
|
||
activeChild = child;
|
||
};
|
||
const concurrentChildren = new Set();
|
||
const stopRequested = () => receivedSignal !== null;
|
||
const handleSignal = (signal) => {
|
||
const repeatedSignal = receivedSignal !== null;
|
||
receivedSignal ??= signal;
|
||
const targets = [activeChild, ...concurrentChildren].filter(Boolean);
|
||
if (targets.length === 0) return;
|
||
for (const target of targets) {
|
||
void terminateChildTree(target, signal, repeatedSignal).catch(() => {});
|
||
}
|
||
if (repeatedSignal) return;
|
||
forceTerminationHandle = setTimeout(() => {
|
||
for (const target of [activeChild, ...concurrentChildren].filter(
|
||
Boolean,
|
||
)) {
|
||
void terminateChildTree(target, 'SIGKILL', true).catch(() => {});
|
||
}
|
||
}, childTerminationGraceMs);
|
||
forceTerminationHandle.unref();
|
||
};
|
||
const clearForceTermination = () => {
|
||
if (forceTerminationHandle) {
|
||
clearTimeout(forceTerminationHandle);
|
||
forceTerminationHandle = null;
|
||
}
|
||
};
|
||
const timeoutMs = resolveSwarmTestTimeoutMs(options);
|
||
const timeoutDeadline = timeoutMs === null ? null : Date.now() + timeoutMs;
|
||
const timeoutHandle =
|
||
timeoutMs === null
|
||
? null
|
||
: setTimeout(() => {
|
||
timedOut = true;
|
||
console.error(
|
||
`agc:test:chat 已达到 ${options.timeoutMinutes ?? 50} 分钟执行期限,正在安全收束。`,
|
||
);
|
||
handleSignal('SIGTERM');
|
||
}, timeoutMs);
|
||
timeoutHandle?.unref();
|
||
process.on('SIGINT', handleSignal);
|
||
process.on('SIGTERM', handleSignal);
|
||
|
||
try {
|
||
session: {
|
||
try {
|
||
sourceConfigDir = await discoverRuntimeConfigDir(options.configDir);
|
||
} catch (error) {
|
||
if (!(await askToConfigureMissingRuntime())) throw error;
|
||
await runMissingConfigWizard(setActiveChild, options.configDir);
|
||
sourceConfigDir = await discoverRuntimeConfigDir(options.configDir);
|
||
}
|
||
runtimeConfig = await prepareSwarmTestRuntimeConfig(sourceConfigDir);
|
||
if (receivedSignal) break session;
|
||
project = await prepareSwarmTestProject(options.projectDir);
|
||
if (receivedSignal) break session;
|
||
|
||
console.log(`配置来源:${path.join(sourceConfigDir, configFileName)}`);
|
||
console.log(
|
||
`隔离运行配置:${path.join(runtimeConfig.path, configFileName)}`,
|
||
);
|
||
console.log(`测试项目:${project.path}`);
|
||
if (options.dryRun) {
|
||
console.log('测试环境检查通过;未启动 LLM。');
|
||
break session;
|
||
}
|
||
|
||
console.log('\n正在检查 LLM 配置...');
|
||
const llmStatus = await runCapturedCargo(
|
||
['--config-dir', runtimeConfig.path, '--llm-status'],
|
||
setActiveChild,
|
||
);
|
||
if (receivedSignal) break session;
|
||
if (llmStatus.code !== 0 || llmStatus.signal) {
|
||
throw new Error(
|
||
`LLM 配置未就绪:${llmStatus.stderr.trim() || llmStatus.stdout.trim()}`,
|
||
);
|
||
}
|
||
console.log('LLM 配置已就绪。');
|
||
console.log(
|
||
options.task
|
||
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
|
||
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
|
||
);
|
||
|
||
phase = 'chat';
|
||
runnerMayHaveStarted = true;
|
||
const chatArguments = [
|
||
'--config-dir',
|
||
runtimeConfig.path,
|
||
'--swarm-chat',
|
||
'--init',
|
||
'--autonomous-game-build',
|
||
project.path,
|
||
];
|
||
let chat;
|
||
try {
|
||
chat = options.task
|
||
? await runTaskCargo(
|
||
chatArguments,
|
||
options.task,
|
||
setActiveChild,
|
||
timeoutDeadline === null
|
||
? null
|
||
: Math.max(1, timeoutDeadline - Date.now()),
|
||
)
|
||
: await runInteractiveCargo(chatArguments, setActiveChild);
|
||
} catch (error) {
|
||
if (error?.code === 'AGC_CHILD_TIMEOUT') timedOut = true;
|
||
throw error;
|
||
}
|
||
if (receivedSignal) break session;
|
||
if (chat.code !== 0 || chat.signal) {
|
||
throw new Error(
|
||
`Agent Swarm 未正常收束:code=${chat.code ?? ''} signal=${chat.signal ?? ''}`,
|
||
);
|
||
}
|
||
if (options.task) {
|
||
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
|
||
}
|
||
const requireEditorImages = await hasConfiguredEditorApiKey(
|
||
runtimeConfig.path,
|
||
);
|
||
await validateSwarmProjectArtifacts(project.path, {
|
||
requireEditorImages,
|
||
parentRunId: turnReport?.parentRunId ?? null,
|
||
});
|
||
|
||
if (!shouldStartPersistentPreview(options)) {
|
||
phase = 'complete';
|
||
console.log(
|
||
`\n真实 Swarm 测试通过:正式产物${
|
||
requireEditorImages ? '及画布图片' : ''
|
||
}已验收,Runtime 已完成静态检查和双视口试玩。`,
|
||
);
|
||
break session;
|
||
}
|
||
|
||
phase = 'preview';
|
||
console.log('\nAgent Swarm 已收束,正在启动试玩...');
|
||
await runPreview(
|
||
project.path,
|
||
options.openBrowser,
|
||
setActiveChild,
|
||
stopRequested,
|
||
);
|
||
phase = 'complete';
|
||
}
|
||
} catch (error) {
|
||
primaryError = error;
|
||
} finally {
|
||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||
clearForceTermination();
|
||
let cleanupError = null;
|
||
let runnerStopped = true;
|
||
if (runtimeConfig && runnerMayHaveStarted) {
|
||
try {
|
||
runnerStopped = await shutdownSwarmTestRunner(
|
||
runtimeConfig,
|
||
setActiveChild,
|
||
);
|
||
if (!runnerStopped) {
|
||
cleanupError = new Error(
|
||
'隔离 Agent Runner 仍有任务,已保留测试项目和隔离运行配置',
|
||
);
|
||
}
|
||
} catch (error) {
|
||
runnerStopped = false;
|
||
cleanupError = error;
|
||
}
|
||
}
|
||
const preserveFailedRun =
|
||
project?.owned &&
|
||
(phase === 'chat' ||
|
||
phase === 'artifact-validation' ||
|
||
(phase === 'preview' && !receivedSignal));
|
||
const preserveForRunner = project?.owned && !runnerStopped;
|
||
if (
|
||
project?.owned &&
|
||
(options.keepProject || preserveFailedRun || preserveForRunner)
|
||
) {
|
||
if (preserveFailedRun && !options.keepProject) {
|
||
console.warn(
|
||
'测试尚未正常结束,为避免删除后台任务或失败证据,测试项目不会自动清理。',
|
||
);
|
||
}
|
||
console.log(`已保留测试项目:${project.path}`);
|
||
} else if (project?.owned) {
|
||
try {
|
||
await cleanupSwarmTestProject(project);
|
||
console.log('已清理一次性测试项目。');
|
||
} catch (error) {
|
||
cleanupError ??= error;
|
||
}
|
||
}
|
||
if (runtimeConfig) {
|
||
if (runnerStopped) {
|
||
try {
|
||
await cleanupSwarmTestRuntimeConfig(runtimeConfig);
|
||
console.log('已清理隔离运行配置。');
|
||
} catch (error) {
|
||
cleanupError ??= error;
|
||
}
|
||
} else {
|
||
console.warn(`已保留隔离运行配置:${runtimeConfig.path}`);
|
||
}
|
||
}
|
||
if (cleanupError) {
|
||
if (primaryError) {
|
||
console.warn(`测试现场清理未完成:${cleanupError.message}`);
|
||
} else {
|
||
primaryError = cleanupError;
|
||
}
|
||
}
|
||
process.off('SIGINT', handleSignal);
|
||
process.off('SIGTERM', handleSignal);
|
||
}
|
||
if (timedOut) {
|
||
if (primaryError) {
|
||
console.warn(`超时收束附带错误:${primaryError.message}`);
|
||
}
|
||
primaryError = Object.assign(new Error('真实 Swarm 测试超过内部执行期限'), {
|
||
exitCode: 124,
|
||
});
|
||
} else if (receivedSignal) {
|
||
if (primaryError) {
|
||
console.warn(`信号收束附带错误:${primaryError.message}`);
|
||
}
|
||
primaryError = Object.assign(
|
||
new Error(`真实 Swarm 测试收到 ${receivedSignal}`),
|
||
{ exitCode: receivedSignal === 'SIGINT' ? 130 : 143 },
|
||
);
|
||
}
|
||
if (primaryError) throw primaryError;
|
||
}
|
||
|
||
async function main() {
|
||
const options = parseSwarmTestArguments(process.argv.slice(2));
|
||
if (options.help) {
|
||
console.log(usage);
|
||
return;
|
||
}
|
||
await runSwarmTestChat(options);
|
||
}
|
||
|
||
const entryPath = process.argv[1]
|
||
? pathToFileURL(path.resolve(process.argv[1])).href
|
||
: '';
|
||
if (entryPath === import.meta.url) {
|
||
main().catch((error) => {
|
||
console.error(`agc:test:chat 失败:${error.message}`);
|
||
process.exitCode = Number.isInteger(error.exitCode) ? error.exitCode : 1;
|
||
});
|
||
}
|