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, secureWindowsPath = null, ) { if (secureWindowsPath) { await secureWindowsPath(targetPath, options); return; } const { secureWindowsGameCreatorPathForCurrentUser } = await import( './game-creator-config-wizard.mjs' ); await secureWindowsGameCreatorPathForCurrentUser(targetPath, options); } async function copyPrivateRuntimeConfigEntry( sourceConfigDir, runtimeConfigDir, fileName, required, secureWindowsPath = null, ) { 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 }, secureWindowsPath, ); 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, { secureWindowsPath = null } = {}, ) { 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 }, secureWindowsPath, ); } 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, secureWindowsPath, ); await copyPrivateRuntimeConfigEntry( canonicalSourceConfigDir, runtimeConfigDir, localConfigFileName, false, secureWindowsPath, ); 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 || !/]*>[\s\S]*<\/html>/iu.test(content) || !/ 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; }); }