52d47d5470
保留 Agent Runtime、Runner、项目与测试的模块化拆分 吸收跨平台启动与本地开发栈稳定性修复 补齐 Gitea CI 门禁与异步测试稳定性改进 统一前端 Runtime 水合、策略读取与状态重置语义
423 lines
13 KiB
JavaScript
423 lines
13 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
const repoRoot = path.resolve(appRoot, '../..');
|
|
const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml');
|
|
const agentId = 'code-prototype';
|
|
const runId = `steer-real-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
const projectRoot = path.join(os.tmpdir(), `genarrative-${runId}`);
|
|
const marker = `STEER_REAL_${randomUUID().replaceAll('-', '').toUpperCase()}`;
|
|
const instruction = `运行中补充:继续保持只读,不调用写入、命令、预览或生成工具;最终回复必须原样包含 ${marker},并明确说明已按追加指令更新。`;
|
|
const timeoutMs = 8 * 60 * 1000;
|
|
const pollMs = 200;
|
|
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const evidence = {
|
|
providerInterrupted: false,
|
|
steerAttempts: 0,
|
|
taskRunIds: [],
|
|
steerStatuses: [],
|
|
userMessageCount: 0,
|
|
assistantMessageCount: 0,
|
|
publicInstructionLeakCount: 0,
|
|
loadedKeyLeakCount: 0,
|
|
};
|
|
let status = 'FAIL';
|
|
let error = null;
|
|
let binary = null;
|
|
|
|
try {
|
|
const config = await loadConfig(options.configDir);
|
|
const secrets = collectSecrets(config);
|
|
assert(isAgentConfigured(config, agentId), 'llm-not-configured');
|
|
binary = await prepareCliBinary();
|
|
await runCli(
|
|
[
|
|
'--agent-enqueue',
|
|
'--init',
|
|
projectRoot,
|
|
agentId,
|
|
runId,
|
|
'这是只读验收。不要修改文件、不要执行命令、不要预览或生成资产;请仔细分析后给出一句简短结论。',
|
|
],
|
|
null,
|
|
120_000,
|
|
);
|
|
|
|
const initial = await waitForRuntime(
|
|
(runtime) => runtime.state.runId === runId && isSteerable(runtime.state),
|
|
60_000,
|
|
);
|
|
const sessionId = initial.state.sessionId;
|
|
assert(sessionId, 'missing-session-id');
|
|
|
|
let latestSteerId = null;
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
const steerId = `steer-real-${attempt}-${randomUUID().slice(0, 8)}`;
|
|
const result = await runCli(
|
|
[
|
|
'--agent-steer',
|
|
projectRoot,
|
|
agentId,
|
|
sessionId,
|
|
runId,
|
|
steerId,
|
|
'--stdin',
|
|
],
|
|
instruction,
|
|
120_000,
|
|
);
|
|
assert(!result.stdout.includes(instruction), 'steer-body-stdout-leak');
|
|
const steer = parsePrefixedJson(result.stdout, 'steerJson=');
|
|
evidence.steerAttempts = attempt;
|
|
latestSteerId = steerId;
|
|
evidence.providerInterrupted ||= steer.providerInterrupted === true;
|
|
if (evidence.providerInterrupted) break;
|
|
await waitForRuntime(
|
|
(runtime) =>
|
|
runtime.state.runId === runId &&
|
|
Number(runtime.state.appliedSteerCursor ?? 0) >= attempt &&
|
|
isSteerable(runtime.state),
|
|
60_000,
|
|
);
|
|
}
|
|
assert(latestSteerId, 'steer-not-accepted');
|
|
assert(evidence.providerInterrupted, 'provider-not-interrupted');
|
|
|
|
const terminal = await waitForRuntime(
|
|
(runtime) =>
|
|
runtime.recentTasks?.some(
|
|
(task) => task.runId === runId && isTerminalTask(task),
|
|
) === true,
|
|
timeoutMs,
|
|
);
|
|
const terminalTask = terminal.recentTasks.find(
|
|
(task) => task.runId === runId && isTerminalTask(task),
|
|
);
|
|
assert(terminalTask?.status === 'completed', 'steered-run-not-completed');
|
|
|
|
const taskRecords = await readJsonl(
|
|
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
|
|
);
|
|
evidence.taskRunIds = [...new Set(taskRecords.map((record) => record.runId))];
|
|
assert(
|
|
evidence.taskRunIds.length === 1 && evidence.taskRunIds[0] === runId,
|
|
'steer-created-new-run',
|
|
);
|
|
assert(
|
|
!taskRecords.some((record) => JSON.stringify(record).includes(instruction)),
|
|
'steer-body-task-leak',
|
|
);
|
|
|
|
const steerRecords = await readJsonl(
|
|
path.join(projectRoot, '.agent/runtime/steers', agentId, `${runId}.jsonl`),
|
|
);
|
|
evidence.steerStatuses = steerRecords.map((record) => record.status);
|
|
assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing');
|
|
assert(evidence.steerStatuses.includes('queued'), 'steer-queued-missing');
|
|
assert(evidence.steerStatuses.includes('applied'), 'steer-applied-missing');
|
|
assert(evidence.steerStatuses.at(-1) === 'closed', 'steer-ledger-not-closed');
|
|
assert(
|
|
steerRecords
|
|
.filter((record) => record.status === 'prepared')
|
|
.every((record) => typeof record.instruction === 'string'),
|
|
'steer-prepared-body-missing',
|
|
);
|
|
assert(
|
|
steerRecords
|
|
.filter((record) => record.status !== 'prepared')
|
|
.every((record) => record.instruction == null),
|
|
'steer-body-transition-leak',
|
|
);
|
|
|
|
const conversationPath =
|
|
sessionId === `agent-session-${agentId}`
|
|
? path.join(
|
|
projectRoot,
|
|
'.agent/conversations/agents',
|
|
`${agentId}.jsonl`,
|
|
)
|
|
: path.join(
|
|
projectRoot,
|
|
'.agent/conversations/agents',
|
|
agentId,
|
|
'sessions',
|
|
`${sessionId}.jsonl`,
|
|
);
|
|
const messages = await readJsonl(conversationPath);
|
|
evidence.userMessageCount = messages.filter(
|
|
(message) => message.role === 'user',
|
|
).length;
|
|
evidence.assistantMessageCount = messages.filter(
|
|
(message) => message.role === 'assistant',
|
|
).length;
|
|
assert(
|
|
evidence.userMessageCount === evidence.steerAttempts + 1,
|
|
'steer-user-message-count-invalid',
|
|
);
|
|
assert(evidence.assistantMessageCount === 1, 'assistant-count-invalid');
|
|
const assistant = messages.find((message) => message.role === 'assistant');
|
|
assert(assistant?.content.includes(marker), 'final-assistant-missing-marker');
|
|
|
|
const publicPaths = [
|
|
path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`),
|
|
path.join(projectRoot, '.agent/runtime/events', `${agentId}.jsonl`),
|
|
path.join(projectRoot, '.agent/runtime/agents', `${agentId}.json`),
|
|
path.join(projectRoot, '.agent/agent.db'),
|
|
];
|
|
evidence.publicInstructionLeakCount = await countNeedleInFiles(
|
|
publicPaths,
|
|
instruction,
|
|
);
|
|
assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak');
|
|
evidence.loadedKeyLeakCount = await countNeedlesInTree(projectRoot, secrets);
|
|
assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak');
|
|
status = 'PASS';
|
|
} catch (caught) {
|
|
error = caught instanceof Error ? caught.message : String(caught);
|
|
if (error === 'llm-not-configured') status = 'BLOCKED';
|
|
} finally {
|
|
if (!options.keepProject) {
|
|
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
}
|
|
process.stdout.write(
|
|
`${JSON.stringify(
|
|
{
|
|
status,
|
|
suite: 'steer',
|
|
runId,
|
|
projectKept: options.keepProject,
|
|
evidence,
|
|
error,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
process.exitCode = status === 'PASS' ? 0 : status === 'BLOCKED' ? 2 : 1;
|
|
}
|
|
|
|
function parseArguments(args) {
|
|
let configDir = null;
|
|
let keepProject = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
if (args[index] === '--config-dir') {
|
|
configDir = args[++index];
|
|
} else if (args[index] === '--keep-project') {
|
|
keepProject = true;
|
|
} else {
|
|
throw new Error(`unknown-argument:${args[index]}`);
|
|
}
|
|
}
|
|
assert(configDir && path.isAbsolute(configDir), 'config-dir-not-absolute');
|
|
const resolved = path.resolve(configDir);
|
|
assert(!isInside(repoRoot, resolved), 'config-dir-inside-repository');
|
|
return { configDir: resolved, keepProject };
|
|
}
|
|
|
|
async function loadConfig(configDir) {
|
|
const content = await fs.readFile(
|
|
path.join(configDir, 'game-creator.config.json'),
|
|
'utf8',
|
|
);
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
function isAgentConfigured(config, targetAgentId) {
|
|
const effective = {
|
|
apiKey: config.llm?.apiKey,
|
|
baseUrl: config.llm?.baseUrl,
|
|
model: config.llm?.model,
|
|
...(config.agentLlm?.[targetAgentId] ?? {}),
|
|
};
|
|
return ['apiKey', 'baseUrl', 'model'].every(
|
|
(key) => typeof effective[key] === 'string' && effective[key].trim(),
|
|
);
|
|
}
|
|
|
|
function collectSecrets(config) {
|
|
const secrets = [];
|
|
for (const candidate of [
|
|
config.llm?.apiKey,
|
|
config.editorApi?.apiKey,
|
|
...Object.values(config.agentLlm ?? {}).map((agent) => agent?.apiKey),
|
|
]) {
|
|
if (typeof candidate === 'string' && candidate.trim().length >= 8) {
|
|
secrets.push(candidate.trim());
|
|
}
|
|
}
|
|
return [...new Set(secrets)];
|
|
}
|
|
|
|
async function prepareCliBinary() {
|
|
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
|
|
await runProcess(
|
|
cargo,
|
|
['build', '--quiet', '--manifest-path', manifestPath],
|
|
null,
|
|
15 * 60 * 1000,
|
|
);
|
|
const metadata = await runProcess(
|
|
cargo,
|
|
[
|
|
'metadata',
|
|
'--format-version',
|
|
'1',
|
|
'--no-deps',
|
|
'--manifest-path',
|
|
manifestPath,
|
|
],
|
|
null,
|
|
120_000,
|
|
);
|
|
const parsed = JSON.parse(metadata.stdout);
|
|
return path.join(
|
|
parsed.target_directory,
|
|
'debug',
|
|
`genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`,
|
|
);
|
|
}
|
|
|
|
async function runCli(args, input = null, commandTimeoutMs = 60_000) {
|
|
return runProcess(
|
|
binary,
|
|
[...args, '--config-dir', options.configDir],
|
|
input,
|
|
commandTimeoutMs,
|
|
);
|
|
}
|
|
|
|
async function waitForRuntime(predicate, waitMs) {
|
|
const deadline = Date.now() + waitMs;
|
|
let latest = null;
|
|
while (Date.now() < deadline) {
|
|
const result = await runCli([
|
|
'--agent-runtime-status',
|
|
projectRoot,
|
|
agentId,
|
|
]);
|
|
latest = parsePrefixedJson(result.stdout, 'runtimeJson=');
|
|
if (predicate(latest)) return latest;
|
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
}
|
|
throw new Error(
|
|
`runtime-timeout:${latest?.state?.status ?? 'missing'}/${latest?.state?.phase ?? 'missing'}`,
|
|
);
|
|
}
|
|
|
|
function isSteerable(runtime) {
|
|
return (
|
|
['running', 'waiting-for-confirmation'].includes(runtime.status) &&
|
|
!['cancelling', 'finalizing', 'needs-reconciliation'].includes(
|
|
runtime.phase,
|
|
)
|
|
);
|
|
}
|
|
|
|
function isTerminalTask(task) {
|
|
return ['completed', 'failed', 'cancelled'].includes(task.status);
|
|
}
|
|
|
|
function parsePrefixedJson(stdout, prefix) {
|
|
const line = stdout
|
|
.split(/\r?\n/u)
|
|
.find((candidate) => candidate.startsWith(prefix));
|
|
assert(line, `missing-output:${prefix}`);
|
|
return JSON.parse(line.slice(prefix.length));
|
|
}
|
|
|
|
async function readJsonl(file) {
|
|
const content = await fs.readFile(file, 'utf8');
|
|
return content
|
|
.split(/\r?\n/u)
|
|
.filter((line) => line.trim())
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
async function countNeedleInFiles(files, needle) {
|
|
let count = 0;
|
|
for (const file of files) {
|
|
const content = await fs.readFile(file).catch(() => Buffer.alloc(0));
|
|
count += countNeedle(content, Buffer.from(needle));
|
|
}
|
|
return count;
|
|
}
|
|
|
|
async function countNeedlesInTree(root, needles) {
|
|
let count = 0;
|
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const target = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
count += await countNeedlesInTree(target, needles);
|
|
} else if (entry.isFile()) {
|
|
const content = await fs.readFile(target);
|
|
for (const needle of needles)
|
|
count += countNeedle(content, Buffer.from(needle));
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function countNeedle(haystack, needle) {
|
|
if (needle.length === 0) return 0;
|
|
let count = 0;
|
|
let offset = 0;
|
|
while (offset <= haystack.length - needle.length) {
|
|
const index = haystack.indexOf(needle, offset);
|
|
if (index < 0) break;
|
|
count += 1;
|
|
offset = index + needle.length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function runProcess(program, args, input, commandTimeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(program, args, {
|
|
cwd: appRoot,
|
|
env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
}, commandTimeoutMs);
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout += chunk.toString('utf8');
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString('utf8');
|
|
});
|
|
child.on('error', reject);
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(timer);
|
|
if (code === 0) {
|
|
resolve({ stdout, stderr });
|
|
} else {
|
|
reject(new Error(`command-failed:${code ?? signal}:${stderr.trim()}`));
|
|
}
|
|
});
|
|
if (input === null) child.stdin.end();
|
|
else child.stdin.end(input);
|
|
});
|
|
}
|
|
|
|
function isInside(parent, target) {
|
|
const relative = path.relative(parent, target);
|
|
return (
|
|
relative === '' ||
|
|
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
function assert(condition, code) {
|
|
if (!condition) throw new Error(code);
|
|
}
|