dab6140f99
Add verify→Aider→OpenHands dev loop, memindadm config/history UI, and john4 customer-order Page Data demo with scenario + verification scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verify(Phase 2)。
|
||
*
|
||
* Usage:
|
||
* node scripts/page-data-aider-dev-loop.mjs
|
||
* node scripts/page-data-aider-dev-loop.mjs --preset children-hobby-diet-survey
|
||
* node scripts/page-data-aider-dev-loop.mjs --preset page-data-platform --max-attempts 2
|
||
* node scripts/page-data-aider-dev-loop.mjs --dry-run
|
||
* node scripts/page-data-aider-dev-loop.mjs --verify-cmd "node scripts/verify-children-hobby-diet-survey.mjs --no-insert"
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { loadH5Environment } from './load-env.mjs';
|
||
import { createDbPool } from '../db.mjs';
|
||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||
import { createToolGateway } from '../tool-gateway.mjs';
|
||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||
import { CHILDREN_HOBBY_DIET_SURVEY } from './scenario-test-lib.mjs';
|
||
|
||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
|
||
export const PAGE_DATA_DEV_COMPLEX_TASK_TYPE = 'page_data_dev_complex';
|
||
|
||
export const VERIFY_PRESETS = Object.freeze({
|
||
'children-hobby-diet-survey': {
|
||
label: 'john4 儿童爱好问卷饮食偏好',
|
||
scenario: 'workspace',
|
||
defaultUserId: CHILDREN_HOBBY_DIET_SURVEY.userId,
|
||
buildVerifyArgs: (options) => [
|
||
'scripts/verify-children-hobby-diet-survey.mjs',
|
||
'--user-id',
|
||
options.userId,
|
||
...(options.testInsert === false ? ['--no-insert'] : []),
|
||
...(options.checkRuns === false ? ['--no-runs'] : []),
|
||
...(options.port ? ['--port', String(options.port)] : []),
|
||
],
|
||
defaultTargetFiles: () => [
|
||
`public/${CHILDREN_HOBBY_DIET_SURVEY.surveyHtml}`,
|
||
`public/${CHILDREN_HOBBY_DIET_SURVEY.adminHtml}`,
|
||
],
|
||
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
|
||
},
|
||
'page-data-platform': {
|
||
label: 'Memind Page Data 平台单测',
|
||
scenario: 'platform',
|
||
defaultUserId: null,
|
||
buildVerifyArgs: () => ['--test', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs'],
|
||
defaultTargetFiles: () => [
|
||
'page-data-public-service.mjs',
|
||
'page-data-service.mjs',
|
||
'public/assets/page-data-client.js',
|
||
],
|
||
verifyCommandLabel: 'npm run verify:page-data',
|
||
},
|
||
});
|
||
|
||
function printHelp() {
|
||
console.log([
|
||
'Usage:',
|
||
' node scripts/page-data-aider-dev-loop.mjs [options]',
|
||
'',
|
||
'Options:',
|
||
' --preset <name> children-hobby-diet-survey (default) | page-data-platform',
|
||
' --verify-cmd <shell> 自定义 verify 命令(覆盖 preset)',
|
||
' --user-id <uuid> workspace 用户 ID(workspace 场景)',
|
||
' --cwd <dir> 覆盖 Aider 工作目录',
|
||
' --target-file <path> 可多次指定;未指定时用 preset 默认',
|
||
' --max-attempts <n> 最大修复轮次(默认 3)',
|
||
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 2,0=禁用)',
|
||
' --executor <aider|openhands> 强制全程使用指定执行器',
|
||
' --no-insert 传给 preset verify',
|
||
' --no-runs 传给 preset verify(跳过 orphan run 检查)',
|
||
' --port <n> Portal 端口(默认 H5_PORT 或 8081)',
|
||
' --dry-run 只打印 instruction,不执行 Aider',
|
||
' --skip-initial-verify 跳过首轮 verify,直接跑 Aider(调试用)',
|
||
' -h, --help',
|
||
'',
|
||
'Requires: DATABASE_URL(Aider launch plan)、MEMIND_TOOL_GATEWAY_ENABLED=1(或 --dry-run)',
|
||
].join('\n'));
|
||
}
|
||
|
||
export function parseDevLoopArgs(argv, env = process.env) {
|
||
const options = {
|
||
preset: 'children-hobby-diet-survey',
|
||
verifyCmd: null,
|
||
userId: null,
|
||
cwd: null,
|
||
targetFiles: [],
|
||
maxAttempts: 3,
|
||
escalateAfter: 2,
|
||
executor: 'aider',
|
||
testInsert: true,
|
||
checkRuns: true,
|
||
port: Number(env.H5_PORT ?? 8081),
|
||
dryRun: false,
|
||
skipInitialVerify: false,
|
||
help: false,
|
||
};
|
||
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const arg = argv[i];
|
||
if (arg === '-h' || arg === '--help') options.help = true;
|
||
else if (arg === '--preset' && argv[i + 1]) options.preset = argv[++i];
|
||
else if (arg === '--verify-cmd' && argv[i + 1]) options.verifyCmd = argv[++i];
|
||
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
|
||
else if (arg === '--cwd' && argv[i + 1]) options.cwd = path.resolve(argv[++i]);
|
||
else if (arg === '--target-file' && argv[i + 1]) options.targetFiles.push(argv[++i]);
|
||
else if (arg === '--max-attempts' && argv[i + 1]) options.maxAttempts = Math.max(1, Number(argv[++i]) || 3);
|
||
else if (arg === '--escalate-after' && argv[i + 1]) {
|
||
options.escalateAfter = Math.max(0, Number(argv[++i]) || 0);
|
||
}
|
||
else if (arg === '--executor' && argv[i + 1]) options.executor = String(argv[++i]).trim().toLowerCase();
|
||
else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
|
||
else if (arg === '--no-insert') options.testInsert = false;
|
||
else if (arg === '--no-runs') options.checkRuns = false;
|
||
else if (arg === '--dry-run') options.dryRun = true;
|
||
else if (arg === '--skip-initial-verify') options.skipInitialVerify = true;
|
||
else throw new Error(`未知参数: ${arg}`);
|
||
}
|
||
|
||
return options;
|
||
}
|
||
|
||
export function resolveVerifyPreset(presetName) {
|
||
const preset = VERIFY_PRESETS[presetName];
|
||
if (!preset) {
|
||
throw new Error(`未知 preset: ${presetName}(可选: ${Object.keys(VERIFY_PRESETS).join(', ')})`);
|
||
}
|
||
return preset;
|
||
}
|
||
|
||
export function resolveWorkspaceCwd({ repoRoot: root, userId, cwdOverride, scenario }) {
|
||
if (cwdOverride) return cwdOverride;
|
||
if (scenario === 'platform') return root;
|
||
if (!userId) {
|
||
throw new Error('workspace 场景需要 --user-id 或 preset 默认 userId');
|
||
}
|
||
return path.join(root, PUBLISH_ROOT_DIR, userId);
|
||
}
|
||
|
||
export function resolveDevLoopExecutor(
|
||
attempt,
|
||
{ executor = 'aider', escalateAfter = 2 } = {},
|
||
) {
|
||
if (executor !== 'aider') {
|
||
return {
|
||
executor,
|
||
taskType: executor === 'openhands' ? PAGE_DATA_DEV_COMPLEX_TASK_TYPE : PAGE_DATA_DEV_TASK_TYPE,
|
||
escalated: false,
|
||
};
|
||
}
|
||
if (escalateAfter > 0 && attempt > escalateAfter) {
|
||
return {
|
||
executor: 'openhands',
|
||
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||
escalated: true,
|
||
};
|
||
}
|
||
return {
|
||
executor: 'aider',
|
||
taskType: PAGE_DATA_DEV_TASK_TYPE,
|
||
escalated: false,
|
||
};
|
||
}
|
||
|
||
export function buildPageDataDevInstruction({
|
||
verifyOutput,
|
||
cwd,
|
||
taskType = PAGE_DATA_DEV_TASK_TYPE,
|
||
targetFiles = [],
|
||
verifyCommandLabel = 'verify script',
|
||
escalated = false,
|
||
}) {
|
||
const targets = targetFiles.length
|
||
? targetFiles.map((item) => `- ${item}`).join('\n')
|
||
: '- public/*.html\n- .mindspace/page-data-policies/*.json';
|
||
|
||
return [
|
||
'[Page Data 开发修复任务]',
|
||
escalated ? '[Escalation] 前序 Aider 修复未通过 verify,现由 OpenHands 接手。' : '',
|
||
'',
|
||
`工作目录: ${cwd}`,
|
||
`taskType: ${taskType}`,
|
||
'',
|
||
'验证失败输出(必须原样附上):',
|
||
String(verifyOutput ?? '').trim() || '(verify 未返回输出)',
|
||
'',
|
||
'目标文件(仅可改这些):',
|
||
targets,
|
||
'',
|
||
'约束:',
|
||
'- 必须使用 /assets/page-data-client.js 公开 API',
|
||
'- 禁止 localStorage / sessionStorage / IndexedDB',
|
||
'- 禁止自建 Express 或独立后端',
|
||
'- 列名必须与 dataset register 的 insert/read 白名单一致',
|
||
'- 不要修改 Memind 平台源码(workspace 场景)',
|
||
'- 不要调用 private_data_execute 或重建 dataset(除非 verify 明确报 table_not_found)',
|
||
'',
|
||
'验收:',
|
||
`- 修改后应能通过: ${verifyCommandLabel}`,
|
||
'- 完成后简要说明改了哪些文件、为何能修复 verify 失败项',
|
||
].join('\n');
|
||
}
|
||
|
||
function runVerifyCommand({ command, args, cwd, env }) {
|
||
const result = spawnSync(command, args, {
|
||
cwd,
|
||
env,
|
||
encoding: 'utf8',
|
||
maxBuffer: 16 * 1024 * 1024,
|
||
});
|
||
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
||
return {
|
||
ok: result.status === 0,
|
||
status: result.status,
|
||
output: output || `(exit ${result.status ?? 'unknown'}, no output)`,
|
||
commandLine: `${command} ${args.join(' ')}`.trim(),
|
||
};
|
||
}
|
||
|
||
function resolveVerifyRun(options, preset) {
|
||
if (options.verifyCmd) {
|
||
return {
|
||
label: options.verifyCmd,
|
||
...runVerifyCommand({
|
||
command: '/bin/sh',
|
||
args: ['-lc', options.verifyCmd],
|
||
cwd: repoRoot,
|
||
env: process.env,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const userId = options.userId ?? preset.defaultUserId;
|
||
if (preset.scenario === 'workspace') {
|
||
options.userId = userId;
|
||
}
|
||
|
||
const verifyArgs = preset.buildVerifyArgs({
|
||
userId,
|
||
testInsert: options.testInsert,
|
||
checkRuns: options.checkRuns,
|
||
port: options.port,
|
||
});
|
||
|
||
const useNpmForPlatform = preset.scenario === 'platform'
|
||
&& verifyArgs[0] === '--test';
|
||
|
||
if (useNpmForPlatform) {
|
||
return {
|
||
label: preset.verifyCommandLabel,
|
||
...runVerifyCommand({
|
||
command: 'npm',
|
||
args: ['run', 'verify:page-data'],
|
||
cwd: repoRoot,
|
||
env: process.env,
|
||
}),
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: preset.verifyCommandLabel,
|
||
...runVerifyCommand({
|
||
command: process.execPath,
|
||
args: verifyArgs,
|
||
cwd: repoRoot,
|
||
env: process.env,
|
||
}),
|
||
};
|
||
}
|
||
|
||
async function runCodeExecutorFix({
|
||
instruction,
|
||
cwd,
|
||
executor,
|
||
taskType,
|
||
dryRun,
|
||
llmProviderService,
|
||
}) {
|
||
const previousGatewayFlag = process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||
if (!dryRun) {
|
||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = '1';
|
||
}
|
||
const gateway = createToolGateway({ llmProviderService, env: process.env });
|
||
const status = gateway.getStatus();
|
||
if (!status.enabled && !dryRun) {
|
||
throw new Error('MEMIND_TOOL_GATEWAY_ENABLED 未开启;请设 1 或使用 --dry-run');
|
||
}
|
||
|
||
const userMessage = {
|
||
role: 'user',
|
||
content: [{ type: 'text', text: instruction }],
|
||
metadata: {
|
||
memindRun: {
|
||
executor,
|
||
taskType,
|
||
},
|
||
},
|
||
};
|
||
|
||
try {
|
||
return await gateway.executeJob({
|
||
runId: `dev-loop-${Date.now()}`,
|
||
requestId: `dev-loop-${Date.now()}`,
|
||
userId: 'dev-loop',
|
||
userMessage,
|
||
taskType,
|
||
cwd,
|
||
timeoutMs: Number(process.env.MEMIND_PAGE_DATA_DEV_LOOP_TIMEOUT_MS ?? 20 * 60 * 1000),
|
||
});
|
||
} finally {
|
||
if (previousGatewayFlag == null) {
|
||
delete process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||
} else {
|
||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = previousGatewayFlag;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
loadH5Environment(import.meta.dirname);
|
||
const options = parseDevLoopArgs(process.argv.slice(2));
|
||
if (options.help) {
|
||
printHelp();
|
||
process.exit(0);
|
||
}
|
||
|
||
const preset = resolveVerifyPreset(options.preset);
|
||
const userId = options.userId ?? preset.defaultUserId;
|
||
const workspaceCwd = resolveWorkspaceCwd({
|
||
repoRoot,
|
||
userId,
|
||
cwdOverride: options.cwd,
|
||
scenario: preset.scenario,
|
||
});
|
||
|
||
if (preset.scenario === 'workspace' && !fs.existsSync(workspaceCwd)) {
|
||
throw new Error(`workspace 不存在: ${workspaceCwd}`);
|
||
}
|
||
|
||
const targetFiles = options.targetFiles.length
|
||
? options.targetFiles
|
||
: preset.defaultTargetFiles();
|
||
|
||
console.log('==> Page Data Aider dev loop');
|
||
console.log(` preset: ${options.preset} (${preset.label})`);
|
||
console.log(` cwd: ${workspaceCwd}`);
|
||
console.log(` max attempts: ${options.maxAttempts}`);
|
||
console.log(` escalate after: ${options.escalateAfter} aider round(s)`);
|
||
console.log(` dry-run: ${options.dryRun ? 'yes' : 'no'}\n`);
|
||
|
||
let pool = null;
|
||
let llmProviderService = null;
|
||
if (!options.dryRun) {
|
||
pool = createDbPool();
|
||
llmProviderService = createLlmProviderService(pool, {
|
||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||
});
|
||
}
|
||
|
||
const shutdown = async () => {
|
||
if (pool) await pool.end().catch(() => {});
|
||
};
|
||
|
||
try {
|
||
let verifyResult = options.skipInitialVerify
|
||
? { ok: false, output: '(跳过首轮 verify)', label: options.verifyCmd ?? preset.verifyCommandLabel }
|
||
: null;
|
||
|
||
if (!options.skipInitialVerify) {
|
||
console.log('--- 首轮 verify ---');
|
||
verifyResult = resolveVerifyRun(options, preset);
|
||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||
if (verifyResult.ok) {
|
||
console.log('\n✔ verify 已通过,无需 Aider 修复');
|
||
await shutdown();
|
||
process.exit(0);
|
||
}
|
||
console.log(verifyResult.output.slice(-8000));
|
||
}
|
||
|
||
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
||
const execution = resolveDevLoopExecutor(attempt, {
|
||
executor: options.executor,
|
||
escalateAfter: options.escalateAfter,
|
||
});
|
||
const instruction = buildPageDataDevInstruction({
|
||
verifyOutput: verifyResult.output,
|
||
cwd: workspaceCwd,
|
||
targetFiles,
|
||
verifyCommandLabel: verifyResult.label,
|
||
taskType: execution.taskType,
|
||
escalated: execution.escalated,
|
||
});
|
||
|
||
console.log(`\n--- 修复第 ${attempt}/${options.maxAttempts} 轮 (${execution.executor}) ---`);
|
||
console.log(instruction.slice(0, 2000));
|
||
if (instruction.length > 2000) {
|
||
console.log(`... (${instruction.length - 2000} more chars)`);
|
||
}
|
||
|
||
if (options.dryRun) {
|
||
console.log('\n(dry-run: 未执行 Aider)');
|
||
await shutdown();
|
||
process.exit(0);
|
||
}
|
||
|
||
console.log(`\n--- 执行 ${execution.executor} ---`);
|
||
const result = await runCodeExecutorFix({
|
||
instruction,
|
||
cwd: workspaceCwd,
|
||
executor: execution.executor,
|
||
taskType: execution.taskType,
|
||
dryRun: false,
|
||
llmProviderService,
|
||
});
|
||
console.log(`${execution.executor} 完成: exit=${result.exitCode ?? 0} taskType=${execution.taskType}`);
|
||
if (result.stderr) {
|
||
console.log(result.stderr.slice(-2000));
|
||
}
|
||
|
||
console.log('\n--- 重跑 verify ---');
|
||
verifyResult = resolveVerifyRun(options, preset);
|
||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||
if (verifyResult.ok) {
|
||
console.log(`\n✔ 第 ${attempt} 轮修复后 verify 通过`);
|
||
await shutdown();
|
||
process.exit(0);
|
||
}
|
||
console.log(verifyResult.output.slice(-4000));
|
||
}
|
||
|
||
console.error(`\n✘ ${options.maxAttempts} 轮修复后 verify 仍未通过`);
|
||
await shutdown();
|
||
process.exit(1);
|
||
} catch (error) {
|
||
await shutdown();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||
if (isMain) {
|
||
main().catch((error) => {
|
||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
|
||
export const pageDataAiderDevLoopInternals = {
|
||
PAGE_DATA_DEV_TASK_TYPE,
|
||
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||
VERIFY_PRESETS,
|
||
};
|