feat: Page Data dev loop, adm Code Run UI, and customer order demo

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>
This commit is contained in:
john
2026-07-23 22:11:18 +08:00
parent 239c41f935
commit dab6140f99
22 changed files with 1795 additions and 14 deletions
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env node
/**
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verifyPhase 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 用户 IDworkspace 场景)',
' --cwd <dir> 覆盖 Aider 工作目录',
' --target-file <path> 可多次指定;未指定时用 preset 默认',
' --max-attempts <n> 最大修复轮次(默认 3)',
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 20=禁用)',
' --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_URLAider 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,
};