#!/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 children-hobby-diet-survey (default) | page-data-platform', ' --verify-cmd 自定义 verify 命令(覆盖 preset)', ' --user-id workspace 用户 ID(workspace 场景)', ' --cwd 覆盖 Aider 工作目录', ' --target-file 可多次指定;未指定时用 preset 默认', ' --max-attempts 最大修复轮次(默认 3)', ' --escalate-after Aider 连续失败 N 轮后升级 OpenHands(默认 2,0=禁用)', ' --executor 强制全程使用指定执行器', ' --no-insert 传给 preset verify', ' --no-runs 传给 preset verify(跳过 orphan run 检查)', ' --port 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, };