merge: feature/page-data-dev-aider into main

Bring in Page Data Aider dev loop, adm code-run policy UI, and customer order demo.
Keep system disclosure policy and agent code-run admin wiring side by side.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-24 14:42:25 +08:00
39 changed files with 3295 additions and 47 deletions
+3
View File
@@ -32,9 +32,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# Code-tool agent run 灰度开关(默认关闭)。
# 后端必须先开启 MEMIND_AGENT_CODE_RUNS_ENABLED,H5 构建开关才会生效。
# 开启后,页面编辑子聊天会传 tool_mode=code;普通聊天仍需同时开启 AUTODETECT 才会按文本自动识别代码任务。
# Phase 1.5:memindadm 运行时策略优先于 VITE_;设为 env 可强制只读 env(紧急 override)。
# MEMIND_CODE_RUN_POLICY_SOURCE=env
# MEMIND_AGENT_CODE_RUNS_ENABLED=0
# VITE_AGENT_CODE_RUNS_ENABLED=0
# VITE_AGENT_CODE_RUNS_AUTODETECT=0
# VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=0
# Tool Gateway Queue v0(/agent/runs 后台任务队列,默认保守限流)。
# MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1
+3
View File
@@ -21,6 +21,7 @@ import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
import { createMindSearchConfigService } from './mindsearch-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
@@ -116,6 +117,7 @@ export async function createAdminServices(env = {}) {
autoRefresh: false,
});
await systemDisclosurePolicyService.initialize();
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
pool,
@@ -158,6 +160,7 @@ export async function createAdminServices(env = {}) {
mindSearchConfigService,
skillRuntimeConfigService,
systemDisclosurePolicyService,
agentCodeRunPolicyService,
wechatScheduleLlmConfigService,
adminSystemTestService,
plazaPosts,
+44
View File
@@ -33,6 +33,7 @@ function plazaRouteError(res, req, error) {
* @param {object|null} deps.memoryV2ConfigService
* @param {object|null} deps.skillRuntimeConfigService
* @param {object|null} deps.systemDisclosurePolicyService
* @param {object|null} deps.agentCodeRunPolicyService
* @param {object|null} deps.adminSystemTestService
* @param {object|null} deps.plazaPosts
* @param {object|null} deps.plazaOps
@@ -50,6 +51,7 @@ export function createAdminApi({
mindSearchConfigService,
skillRuntimeConfigService,
systemDisclosurePolicyService,
agentCodeRunPolicyService,
adminSystemTestService,
plazaPosts,
plazaOps,
@@ -286,6 +288,48 @@ export function createAdminApi({
return res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
});
adminApi.get('/agent-code-run/config', requireAdmin, async (_req, res) => {
if (!agentCodeRunPolicyService?.getAdminConfig) {
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
}
return res.json(await agentCodeRunPolicyService.getAdminConfig());
});
const updateAgentCodeRunConfig = async (req, res) => {
if (!agentCodeRunPolicyService?.updateAdminConfig) {
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
}
try {
const result = await agentCodeRunPolicyService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
updatedBy: req.currentUser.id,
});
return res.json(result);
} catch (err) {
if (err?.code === 'CODE_RUN_POLICY_ENV_LOCKED') {
return res.status(409).json({ message: err.message });
}
throw err;
}
};
adminApi.put('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig);
adminApi.patch('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig);
adminApi.get('/agent-code-run/runtime', requireAdmin, async (_req, res) => {
if (!agentCodeRunPolicyService?.getRuntimeState) {
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
}
return res.json(await agentCodeRunPolicyService.getRuntimeState());
});
adminApi.get('/agent-code-run/runs', requireAdmin, async (req, res) => {
if (!agentCodeRunPolicyService?.listRecentPageDataDevRuns) {
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
}
const limit = Number(req.query?.limit ?? 50);
return res.json(await agentCodeRunPolicyService.listRecentPageDataDevRuns({ limit }));
});
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
if (!adminSystemTestService?.runSkillValidation) {
return res.status(503).json({ message: '系统测试服务未启用' });
+2
View File
@@ -80,7 +80,9 @@ const CONSOLES = {
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
memoryV2ConfigService: services.memoryV2ConfigService,
mindSearchConfigService: services.mindSearchConfigService,
skillRuntimeConfigService: services.skillRuntimeConfigService,
systemDisclosurePolicyService: services.systemDisclosurePolicyService,
agentCodeRunPolicyService: services.agentCodeRunPolicyService,
adminSystemTestService: services.adminSystemTestService,
plazaPosts: services.plazaPosts,
plazaOps: services.plazaOps,
+366
View File
@@ -0,0 +1,366 @@
const CONFIG_TABLE = 'h5_agent_code_run_config';
const CONFIG_SCOPE = 'global';
const POLICY_SOURCE_ENV = 'env';
const POLICY_SOURCE_ENV_OVERRIDE = 'env-override';
const POLICY_SOURCE_ENV_MIGRATION = 'env-migration';
const POLICY_SOURCE_ADMIN_DB = 'admin-db';
const POLICY_SOURCE_DEFAULT = 'default';
const DEFAULT_TASK_TYPES = Object.freeze([
'page_data_dev',
'page_data_dev_complex',
'h5_chat_code_task',
'page_edit_code_task',
]);
function defaultConfigShape() {
return {
codeRun: {
enabled: false,
clientEnabled: false,
generalAutodetect: false,
requireValidation: true,
userAllowlist: [],
taskTypeAllowlist: [...DEFAULT_TASK_TYPES],
},
pageDataDev: {
autodetect: false,
},
meta: {
notes: '',
},
};
}
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function normalizeStringList(value) {
if (!Array.isArray(value)) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return value.map((item) => String(item ?? '').trim()).filter(Boolean);
}
function cloneConfig(config = null) {
return structuredClone?.(config ?? defaultConfigShape())
?? JSON.parse(JSON.stringify(config ?? defaultConfigShape()));
}
function parseJsonLike(value, fallback) {
if (value == null || value === '') return fallback;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
if (typeof value === 'object') return value;
return fallback;
}
function policySourceFromEnv(env = process.env) {
return String(env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env'
? POLICY_SOURCE_ENV_OVERRIDE
: null;
}
function buildConfigFromEnv(env = process.env) {
const config = defaultConfigShape();
config.codeRun.enabled = envFlag(env.MEMIND_AGENT_CODE_RUNS_ENABLED);
config.codeRun.clientEnabled = envFlag(env.VITE_AGENT_CODE_RUNS_ENABLED, config.codeRun.enabled);
config.codeRun.generalAutodetect = envFlag(env.VITE_AGENT_CODE_RUNS_AUTODETECT);
config.codeRun.requireValidation = envFlag(env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION, true);
config.codeRun.userAllowlist = normalizeStringList(env.MEMIND_AGENT_CODE_RUNS_USER_IDS);
const taskTypes = normalizeStringList(env.MEMIND_AGENT_CODE_RUN_TASK_TYPES);
config.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES];
config.pageDataDev.autodetect = envFlag(env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT);
return config;
}
function envPolicyActive(config) {
return Boolean(config?.codeRun?.enabled)
|| Boolean(config?.codeRun?.clientEnabled)
|| Boolean(config?.pageDataDev?.autodetect)
|| (config?.codeRun?.userAllowlist?.length ?? 0) > 0;
}
function mergePatch(currentConfig, patch = {}) {
const next = cloneConfig(currentConfig);
const codeRunPatch = patch?.codeRun ?? {};
const pageDataDevPatch = patch?.pageDataDev ?? {};
if ('enabled' in codeRunPatch) next.codeRun.enabled = normalizeBoolean(codeRunPatch.enabled, false);
if ('clientEnabled' in codeRunPatch) {
next.codeRun.clientEnabled = normalizeBoolean(codeRunPatch.clientEnabled, false);
}
if ('generalAutodetect' in codeRunPatch) {
next.codeRun.generalAutodetect = normalizeBoolean(codeRunPatch.generalAutodetect, false);
}
if ('requireValidation' in codeRunPatch) {
next.codeRun.requireValidation = normalizeBoolean(codeRunPatch.requireValidation, true);
}
if ('userAllowlist' in codeRunPatch) {
next.codeRun.userAllowlist = normalizeStringList(codeRunPatch.userAllowlist);
}
if ('taskTypeAllowlist' in codeRunPatch) {
const taskTypes = normalizeStringList(codeRunPatch.taskTypeAllowlist);
next.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES];
}
if ('autodetect' in pageDataDevPatch) {
next.pageDataDev.autodetect = normalizeBoolean(pageDataDevPatch.autodetect, false);
}
if (patch?.meta && typeof patch.meta.notes === 'string') {
next.meta.notes = patch.meta.notes;
}
return next;
}
function flattenPolicy(config, source) {
const taskTypeAllowlist = normalizeStringList(config.codeRun.taskTypeAllowlist);
return {
source,
enabled: Boolean(config.codeRun.enabled),
clientEnabled: Boolean(config.codeRun.clientEnabled),
generalAutodetect: Boolean(config.codeRun.generalAutodetect),
requireValidation: Boolean(config.codeRun.requireValidation),
pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect),
userAllowlist: normalizeStringList(config.codeRun.userAllowlist),
taskTypeAllowlist: taskTypeAllowlist.length ? taskTypeAllowlist : [...DEFAULT_TASK_TYPES],
};
}
export function isUserAllowedByCodeRunPolicy(userId, policy) {
if (!policy?.enabled) return false;
const allowlist = policy.userAllowlist ?? [];
if (allowlist.length === 0) return true;
return allowlist.includes(String(userId ?? '').trim());
}
export function isTaskTypeAllowedByCodeRunPolicy(taskType, policy) {
const allowlist = (policy?.taskTypeAllowlist ?? []).map((item) => String(item).toLowerCase());
if (allowlist.length === 0) return true;
const normalized = String(taskType ?? '').trim().toLowerCase();
if (!normalized) return false;
return allowlist.includes(normalized);
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_scope VARCHAR(32) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function loadStoredState(pool) {
await ensureConfigTable(pool);
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_scope = ?
LIMIT 1`,
[CONFIG_SCOPE],
);
const row = rows[0];
if (!row) return null;
const parsed = parseJsonLike(row.config_json, {});
return {
config: mergePatch(defaultConfigShape(), parsed),
updatedAt: Number(row.updated_at ?? 0) || null,
updatedBy: row.updated_by ?? null,
};
}
export function createAgentCodeRunAdminConfigService(pool, { env = process.env } = {}) {
async function loadEffectiveConfig() {
const forcedEnvSource = policySourceFromEnv(env);
if (forcedEnvSource) {
const config = buildConfigFromEnv(env);
return {
config,
updatedAt: null,
updatedBy: null,
source: forcedEnvSource,
};
}
const stored = await loadStoredState(pool);
if (stored) {
return {
config: cloneConfig(stored.config),
updatedAt: stored.updatedAt,
updatedBy: stored.updatedBy,
source: POLICY_SOURCE_ADMIN_DB,
};
}
const envConfig = buildConfigFromEnv(env);
if (envPolicyActive(envConfig)) {
return {
config: envConfig,
updatedAt: null,
updatedBy: null,
source: POLICY_SOURCE_ENV_MIGRATION,
};
}
return {
config: defaultConfigShape(),
updatedAt: null,
updatedBy: null,
source: POLICY_SOURCE_DEFAULT,
};
}
return {
async getAdminConfig() {
const state = await loadEffectiveConfig();
return {
config: state.config,
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
source: state.source,
envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE,
};
},
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
if (policySourceFromEnv(env)) {
throw Object.assign(new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不允许通过后台修改'), {
code: 'CODE_RUN_POLICY_ENV_LOCKED',
});
}
const stored = await loadStoredState(pool);
const base = stored?.config ?? defaultConfigShape();
const nextConfig = mergePatch(base, patch.config ?? patch);
await ensureConfigTable(pool);
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE}
(config_scope, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
);
return this.getAdminConfig();
},
async getRuntimeState() {
const state = await loadEffectiveConfig();
const policy = flattenPolicy(state.config, state.source);
return {
source: state.source,
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
config: state.config,
policy,
envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE,
};
},
async getEffectivePolicy(userId) {
const state = await loadEffectiveConfig();
const policy = flattenPolicy(state.config, state.source);
return {
...policy,
userAllowed: isUserAllowedByCodeRunPolicy(userId, policy),
};
},
async getPublicClientPolicy(userId) {
const policy = await this.getEffectivePolicy(userId);
const enabled = policy.enabled && policy.clientEnabled && policy.userAllowed;
return {
codeRun: {
enabled,
pageDataDevAutodetect: enabled && policy.pageDataDevAutodetect,
generalAutodetect: enabled && policy.generalAutodetect,
},
};
},
async getRuntimeStatusSummary() {
const state = await loadEffectiveConfig();
const policy = flattenPolicy(state.config, state.source);
return {
source: policy.source,
enabled: policy.enabled,
clientEnabled: policy.clientEnabled,
userAllowlist: policy.userAllowlist,
taskTypeAllowlist: policy.taskTypeAllowlist,
requireValidation: policy.requireValidation,
pageDataDevAutodetect: policy.pageDataDevAutodetect,
generalAutodetect: policy.generalAutodetect,
envOverrideActive: policy.source === POLICY_SOURCE_ENV_OVERRIDE,
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
};
},
async listRecentPageDataDevRuns({ limit = 50 } = {}) {
const pageDataTaskTypes = new Set(['page_data_dev', 'page_data_dev_complex']);
const capped = Math.min(Math.max(Number(limit) || 50, 1), 200);
const [rows] = await pool.query(
`SELECT id, user_id, request_id, status, error_message, created_at, updated_at, user_message_json
FROM h5_agent_runs
ORDER BY updated_at DESC
LIMIT ?`,
[capped * 4],
);
const runs = [];
for (const row of rows) {
if (runs.length >= capped) break;
const parsed = parseJsonLike(row.user_message_json, {});
const metadata = parsed?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
const taskType = String(runMetadata.taskType ?? metadata.taskType ?? '').trim().toLowerCase();
if (!pageDataTaskTypes.has(taskType)) continue;
runs.push({
id: row.id,
userId: row.user_id,
requestId: row.request_id,
status: row.status,
taskType,
executor: String(runMetadata.executor ?? metadata.executor ?? '').trim() || null,
errorMessage: row.error_message ?? null,
createdAt: Number(row.created_at ?? 0) || null,
updatedAt: Number(row.updated_at ?? 0) || null,
});
}
return { runs, limit: capped };
},
};
}
export const agentCodeRunAdminConfigInternals = {
CONFIG_SCOPE,
CONFIG_TABLE,
DEFAULT_TASK_TYPES,
defaultConfigShape,
buildConfigFromEnv,
mergePatch,
flattenPolicy,
};
+175
View File
@@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
agentCodeRunAdminConfigInternals,
createAgentCodeRunAdminConfigService,
isTaskTypeAllowedByCodeRunPolicy,
isUserAllowedByCodeRunPolicy,
} from './agent-code-run-admin-config.mjs';
function createMemoryPool(initialRow = null) {
const state = { row: initialRow };
return {
state,
async query(sql, params = []) {
const normalized = String(sql).replace(/\s+/g, ' ').trim();
if (normalized.startsWith('CREATE TABLE')) return [[]];
if (normalized.includes('SELECT config_json')) {
return [state.row ? [state.row] : []];
}
if (normalized.includes('INSERT INTO h5_agent_code_run_config')) {
state.row = {
config_json: params[1],
updated_by: params[2],
updated_at: params[3],
};
return [{ affectedRows: 1 }];
}
return [[]];
},
};
}
test('default policy is fail closed', async () => {
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), { env: {} });
const policy = await service.getEffectivePolicy('user-1');
assert.equal(policy.enabled, false);
assert.equal(policy.clientEnabled, false);
assert.equal(policy.pageDataDevAutodetect, false);
assert.equal(policy.source, 'default');
});
test('env migration activates when legacy env flags are set', async () => {
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), {
env: {
MEMIND_AGENT_CODE_RUNS_ENABLED: '1',
VITE_AGENT_PAGE_DATA_DEV_AUTODETECT: '1',
MEMIND_AGENT_CODE_RUNS_USER_IDS: 'user-a',
},
});
const policy = await service.getEffectivePolicy('user-a');
assert.equal(policy.source, 'env-migration');
assert.equal(policy.enabled, true);
assert.equal(policy.pageDataDevAutodetect, true);
assert.equal(policy.userAllowed, true);
assert.equal(isUserAllowedByCodeRunPolicy('user-b', policy), false);
});
test('admin-db config overrides env migration defaults', async () => {
const pool = createMemoryPool({
config_json: JSON.stringify({
codeRun: {
enabled: true,
clientEnabled: true,
pageDataDevAutodetect: false,
generalAutodetect: false,
requireValidation: true,
userAllowlist: [],
taskTypeAllowlist: ['page_data_dev'],
},
pageDataDev: { autodetect: true },
}),
updated_by: 'admin-1',
updated_at: 123,
});
const service = createAgentCodeRunAdminConfigService(pool, {
env: { MEMIND_AGENT_CODE_RUNS_ENABLED: '0' },
});
const policy = await service.getEffectivePolicy('user-1');
assert.equal(policy.source, 'admin-db');
assert.equal(policy.enabled, true);
assert.equal(policy.pageDataDevAutodetect, true);
assert.equal(isTaskTypeAllowedByCodeRunPolicy('page_data_dev', policy), true);
assert.equal(isTaskTypeAllowedByCodeRunPolicy('h5_chat_code_task', policy), false);
});
test('updateAdminConfig persists patch to database', async () => {
const pool = createMemoryPool();
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
const saved = await service.updateAdminConfig(
{
config: {
codeRun: { enabled: true, clientEnabled: true },
pageDataDev: { autodetect: true },
},
},
{ updatedBy: 'admin-1' },
);
assert.equal(saved.source, 'admin-db');
assert.equal(saved.config.codeRun.enabled, true);
assert.equal(saved.config.pageDataDev.autodetect, true);
const clientPolicy = await service.getPublicClientPolicy('user-1');
assert.equal(clientPolicy.codeRun.enabled, true);
assert.equal(clientPolicy.codeRun.pageDataDevAutodetect, true);
});
test('MEMIND_CODE_RUN_POLICY_SOURCE=env locks admin updates', async () => {
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), {
env: { MEMIND_CODE_RUN_POLICY_SOURCE: 'env', MEMIND_AGENT_CODE_RUNS_ENABLED: '1' },
});
await assert.rejects(
() => service.updateAdminConfig({ config: { codeRun: { enabled: false } } }),
/不允许通过后台修改/,
);
});
test('getPublicClientPolicy disables client flags when user not allowlisted', async () => {
const config = agentCodeRunAdminConfigInternals.defaultConfigShape();
config.codeRun.enabled = true;
config.codeRun.clientEnabled = true;
config.codeRun.userAllowlist = ['allowed-user'];
config.pageDataDev.autodetect = true;
const pool = createMemoryPool({
config_json: JSON.stringify(config),
updated_by: null,
updated_at: 1,
});
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
const denied = await service.getPublicClientPolicy('other-user');
assert.equal(denied.codeRun.enabled, false);
const allowed = await service.getPublicClientPolicy('allowed-user');
assert.equal(allowed.codeRun.enabled, true);
assert.equal(allowed.codeRun.pageDataDevAutodetect, true);
});
test('listRecentPageDataDevRuns filters page data dev task types', async () => {
const pool = {
async query(sql) {
const normalized = String(sql).replace(/\s+/g, ' ').trim();
if (normalized.includes('FROM h5_agent_runs')) {
return [[
{
id: 'run-1',
user_id: 'user-1',
request_id: 'req-1',
status: 'failed',
error_message: 'boom',
created_at: 1,
updated_at: 2,
user_message_json: JSON.stringify({
metadata: { memindRun: { taskType: 'page_data_dev', executor: 'aider' } },
}),
},
{
id: 'run-2',
user_id: 'user-1',
request_id: 'req-2',
status: 'succeeded',
error_message: null,
created_at: 3,
updated_at: 4,
user_message_json: JSON.stringify({
metadata: { memindRun: { taskType: 'h5_chat_code_task' } },
}),
},
]];
}
return [[]];
},
};
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
const result = await service.listRecentPageDataDevRuns({ limit: 10 });
assert.equal(result.runs.length, 1);
assert.equal(result.runs[0].taskType, 'page_data_dev');
assert.equal(result.runs[0].executor, 'aider');
});
+42 -23
View File
@@ -56,12 +56,32 @@ export function createPostAgentRunsHandler({
sessionAccess = null,
agentRunGateway,
mindSpaceAssetAgent = null,
codeRunPolicyService = null,
codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
requireCodeRunValidation = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
}) {
const sessionStore = sessionAccess ?? createSessionAccess({ userAuth, enabled: false });
async function resolveCodeRunPolicy(userId) {
if (codeRunPolicyService?.getEffectivePolicy) {
return codeRunPolicyService.getEffectivePolicy(userId);
}
return {
source: 'env',
enabled: codeRunsEnabled,
clientEnabled: codeRunsEnabled,
generalAutodetect: false,
pageDataDevAutodetect: false,
requireValidation: requireCodeRunValidation,
userAllowlist: [...codeRunUserIds],
taskTypeAllowlist: [...codeRunTaskTypes],
userAllowed:
!codeRunUserIds.size || codeRunUserIds.has(String(userId ?? '').trim()),
};
}
return async function postAgentRuns(request, response) {
try {
const sessionId = String(request.body?.session_id ?? '').trim() || null;
@@ -87,29 +107,28 @@ export function createPostAgentRunsHandler({
});
return;
}
if (toolMode === 'code' && !codeRunsEnabled) {
response.status(403).json({ message: '代码任务灰度未开启' });
return;
}
if (
toolMode === 'code' &&
codeRunUserIds.size > 0 &&
!codeRunUserIds.has(request.currentUser.id)
) {
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
return;
}
if (
toolMode === 'code' &&
codeRunTaskTypes.size > 0 &&
(!taskType || !codeRunTaskTypes.has(taskType.toLowerCase()))
) {
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
return;
}
if (toolMode === 'code' && requireCodeRunValidation && !hasExpectedFileValidation(userMessage)) {
response.status(400).json({ message: '代码任务必须声明产物校验规则' });
return;
if (toolMode === 'code') {
const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id);
if (!codeRunPolicy.enabled) {
response.status(403).json({ message: '代码任务灰度未开启' });
return;
}
if (!codeRunPolicy.userAllowed) {
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
return;
}
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
if (
taskTypeAllowlist.length > 0 &&
(!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase()))
) {
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
return;
}
if (codeRunPolicy.requireValidation && !hasExpectedFileValidation(userMessage)) {
response.status(400).json({ message: '代码任务必须声明产物校验规则' });
return;
}
}
if (sessionId) {
const owns = await sessionStore.validateOwnership(request.currentUser.id, sessionId);
+10
View File
@@ -3,6 +3,7 @@ import {
buildWebNewsSkillPrompt,
extractSelectedChatSkillName,
hasExplicitChatSkillPrompt,
isPageDataDevIntent,
isPageDataIntent,
isPageGenerationIntent,
isProductCampaignIntent,
@@ -930,6 +931,15 @@ export function classifyWithRules({
reason,
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isPageDataDevIntent(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.94,
reason: 'Page Data 开发修复(修 bug / 测试回归)',
agent_brief:
'这是 Page Data 开发修复任务:优先根据 verify/报错信息修 public HTML 与 policy,不要重建问卷;建表/bind 缺失时说明需 Goose 或 repair 脚本。',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
+11
View File
@@ -1335,3 +1335,14 @@ test('createChatIntentRouter uses agent fallback for unmatched general questions
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 0);
});
test('classifyWithRules routes page data dev repair before new page-data collect', () => {
const result = classifyWithRules({
text: '修复问卷 insert 403,columns_not_allowed',
sessionId: null,
sessionMessageCount: 0,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(result.reason, /Page Data 开发修复/);
assert.equal(result.suggested_skill, undefined);
});
+18
View File
@@ -71,9 +71,27 @@ function isInteractivePageDataIntent(text) {
return INTERACTIVE_PAGE_DATA_SURFACE_PATTERN.test(text) || featureCount >= 2;
}
const PAGE_DATA_DEV_INTENT_PATTERNS = [
/columns_not_allowed/i,
/\bpage[\s-]?data[\s-]?dev\b/i,
/(?:insert|提交|写入).{0,16}(?:失败|403|报错|error)/iu,
/(?:修复|修|排查|debug|fix).{0,24}(?:问卷|page[\s-]?data|数据提交|插入|admin|后台|policy|绑定|bind)/iu,
/(?:问卷|page[\s-]?data|数据页|表单页).{0,24}(?:bug|坏了|不通|失败|有问题)/iu,
/(?:测试|verify).{0,16}(?:没过|失败|不通过)/iu,
/(?:repair|修复).{0,16}(?:bind|绑定|policy|策略)/iu,
];
/** Page Data 开发修复(Aider 兜底):修 bug / 测回归,不是新建问卷。 */
export function isPageDataDevIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_DATA_DEV_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isPageDataIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (isPageDataDevIntent(normalized)) return false;
return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized))
|| isInteractivePageDataIntent(normalized);
}
+9
View File
@@ -6,6 +6,7 @@ import {
CHAT_SKILL_DEFINITIONS,
filterChatSkills,
isExcelAnalysisIntent,
isPageDataDevIntent,
isPageDataIntent,
isPageGenerationIntent,
} from './chat-skills.mjs';
@@ -168,3 +169,11 @@ test('buildAutoChatSkillPrefix enables publish for implicit page requests', () =
assert.match(prefix, /static-page-publish/);
assert.match(prefix, /禁止询问是否还要发布/);
});
test('isPageDataDevIntent matches repair phrases but not new survey creation', () => {
assert.equal(isPageDataDevIntent('修复问卷 insert 403 columns_not_allowed'), true);
assert.equal(isPageDataDevIntent('page-data-dev 修 policy 白名单'), true);
assert.equal(isPageDataDevIntent('帮我做一个报名问卷'), false);
assert.equal(isPageDataIntent('帮我做一个报名问卷'), true);
assert.equal(isPageDataIntent('修复问卷 insert 403 columns_not_allowed'), false);
});
+41
View File
@@ -37,6 +37,47 @@ runtime-slo-report.ok=true
runtime-slo-report.failures=[]
```
## Admin DB Policy (Phase 1.5)
Code-run **user/taskType/client toggles** can be managed in memindadm instead of rebuilding H5 for every change.
| Layer | Controls |
|-------|----------|
| **memindadm** (`h5_agent_code_run_config`) | `codeRun.enabled`, `clientEnabled`, allowlists, `pageDataDev.autodetect`, `generalAutodetect`, `requireValidation` |
| **Portal env** | `MEMIND_TOOL_GATEWAY_ENABLED`, worker queue topology, emergency override via `MEMIND_CODE_RUN_POLICY_SOURCE=env` |
| **H5 runtime** | `/auth/status.agentCodeRun` → client autodetect without `VITE_*` rebuild |
Admin API (memind_adm `:8082`):
```text
GET/PATCH /admin-api/agent-code-run/config
GET /admin-api/agent-code-run/runtime
```
Ops UI: super-admin console → **Code Run** (`/ops/admin/agent-code-run`).
One-time env → DB migration (dev/staging):
```bash
node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by <admin-user-uuid>
```
Verify effective policy after save:
```bash
curl -fsS http://127.0.0.1:8081/auth/status -H "Cookie: h5_user=<token>" | jq '.agentCodeRun'
curl -fsS http://127.0.0.1:8081/api/runtime/status | jq '.toolRuntime.codeRunPolicy'
```
Page Data dev loop (local only):
```bash
MEMIND_TOOL_GATEWAY_ENABLED=1 npm run dev:page-data-aider-loop -- --dry-run
```
See also: [help-code01.md](./help-code01.md), [help-code02.md](./help-code02.md).
## One-user Canary Profile
Use only for a controlled john-user canary.
+373
View File
@@ -0,0 +1,373 @@
# Help Code01:Aider 补充 Page Data 简单系统开发方案
> 文档版本:2026-07-23
> 状态:方案设计(未实施)
> 目标:用 **Aider** 在 Goose 主路径之外,补充 Page Data 简单系统在**开发/调试**阶段的修 bug 与测试回归;**不替代 Goose**,不替代 `page-data-collect` skill。
---
## 1. 背景与目标
### 1.1 现状
Memind 的 Page Data 主路径由 **Goose + `page-data-collect` skill + sandbox-fs MCP** 完成:
```text
load_skill(page-data-collect)
→ private_data_execute(建表)
→ private_data_register_dataset(注册 dataset)
→ write_file(survey.html + admin.html)
→ private_data_bind_workspace_page(发布 + 策略)
```
**Aider / OpenHands** 当前仅在 `toolMode='code'` 的显式代码任务中作为外部执行器,**不参与** Page Data 创建与日常 H5 聊天。
### 1.2 痛点
简单 Page Data 系统(问卷、台账、小后台)在 Goose 搭完骨架后,开发阶段常见:
- HTML 字段名与 dataset 列不一致 → insert 403
- `page-data-client.js` 用法错误
- policy 白名单未更新(bind 失败或漏列)
- admin 页读不到数据
- 改完不确定是否回归通过
重新走聊天让 Goose 修,成本高、周期长。
### 1.3 目标
引入 **Aider 作为 L2 开发兜底**:
| 阶段 | 执行者 |
|------|--------|
| **创建** | Goose + `page-data-collect`(不变) |
| **测试** | 现有 verify / scenario 脚本(不变) |
| **修 bug** | Aider(新增,仅 dev/staging) |
| **改表 / 重新 bind** | Goose 或运维脚本(Aider 不主责) |
---
## 2. 设计原则
1. **不替代 Goose**:Page Data 首次建表、注册、bind 仍走 MCP 主路径。
2. **测试驱动**:Aider 修复必须以 verify 脚本失败输出为依据,禁止无验收的盲改。
3. **cwd 隔离**:用户 workspace 与 Memind 平台源码分开,不可混跑。
4. **dev 优先**:生产环境默认不自动触发 Aider Page Data 修复;需白名单与 code run 灰度。
5. **简单系统用 Aider**:复杂多文件改造才考虑 OpenHands,本方案默认 `executor=aider`。
---
## 3. 三层架构
```mermaid
flowchart LR
A[Goose page-data-collect] --> B[产物: HTML + PG表 + policy]
B --> C[npm verify / 场景脚本]
C -->|失败| D[Aider code run]
D --> E[改 workspace 内文件]
E --> C
C -->|通过| F[交付]
```
### 3.1 L0 — 创建(Goose,不变)
- Skill:`skills/page-data-collect/SKILL.md`
- MCP:`mindspace-sandbox-mcp.mjs`(`private_data_*` + `write_file` / `edit_file`)
- 守卫:`mindspace-page-data-finish-guard.mjs`、交付 sync
### 3.2 L1 — 测试(平台脚本,不变)
| 命令 | 用途 |
|------|------|
| `npm run verify:page-data` | Page Data 平台单测与集成测 |
| `npm run verify:children-hobby-diet-survey` | 问卷链路产物验证(~5s) |
| `npm run test:scenario:john4-diet` | 全链路 E2E(含 Agent 聊天,~3–10min) |
| `npm run verify:page-data-delivery` | 绑定/delivery dry-run |
| `npm run repair:page-data-bindings` | 修复 workspace bind |
参考:`.claude/skills/memind-page-data-survey-verify/SKILL.md`
### 3.3 L2 — 修 bug(Aider,本方案新增)
- 入口:`toolMode='code'`,`executor='aider'`,建议 `taskType='page_data_dev'`
- cwd:用户 workspace(`resolveWorkingDir(userId)`)或 Memind 仓库(平台开发)
- 输入:verify 失败输出 + 目标文件路径 + Page Data 约束摘要
- 输出:修改后的 HTML / policy JSON;可选 receipt
- 验收:重跑 L1 verify 直至通过
---
## 4. Aider 能力边界
### 4.1 ✅ 适合修复
| 问题类型 | 可改路径 |
|----------|----------|
| HTML 字段与 dataset 列不一致 | `public/*-survey.html`、`public/*-admin.html` |
| `page-data-client.js` API 误用 | 同上 |
| policy insert/read 白名单错误 | `.mindspace/page-data-policies/{pageId}.json` |
| CSP / 脚本路径 / 同源约束 | `public/assets/`、`public/*.html` |
| 提交后 UI 无反馈 | HTML 内 JS |
### 4.2 ⚠️ 需配合脚本或 Goose
| 问题 | 推荐做法 |
|------|----------|
| PG 表缺列 | Goose 重新 `private_data_execute`,或运维 SQL |
| dataset 未注册 | `scripts/ensure-page-data-datasets.mjs` |
| bind 丢失 | `npm run repair:page-data-bindings` |
| 策略索引不同步 | `page-data-policy-index` 相关 repair |
### 4.3 ❌ 不交给 Aider
| 问题 | 原因 |
|------|------|
| 首次建表 + register + bind | 无 `private_data_*` MCP |
| 替代 Goose 做页面生成 | 缺 skill、finish sync、交付守卫 |
| 生产环境无人值守自动改码 | 风险高,需严格灰度 |
---
## 5. 两种开发场景
### 5.1 场景 A:用户 workspace 里的 Page Data 小系统
**典型产物:**
```text
MindSpace/{userId}/
public/my-survey.html
public/my-admin.html
.mindspace/page-data-policies/{pageId}.json
```
**Aider 配置:**
| 项 | 值 |
|----|-----|
| cwd | `MindSpace/{userId}/` |
| executor | `aider` |
| taskType | `page_data_dev`(建议新增)或 `page_edit_code_task` |
| validation | `expectedFiles`: 目标 `public/*.html` |
**测试裁判:** 问卷专用 verify 或自定义 `scripts/verify-*.mjs`
### 5.2 场景 B:Memind 平台 Page Data 模块开发
**典型修改:**
- `page-data-service.mjs`、`page-data-public-service.mjs`
- `mindspace-page-data-finish-guard.mjs`
- `public/assets/page-data-client.js`
**Aider 配置:**
| 项 | 值 |
|----|-----|
| cwd | `/Users/john/Project/Memind`(或 CI checkout 路径) |
| executor | `aider` |
| taskType | `h5_chat_code_task` 或专用 `page_data_platform_dev` |
| validation | receipt + 单测通过 |
**测试裁判:** `npm run verify:page-data`
> **禁止**在同一次 Aider run 中混用场景 A 与 B 的 cwd。
---
## 6. 开发闭环操作手册
### 6.1 前置:开启 code run
> **Phase 1(当前):** env + VITE,适合本机 dev。
> **Phase 1.5(目标):** memindadm 配置 + `/auth/status` 下发,见 [help-code02.md](./help-code02.md)。
```bash
# .env 示例(开发机,Phase 1)
MEMIND_AGENT_CODE_RUNS_ENABLED=1
MEMIND_TOOL_GATEWAY_ENABLED=1
MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider
VITE_AGENT_CODE_RUNS_ENABLED=1
VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=1
VITE_AGENT_CODE_RUNS_USER_IDS=<dev-user-uuid> # 或留空表示全用户(仅 dev)
# 可选:MEMIND_AGENT_CODE_RUN_TASK_TYPES=page_data_dev,h5_chat_code_task,...
```
还需:
- DB `h5_capability_grants`:目标用户 `aider=true`
- `h5_llm_executor_bindings`:aider 有 enabled 的 provider/model
- Portal 或 external worker 之一启用 Tool Gateway(本地 dev 可 Portal 直开)
### 6.2 标准循环
```bash
# 1. 确认 Portal
curl -sf http://127.0.0.1:8081/auth/status
# 2. Goose 已建好 Page Data 系统(聊天或 scenario)
# 3. 跑验证
npm run verify:children-hobby-diet-survey
# 或
npm run verify:page-data
# 4. 若失败 → 触发 Aider(H5 / agent-run / 独立脚本)
# instruction 必须包含完整 verify 失败输出
# 5. Aider 改完 → 回到步骤 3,直至全绿
```
### 6.3 H5 触发话术示例
- 「问卷 submit 403,列 q4_diet_meals 不允许,请根据 verify 结果修 HTML 和 policy」
- 「Page Data admin 页读不到数据,测试脚本报 policy read 未开启」
- 「修 page-data 简单系统的 bug,不要改表,只改 public HTML」
### 6.4 Aider instruction 模板
```text
[Page Data 开发修复任务]
工作目录: MindSpace/{userId}/
taskType: page_data_dev
验证失败输出(必须原样附上):
<paste verify script stderr/stdout>
目标文件(仅可改这些):
- public/{survey}.html
- public/{admin}.html
- .mindspace/page-data-policies/{pageId}.json
约束:
- 必须使用 /assets/page-data-client.js 公开 API
- 禁止 localStorage / sessionStorage / IndexedDB
- 禁止自建 Express 或独立后端
- 列名必须与 dataset register 的 insert/read 白名单一致
- 不要修改 Memind 平台源码
验收:
- 修改后应能通过: npm run verify:children-hobby-diet-survey
- 或: node scripts/verify-<your-scenario>.mjs
```
---
## 7. 与现有 Memind 架构的集成点
### 7.1 现有组件(复用,不重写)
| 组件 | 路径 | 角色 |
|------|------|------|
| Agent Run 队列 | `agent-run-gateway.mjs` | code run 调度 |
| Tool Gateway | `tool-gateway.mjs` | spawn Aider |
| Launch Plan | `llm-providers.mjs` | `aider --message ... --yes-always` |
| 前端 code 检测 | `src/utils/agentRunMode.ts` | `toolMode:'code'` |
| 能力门禁 | `capabilities.mjs` | code mode 才注入 aider |
| Page Data 路由 | `page-data-routes.mjs` | 运行时 API(Aider 不直接调,HTML 调) |
### 7.2 建议新增(实施阶段)
| 项 | 说明 | 优先级 |
|----|------|--------|
| `taskType: page_data_dev` | router / autodetect 识别「修 page data bug」 | P1 |
| chat-intent-router 规则 | 「修问卷/测试没过/page data bug」→ code run | P1 |
| `buildAgentRunTaskValidation` 扩展 | page_data_dev 的 expectedFiles + receipt | P2 |
| 独立 dev 脚本 | 仓库外或 `scripts/page-data-aider-dev-loop.mjs`:verify → aider → verify | P2 |
| 文档索引 | 在 `docs/regression-guards/` 或 AGENTS.md 加链接 | P3 |
### 7.3 不建议的做法
- 用 Aider/OpenHands **替代** Goose 做 Page Data 创建
- 用 `page-data-collect` skill 充当通用 code executor
- 生产默认全开 `MEMIND_AGENT_CODE_RUNS_ENABLED` 给所有用户
- 无 verify 输出让 Aider 盲改 workspace
---
## 8. 与 OpenHands / Cursor 的关系
| 引擎 | 本方案中的角色 |
|------|----------------|
| **Goose** | L0 创建(主路径,不变) |
| **Aider** | L2 简单 Page Data dev 修 bug(**本方案核心**) |
| **OpenHands** | 可选 L3:Aider 失败且涉及多文件/复杂 repo 时再 escalation |
| **Cursor SDK** | 非必需;仅 Memind 平台源码开发或 IDE 级工具时可选用 |
OpenHands 任务类型保持现有 `repo_refactor,multi_file,complex_repo`;**不必**为 Page Data 简单 HTML bug 默认走 OpenHands。
---
## 9. 风险与运维
| 风险 | 缓解 |
|------|------|
| Aider 误改非目标文件 | validation `expectedFiles` + cwd 沙箱 + prompt 白名单 |
| 表结构问题被误当 HTML bug | verify 区分 `columns_not_allowed` vs `table_not_found`;后者回 Goose |
| code run 孤儿化导致 H5 loading | verify 脚本检查 orphan running;`--no-runs` 可选 |
| 生产误触发 | 生产 `MEMIND_AGENT_CODE_RUNS_ENABLED=0` 或严格 user allowlist |
| PG 连接失败 | fail closed(skill 已有);Aider 不得「先写 HTML 等 PG 恢复」 |
---
## 10. 实施路线图
### Phase 0 — 文档与手动流程(当前)
- [x] 本方案文档 `docs/help-code01.md`
- [ ] 开发者在本地手动:verify 失败 → 复制输出 → H5/code run 触发 Aider → 再 verify
### Phase 1 — 最小 Memind 改动
- [x] 新增 `page_data_dev` taskType
- [x] `chat-intent-router` 增加 Page Data 修 bug 意图
- [x] `agentRunMode.ts` 可选 autodetect 模式(dev only,`VITE_AGENT_PAGE_DATA_DEV_AUTODETECT`)
### Phase 1.5 — memindadm 运行时策略(推荐下一步)
> 详细设计见 **[help-code02.md](./help-code02.md)**。
- [x] `h5_agent_code_run_config` 表 + `agent-code-run-admin-config.mjs`
- [x] memindadm API:`/admin-api/agent-code-run/config`、`/runtime`(UI 表单待做)
- [x] `/auth/status` 下发 `agentCodeRun`,H5 不再依赖 `VITE_*` rebuild
- [x] `agent-run-routes.mjs` 从 DB 读策略(env 仅紧急 override)
- [x] Worker 拓扑(`MEMIND_TOOL_GATEWAY_ENABLED`)继续留 env
- [x] `scripts/migrate-agent-code-run-config-from-env.mjs`
**为何需要 Phase 1.5:** Phase 1 的 env/VITE 适合 dev;要解决「用户经常失败」的运营灰度,必须在 memindadm 按用户/任务类型动态开关,且 H5 需运行时生效。
### Phase 2 — 自动化 dev loop
- [x] `scripts/page-data-aider-dev-loop.mjs`:读 verify 输出 → 调 Tool Gateway/Aider → 重跑 verify
- [x] `npm run dev:page-data-aider-loop` 快捷入口
- [ ] 接入 CI optional job(仅 staging)
- [x] `npm run ci:page-data-dev-loop-smoke`(dry-run 烟测,可挂 CI optional job)
### Phase 3 — 可选 escalation
- [x] Aider 连续 N 次 verify 失败 → 升级 OpenHands(`taskType=page_data_dev_complex`,`--escalate-after`)
- [x] 运维面板展示 page_data_dev run 历史(`/admin-api/agent-code-run/runs` + Ops UI)
---
## 11. 相关文档与命令速查
| 资源 | 路径 / 命令 |
|------|-------------|
| **adm 运行时策略(Phase 1.5)** | `docs/help-code02.md` |
| Page Data API 用法 | `docs/page-data-api-usage.md` |
| page-data-collect skill | `skills/page-data-collect/SKILL.md` |
| 问卷 verify skill | `.claude/skills/memind-page-data-survey-verify/SKILL.md` |
| Tool Gateway 架构 | `docs/architecture/memind-2-streaming-agent-runtime-plan.md` |
| Agent run worker | `docs/agent-run-worker-rollout-runbook.md` |
| `npm run verify:page-data` | Page Data 平台测试 |
| `npm run verify:children-hobby-diet-survey` | 问卷产物验证 |
| `npm run repair:page-data-bindings` | 修复 bind |
---
## 12. 一句话总结
**Goose 负责把 Page Data 简单系统「建起来」;现有 verify 脚本负责「验对不对」;Aider 负责开发阶段「根据测试结果改 HTML/policy 修 bug」——三者串联,不替代 Goose 主路径。**
+382
View File
@@ -0,0 +1,382 @@
# Help Code02:Code Run / Page Data Dev 的 memindadm 运行时策略
> 文档版本:2026-07-23
> 状态:方案设计(未实施)
> 前置:[help-code01.md](./help-code01.md)(Aider 补充 Page Data 开发兜底)
> 关联:[memindadm-goose-gateway-design.md](./memindadm-goose-gateway-design.md)
---
## 1. 为什么要做 Phase 1.5
Phase 1 用 **env + VITE_ 构建期变量** 控制 code run 与 `page_data_dev` autodetect。这在 dev/staging 可用,但对「用户经常页面失败、插入失败」的运营场景不够:
| 问题 | env/VITE 的局限 |
|------|----------------|
| 改开关要 rebuild H5 | `VITE_*` 打进 bundle,memindadm 点了不生效 |
| 改开关要 SSH 改 `.env` | 103 Portal / worker 两套 env,易不一致 |
| 无法按用户即时灰度 | 白名单改 env 无审计、无 UI |
| 与现有 adm 能力分裂 | aider 能力已在 `h5_capability_grants`,策略却在 env |
**目标:** 把 **「谁、哪种 task、是否 page_data_dev autodetect」** 迁入 memindadm;**部署拓扑**(worker 是否 spawn Aider)继续留 env。
---
## 2. 配置分层(硬边界)
```mermaid
flowchart TB
subgraph adm ["memindadm 运行时策略(DB)"]
P1[codeRun.enabled]
P2[userAllowlist / roleAllowlist]
P3[taskTypeAllowlist 含 page_data_dev]
P4[pageDataDev.autodetect]
P5[requireValidation]
P6[generalCodeAutodetect]
end
subgraph env ["部署 env(不改或只读 override)"]
E1[MEMIND_TOOL_GATEWAY_ENABLED]
E2[AIDER_BIN / OPENHANDS_BIN]
E3[worker 并发 / 超时 / guard]
E4[MEMIND_AGENT_RUN_* 紧急 override]
end
subgraph cap ["已有用户能力(DB)"]
C1[h5_capability_grants: aider/openhands]
C2[h5_llm_executor_bindings]
end
adm --> Portal[agent-run-routes 校验]
adm --> Auth["/auth/status → H5"]
cap --> Portal
env --> Worker[agent-run-worker]
Portal --> Worker
```
### 2.1 进 memindadm 的项
| 原 env / VITE | adm 字段 | 说明 |
|---------------|----------|------|
| `MEMIND_AGENT_CODE_RUNS_ENABLED` | `codeRun.enabled` | 后端是否接受 `tool_mode=code` |
| `MEMIND_AGENT_CODE_RUNS_USER_IDS` | `codeRun.userAllowlist` | 空 = 不限制(enabled 时) |
| `MEMIND_AGENT_CODE_RUN_TASK_TYPES` | `codeRun.taskTypeAllowlist` | 含 `page_data_dev`、`h5_chat_code_task` |
| `MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION` | `codeRun.requireValidation` | 是否强制 receipt |
| `VITE_AGENT_CODE_RUNS_ENABLED` | `codeRun.clientEnabled` | H5 是否展示/走 code 路径 |
| `VITE_AGENT_CODE_RUNS_AUTODETECT` | `codeRun.generalAutodetect` | 通用代码语义 autodetect |
| `VITE_AGENT_PAGE_DATA_DEV_AUTODETECT` | `pageDataDev.autodetect` | Page Data 修 bug 专用 autodetect |
### 2.2 继续留 env 的项
| env | 原因 |
|-----|------|
| `MEMIND_TOOL_GATEWAY_ENABLED` | worker 进程级;Portal 与 worker 故意不同值 |
| `MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR` | 机器上装的是 aider 还是 openhands |
| `MEMIND_AGENT_RUN_QUEUE_CONCURRENCY` 等 | SLO / guard / LaunchAgent |
| `MEMIND_AGENT_CODE_RUNS_*`(可选) | **紧急 override**:`MEMIND_CODE_RUN_POLICY_SOURCE=env` 时强制 env 优先 |
---
## 3. 数据模型
### 3.1 表:`h5_agent_code_run_config`
与 `h5_skill_runtime_config`、`h5_image_make_admin_config` 同模式:`config_scope='global'` 单行 JSON。
```sql
CREATE TABLE IF NOT EXISTS h5_agent_code_run_config (
config_scope VARCHAR(32) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
### 3.2 默认 JSON Schema
```json
{
"codeRun": {
"enabled": false,
"clientEnabled": false,
"generalAutodetect": false,
"requireValidation": true,
"userAllowlist": [],
"taskTypeAllowlist": [
"page_data_dev",
"h5_chat_code_task",
"page_edit_code_task"
]
},
"pageDataDev": {
"autodetect": false
},
"meta": {
"notes": ""
}
}
```
### 3.3 生效优先级(与 Memory V2 adm 一致)
参考 `memory-v2-admin-config.mjs` 的 `FIELD_SPECS + env override` 模式:
```text
1. 若 MEMIND_CODE_RUN_POLICY_SOURCE=env → 仅读 env(紧急回滚)
2. 否则读 h5_agent_code_run_config(admin-db)
3. 若表为空且 MEMIND_AGENT_CODE_RUNS_ENABLED=1 → source=env-migration(兼容旧部署)
4. 否则 default(全 false,fail closed)
```
### 3.4 用户级能力(不重复造表)
以下 **仍用现有表**,adm「Code Run 策略」页只读展示 + 链到用户能力编辑:
- `h5_capability_grants`:`aider` / `openhands`
- `h5_llm_executor_bindings`:Aider 模型绑定
- `h5_user_policies`:`code_delegate_executor`、`code_task_routing`
**完整放行条件(与 today 相同,只是策略来源改为 DB):**
```text
codeRun.enabled (adm)
AND user ∈ allowlist(若配置)
AND taskType ∈ taskTypeAllowlist
AND capabilities.aider(用户 grant)
AND executor binding 可用
AND(若 requireValidation)message 含 validation metadata
AND toolGateway.enabled(env,worker 侧)
```
---
## 4. 服务模块:`agent-code-run-admin-config.mjs`
建议新建,API 对齐 `skill-runtime-admin-config.mjs`:
| 方法 | 用途 |
|------|------|
| `getAdminConfig()` | memindadm 编辑页 |
| `updateAdminConfig(patch, { updatedBy })` | 保存 + 审计 |
| `getRuntimeState()` | adm 运行时预览 |
| `getEffectivePolicy({ userId })` | Portal 校验用 |
| `getPublicClientPolicy({ userId })` | `/auth/status` 下发 H5 |
### 4.1 `getEffectivePolicy` 返回示例
```json
{
"source": "admin-db",
"updatedAt": 1753276800000,
"enabled": true,
"userAllowed": true,
"taskTypes": ["page_data_dev", "h5_chat_code_task", "page_edit_code_task"],
"requireValidation": true,
"pageDataDevAutodetect": true,
"generalAutodetect": false
}
```
### 4.2 `getPublicClientPolicy` 返回示例(按用户过滤后)
```json
{
"codeRun": {
"enabled": true,
"pageDataDevAutodetect": true,
"generalAutodetect": false
}
}
```
未登录或用户不在 allowlist 时:`codeRun.enabled=false`(或不返回该块)。
---
## 5. HTTP API
### 5.1 memindadm(`admin-routes.mjs`)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/admin/agent-code-run/config` | 读配置 + source + updatedBy |
| PUT/PATCH | `/api/admin/agent-code-run/config` | 更新 |
| GET | `/api/admin/agent-code-run/runtime` | 有效策略 + env 覆盖状态 + worker 只读摘要 |
Worker 摘要可代理现有 `/api/runtime/status` 的 `toolRuntime.codeRunPolicy` 与 `queue`(**只读**,不在 adm 改 worker env)。
### 5.2 Portal 用户面
**扩展 `GET /auth/status`**(已登录用户):
```json
{
"authenticated": true,
"user": { "...": "..." },
"capabilities": { "aider": true, "...": "..." },
"skillRuntime": { "...": "..." },
"agentCodeRun": {
"enabled": true,
"pageDataDevAutodetect": true,
"generalAutodetect": false
}
}
```
**扩展 `POST /api/agent/runs` 校验:** 从 `getEffectivePolicy(userId)` 读,不再只读 `process.env.MEMIND_AGENT_CODE_RUNS_*`。
### 5.3 只读运维
`GET /api/runtime/status` 增加:
```json
{
"toolRuntime": {
"codeRunPolicy": {
"source": "admin-db",
"enabled": true,
"userAllowlist": [],
"taskTypeAllowlist": ["page_data_dev", "..."],
"requireValidation": true,
"pageDataDevAutodetect": true,
"envOverrideActive": false
}
}
}
```
---
## 6. 前端改造(去掉 VITE_ 硬依赖)
### 6.1 `src/utils/agentRunMode.ts`
```typescript
// 优先级:runtime policy(/auth/status)> VITE_(dev fallback)
let runtimePolicy: AgentCodeRunClientPolicy | null = null;
export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) {
runtimePolicy = policy;
}
function clientCodeRunsEnabled(): boolean {
if (runtimePolicy?.codeRun?.enabled != null) return runtimePolicy.codeRun.enabled;
return agentCodeRunsEnabled; // VITE fallback
}
function clientPageDataDevAutodetect(): boolean {
if (runtimePolicy?.codeRun?.pageDataDevAutodetect != null) {
return runtimePolicy.codeRun.pageDataDevAutodetect;
}
return agentPageDataDevAutodetectEnabled;
}
```
### 6.2 注入时机
在现有 `/auth/status` 加载处(如 `src/api/client.ts` 或 auth hook):
```typescript
const status = await fetchAuthStatus();
applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null);
```
**效果:** memindadm 打开 `pageDataDev.autodetect` 后,用户刷新页面即生效,**无需 rebuild H5**。
---
## 7. memindadm UI(Ops 后台)
建议菜单位置:**系统 / Agent 运行时 → Code Run & Page Data Dev**
| 控件 | 类型 | 说明 |
|------|------|------|
| Code Run 总开关 | toggle | `codeRun.enabled` |
| H5 客户端启用 | toggle | `codeRun.clientEnabled` |
| Page Data Dev Autodetect | toggle | `pageDataDev.autodetect` |
| 通用 Code Autodetect | toggle | `codeRun.generalAutodetect`(默认关) |
| 强制 Validation | toggle | `codeRun.requireValidation` |
| 用户白名单 | multi-select UUID | 空 = 全部(enabled 时) |
| Task Types | checkbox list | 至少含 `page_data_dev` |
| 当前 Worker 状态 | read-only | 来自 `/runtime/status` |
| Env Override 警告 | banner | `MEMIND_CODE_RUN_POLICY_SOURCE=env` 时显示 |
**保存时:** 写 `updated_by` + `updated_at`;可选写 admin audit log(与 image-make 一致)。
---
## 8. 迁移与兼容
### 8.1 首次上线
1. 建表 `h5_agent_code_run_config`,默认全 `false`
2. 部署 `agent-code-run-admin-config.mjs` + admin API
3. **迁移脚本**(可选):若 env 已开启,import 到 DB 并提示改 `POLICY_SOURCE=admin`
```bash
node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
node scripts/migrate-agent-code-run-config-from-env.mjs --apply
```
### 8.2 回滚
| 场景 | 操作 |
|------|------|
| adm 配错了 | memindadm 关 `codeRun.enabled` |
| 紧急全站关闭 | `MEMIND_CODE_RUN_POLICY_SOURCE=env` + unset `MEMIND_AGENT_CODE_RUNS_ENABLED` |
| worker 异常 | LaunchAgent 停 worker(现有 runbook),与 adm 无关 |
### 8.3 与 help-code01 Phase 的关系
| Phase | 内容 | 配置来源 |
|-------|------|----------|
| Phase 1 ✅ | `page_data_dev` 意图 + autodetect 逻辑 | env/VITE |
| **Phase 1.5** | adm 运行时策略 + `/auth/status` | **DB + adm UI** |
| Phase 2 | verify → Aider dev loop 脚本 | 脚本 + adm 开关 |
---
## 9. 实施清单(Phase 1.5)
- [x] `agent-code-run-admin-config.mjs` + 单测
- [x] `admin-routes.mjs`:`/agent-code-run/config`、`/runtime`
- [x] `server.mjs`:`/auth/status` 增加 `agentCodeRun`
- [x] `agent-run-routes.mjs`:改用 `getEffectivePolicy(userId)`
- [x] `agentRunMode.ts`:runtime policy 优先于 VITE_
- [x] Ops UI 页面(表单 + JSON 高级编辑:`/ops/admin/agent-code-run`)
- [x] `migrate-agent-code-run-config-from-env.mjs`
- [x] 更新 `docs/agent-run-worker-rollout-runbook.md`
- [x] `.env.example` 增加 `MEMIND_CODE_RUN_POLICY_SOURCE`
- [x] verify:`agent-code-run-admin-config.test.mjs` + 扩展 `chat-agent-run-gate.test.mjs`
---
## 10. 安全与审计
1. **仅 admin 可写** — 复用 `requireAdmin`
2. **默认 fail closed** — 新环境 adm 配置为空 = 全关
3. **双闸门保留** — adm 开 + 用户 `aider` grant + worker gateway env
4. **审计字段** — `updated_by`、`updated_at`;重要变更写 admin audit
5. **生产建议** — 先 `userAllowlist` 小范围开 `page_data_dev`,再扩 `generalAutodetect`
---
## 11. 相关文件索引
| 类型 | 路径 |
|------|------|
| 方案总览 | `docs/help-code01.md` |
| Goose 网关规划 | `docs/memindadm-goose-gateway-design.md` |
| 可复用 adm 模式 | `skill-runtime-admin-config.mjs` |
| env↔adm 映射先例 | `memory-v2-admin-config.mjs` |
| 后端 code run 门禁 | `agent-run-routes.mjs` |
| 前端 autodetect | `src/utils/agentRunMode.ts` |
| 运行时只读 | `server.mjs` → `runtimeCodeRunPolicyStatus()` |
| Worker runbook | `docs/agent-run-worker-rollout-runbook.md` |
---
## 12. 一句话总结
**memindadm 管「策略」(谁、哪种 task、是否 page_data_dev);env 管「部署」(worker 是否 spawn Aider);H5 从 `/auth/status` 读运行时策略,不再依赖 rebuild VITE_。**
+18
View File
@@ -117,6 +117,24 @@ test('buildChatSavePreviewUrls use local api routes', () => {
);
});
test('chat page previews keep generated scripts sandboxed and proxy analytics through Portal', async () => {
const [pageSavePreview, chatSharePreview, viteConfig] = await Promise.all([
fs.readFile(new URL('./src/components/PageSavePreviewPanel.tsx', import.meta.url), 'utf8'),
fs.readFile(new URL('./src/components/ChatSharePreviewModal.tsx', import.meta.url), 'utf8'),
fs.readFile(new URL('./vite.config.ts', import.meta.url), 'utf8'),
]);
assert.match(pageSavePreview, /sandbox="allow-scripts allow-downloads"/);
assert.match(chatSharePreview, /sandbox="allow-scripts allow-popups allow-downloads"/);
for (const source of [pageSavePreview, chatSharePreview]) {
assert.doesNotMatch(
source,
/sandbox="[^"]*(?:allow-scripts[^"]*allow-same-origin|allow-same-origin[^"]*allow-scripts)[^"]*"/,
);
}
assert.match(viteConfig, /['"]\/analytics['"]\s*:\s*portalTarget/);
});
test('findPublishHtml resolves nested public html by basename', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-'));
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
+2
View File
@@ -11,6 +11,7 @@ import { ReviewPage } from './pages/ReviewPage';
import { SummaryPage } from './pages/admin/SummaryPage';
import { UsersPage } from './pages/admin/UsersPage';
import { BillingPage } from './pages/admin/BillingPage';
import { AgentCodeRunPage } from './pages/admin/AgentCodeRunPage';
import { WechatPage } from './pages/admin/WechatPage';
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
@@ -44,6 +45,7 @@ export function App() {
<Route index element={<SummaryPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="billing" element={<BillingPage />} />
<Route path="agent-code-run" element={<AgentCodeRunPage />} />
<Route path="wechat" element={<WechatPage />} />
<Route path="policy" element={<SystemPolicyPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
+78
View File
@@ -358,3 +358,81 @@ export async function resumeWechatDigest(id: string) {
method: 'POST',
});
}
// ─── Agent Code Run ───────────────────────────────────────────────────────────
export type AgentCodeRunConfigShape = {
codeRun: {
enabled: boolean;
clientEnabled: boolean;
generalAutodetect: boolean;
requireValidation: boolean;
userAllowlist: string[];
taskTypeAllowlist: string[];
};
pageDataDev: {
autodetect: boolean;
};
meta: {
notes: string;
};
};
export type AgentCodeRunAdminConfig = {
config: AgentCodeRunConfigShape;
updatedAt: number | null;
updatedBy: string | null;
source: string;
envOverrideActive: boolean;
};
export type AgentCodeRunRuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
config: AgentCodeRunConfigShape;
policy: {
source: string;
enabled: boolean;
clientEnabled: boolean;
generalAutodetect: boolean;
requireValidation: boolean;
pageDataDevAutodetect: boolean;
userAllowlist: string[];
taskTypeAllowlist: string[];
};
envOverrideActive: boolean;
};
export async function fetchAgentCodeRunConfig() {
return adminFetch<AgentCodeRunAdminConfig>('/admin-api/agent-code-run/config');
}
export async function fetchAgentCodeRunRuntime() {
return adminFetch<AgentCodeRunRuntimeState>('/admin-api/agent-code-run/runtime');
}
export async function patchAgentCodeRunConfig(body: { config: AgentCodeRunConfigShape }) {
return adminFetch<AgentCodeRunAdminConfig>('/admin-api/agent-code-run/config', {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export type AgentCodeRunHistoryRow = {
id: string;
userId: string;
requestId: string;
status: string;
taskType: string;
executor: string | null;
errorMessage: string | null;
createdAt: number | null;
updatedAt: number | null;
};
export async function fetchAgentCodeRunHistory(limit = 50) {
return adminFetch<{ runs: AgentCodeRunHistoryRow[]; limit: number }>(
`/admin-api/agent-code-run/runs?limit=${limit}`,
);
}
+1
View File
@@ -5,6 +5,7 @@ const links = [
{ to: '/admin/users', label: '用户管理' },
{ to: '/admin/billing', label: '账单记录' },
{ to: '/admin/wechat', label: '服务号管理' },
{ to: '/admin/agent-code-run', label: 'Code Run' },
{ to: '/admin/policy', label: '策略中心' },
];
+396
View File
@@ -0,0 +1,396 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
fetchAgentCodeRunConfig,
fetchAgentCodeRunHistory,
fetchAgentCodeRunRuntime,
patchAgentCodeRunConfig,
type AgentCodeRunAdminConfig,
type AgentCodeRunHistoryRow,
type AgentCodeRunRuntimeState,
} from '../../api/admin';
type FormState = {
enabled: boolean;
clientEnabled: boolean;
generalAutodetect: boolean;
requireValidation: boolean;
pageDataDevAutodetect: boolean;
userAllowlist: string;
taskTypeAllowlist: string;
notes: string;
};
function listToText(values: string[] | undefined) {
return (values ?? []).join(', ');
}
function textToList(value: string) {
return value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean);
}
function configToForm(config: AgentCodeRunAdminConfig['config']): FormState {
return {
enabled: Boolean(config.codeRun.enabled),
clientEnabled: Boolean(config.codeRun.clientEnabled),
generalAutodetect: Boolean(config.codeRun.generalAutodetect),
requireValidation: Boolean(config.codeRun.requireValidation),
pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect),
userAllowlist: listToText(config.codeRun.userAllowlist),
taskTypeAllowlist: listToText(config.codeRun.taskTypeAllowlist),
notes: config.meta?.notes ?? '',
};
}
function formToPatch(form: FormState) {
return {
config: {
codeRun: {
enabled: form.enabled,
clientEnabled: form.clientEnabled,
generalAutodetect: form.generalAutodetect,
requireValidation: form.requireValidation,
userAllowlist: textToList(form.userAllowlist),
taskTypeAllowlist: textToList(form.taskTypeAllowlist),
},
pageDataDev: {
autodetect: form.pageDataDevAutodetect,
},
meta: {
notes: form.notes,
},
},
};
}
function formatTime(value: number | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function ToggleRow({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
return (
<label style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'start' }}>
<span>
<strong>{label}</strong>
{hint ? <p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>{hint}</p> : null}
</span>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
style={{ width: 'auto', marginTop: 4 }}
/>
</label>
);
}
export function AgentCodeRunPage() {
const [configState, setConfigState] = useState<AgentCodeRunAdminConfig | null>(null);
const [runtimeState, setRuntimeState] = useState<AgentCodeRunRuntimeState | null>(null);
const [runHistory, setRunHistory] = useState<AgentCodeRunHistoryRow[]>([]);
const [form, setForm] = useState<FormState | null>(null);
const [jsonDraft, setJsonDraft] = useState('');
const [useJson, setUseJson] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const locked = Boolean(configState?.envOverrideActive);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [config, runtime, history] = await Promise.all([
fetchAgentCodeRunConfig(),
fetchAgentCodeRunRuntime(),
fetchAgentCodeRunHistory(30),
]);
setConfigState(config);
setRuntimeState(runtime);
setRunHistory(history.runs ?? []);
setForm(configToForm(config.config));
setJsonDraft(JSON.stringify(config.config, null, 2));
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const effectivePolicy = runtimeState?.policy;
const previewJson = useMemo(() => {
if (!form) return '';
return JSON.stringify(formToPatch(form).config, null, 2);
}, [form]);
async function handleSave() {
if (!form) return;
setSaving(true);
setMessage(null);
setError(null);
try {
let patch: ReturnType<typeof formToPatch>;
if (useJson) {
const parsed = JSON.parse(jsonDraft) as AgentCodeRunAdminConfig['config'];
patch = { config: parsed };
} else {
patch = formToPatch(form);
}
const saved = await patchAgentCodeRunConfig(patch);
setConfigState(saved);
setForm(configToForm(saved.config));
setJsonDraft(JSON.stringify(saved.config, null, 2));
setMessage('配置已保存');
const runtime = await fetchAgentCodeRunRuntime();
setRuntimeState(runtime);
const history = await fetchAgentCodeRunHistory(30);
setRunHistory(history.runs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
}
if (loading) return <p>加载中…</p>;
if (error && !form) return <p className="alert">{error}</p>;
if (!form || !configState) return <p className="alert">配置不可用</p>;
return (
<div className="grid">
<div className="card">
<h2 style={{ marginTop: 0 }}>Agent Code Run & Page Data Dev</h2>
<p style={{ color: '#68716c', marginTop: 0 }}>
控制 code run 灰度、H5 客户端开关与 Page Data 开发修复 autodetect。Worker 拓扑仍由 env 管理。
</p>
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
<p style={{ margin: 0 }}>
当前来源:<strong>{configState.source}</strong>
{configState.envOverrideActive ? (
<span className="warn"> · MEMIND_CODE_RUN_POLICY_SOURCE=env 已锁定后台修改</span>
) : null}
</p>
<p style={{ margin: 0 }}>更新时间:{formatTime(configState.updatedAt)}</p>
{effectivePolicy ? (
<p style={{ margin: 0 }}>
有效策略:server={effectivePolicy.enabled ? '开' : '关'} · client=
{effectivePolicy.clientEnabled ? '开' : '关'} · pageDataDev=
{effectivePolicy.pageDataDevAutodetect ? '开' : '关'}
</p>
) : null}
</div>
</div>
{locked ? (
<div className="card warn">
当前环境启用了 <code>MEMIND_CODE_RUN_POLICY_SOURCE=env</code>,后台无法写入 DB。请改 env 或去掉该变量后再保存。
</div>
) : null}
<div className="card" style={{ display: 'grid', gap: 16 }}>
<h3 style={{ margin: 0 }}>开关</h3>
<ToggleRow
label="Code Run 总开关(服务端)"
hint="POST /agent/runs 的 tool_mode=code 门禁"
checked={form.enabled}
disabled={locked}
onChange={(enabled) => setForm((current) => (current ? { ...current, enabled } : current))}
/>
<ToggleRow
label="H5 客户端启用"
hint="/auth/status.agentCodeRun.codeRun.enabled"
checked={form.clientEnabled}
disabled={locked}
onChange={(clientEnabled) => setForm((current) => (current ? { ...current, clientEnabled } : current))}
/>
<ToggleRow
label="Page Data Dev Autodetect"
hint="聊天识别「修问卷 / verify 失败」→ page_data_dev"
checked={form.pageDataDevAutodetect}
disabled={locked}
onChange={(pageDataDevAutodetect) =>
setForm((current) => (current ? { ...current, pageDataDevAutodetect } : current))
}
/>
<ToggleRow
label="通用 Code Autodetect"
hint="普通聊天文本识别代码任务(默认建议关闭)"
checked={form.generalAutodetect}
disabled={locked}
onChange={(generalAutodetect) =>
setForm((current) => (current ? { ...current, generalAutodetect } : current))
}
/>
<ToggleRow
label="强制 Validation"
hint="code run 必须带 expectedFiles / receipt"
checked={form.requireValidation}
disabled={locked}
onChange={(requireValidation) =>
setForm((current) => (current ? { ...current, requireValidation } : current))
}
/>
</div>
<div className="card" style={{ display: 'grid', gap: 12 }}>
<h3 style={{ margin: 0 }}>白名单</h3>
<label style={{ display: 'grid', gap: 6 }}>
<strong>用户 UUID(逗号或换行分隔,留空=全部)</strong>
<textarea
rows={3}
value={form.userAllowlist}
disabled={locked}
onChange={(event) =>
setForm((current) => (current ? { ...current, userAllowlist: event.target.value } : current))
}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<strong>taskType 允许列表</strong>
<textarea
rows={3}
value={form.taskTypeAllowlist}
disabled={locked}
onChange={(event) =>
setForm((current) => (current ? { ...current, taskTypeAllowlist: event.target.value } : current))
}
/>
</label>
<label style={{ display: 'grid', gap: 6 }}>
<strong>备注</strong>
<input
value={form.notes}
disabled={locked}
onChange={(event) => setForm((current) => (current ? { ...current, notes: event.target.value } : current))}
/>
</label>
</div>
<div className="card" style={{ display: 'grid', gap: 12 }}>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="checkbox"
checked={useJson}
disabled={locked}
onChange={(event) => setUseJson(event.target.checked)}
style={{ width: 'auto' }}
/>
<strong>高级:直接编辑 JSON config</strong>
</label>
{useJson ? (
<textarea
rows={16}
value={jsonDraft}
disabled={locked}
onChange={(event) => setJsonDraft(event.target.value)}
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}
/>
) : (
<pre
style={{
margin: 0,
padding: 12,
borderRadius: 8,
background: '#f8f4ea',
overflow: 'auto',
fontSize: 12,
}}
>
{previewJson}
</pre>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button className="btn" type="button" disabled={saving || locked} onClick={() => void handleSave()}>
{saving ? '保存中…' : '保存配置'}
</button>
<button className="btn secondary" type="button" disabled={loading} onClick={() => void load()}>
重新加载
</button>
</div>
{message ? <p style={{ color: '#2f6f57' }}>{message}</p> : null}
{error ? <p className="alert">{error}</p> : null}
{runtimeState ? (
<div className="card">
<h3 style={{ marginTop: 0 }}>运行时只读</h3>
<pre
style={{
margin: 0,
padding: 12,
borderRadius: 8,
background: '#f8f4ea',
overflow: 'auto',
fontSize: 12,
}}
>
{JSON.stringify(runtimeState, null, 2)}
</pre>
</div>
) : null}
<div className="card">
<h3 style={{ marginTop: 0 }}>Page Data Dev Run 历史</h3>
{runHistory.length === 0 ? (
<p style={{ color: '#68716c', margin: 0 }}>暂无 page_data_dev / page_data_dev_complex 记录</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>时间</th>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>用户</th>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>taskType</th>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>executor</th>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>状态</th>
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>requestId</th>
</tr>
</thead>
<tbody>
{runHistory.map((row) => (
<tr key={row.id}>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{formatTime(row.updatedAt)}</td>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
{row.userId.slice(0, 8)}…
</td>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.taskType}</td>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.executor ?? '—'}</td>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.status}</td>
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
{row.requestId}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+3
View File
@@ -65,6 +65,9 @@ export function SummaryPage() {
<Link className="btn" to="/admin/wechat" style={{ textAlign: 'center', textDecoration: 'none' }}>
进入通知推送
</Link>
<Link className="btn secondary" to="/admin/agent-code-run" style={{ textAlign: 'center', textDecoration: 'none' }}>
Code Run 配置
</Link>
<Link
className="btn secondary"
to="/admin/users"
+3
View File
@@ -60,6 +60,9 @@
"pretest": "node --test episodic-memory.test.mjs",
"test:scenario": "node scripts/run-scenario-test.mjs",
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
"dev:page-data-aider-loop": "node scripts/page-data-aider-dev-loop.mjs",
"ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs",
"migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs",
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
+32
View File
@@ -0,0 +1,32 @@
{
"id": "customer-order-system",
"name": "客户下单系统(Page Data 前后台)",
"description": "用户话术 → Goose page-data-collect 生成下单页 + 管理后台 → 验证 Page Data 交付",
"account": {
"username": "john4",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "创建客户下单系统(前端下单 + 后台管理)",
"message": "帮我做一个客户下单系统,分前端和后端两个页面:\n\n【前端下单页】客户填写:客户姓名、联系电话、商品名称、购买数量、收货地址、备注(选填),提交后保存到数据库。\n\n【管理后台页】密码 88888888,能查看所有订单列表,显示下单时间和状态,能按商品名称做简单统计,支持导出 CSV。\n\n要求:使用 Page Data 公开 API(page-data-client.js),不要自建后端;页面简洁可用;做完把下单页链接和后台链接发给我。",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
"timeoutMs": 600000,
"replyKeywords": ["下单", "后台"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
"survey": {
"requirePolicy": true,
"requireDataset": true,
"forbidHtmlPatterns": ["localStorage", "sessionStorage", "fetch('/api/"]
}
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
/**
* CI/staging smoke for Page Data dev loop (dry-run only, no Aider spawn).
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const script = path.join(repoRoot, 'scripts/page-data-aider-dev-loop.mjs');
const result = spawnSync(
process.execPath,
[script, '--dry-run', '--skip-initial-verify', '--max-attempts', '1', '--escalate-after', '1'],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exit(result.status ?? 1);
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* 将当前 env 中的 code-run 灰度配置写入 h5_agent_code_run_config(一次性迁移)。
*
* Usage:
* node scripts/migrate-agent-code-run-config-from-env.mjs
* node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
* node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by <admin-user-id>
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import {
agentCodeRunAdminConfigInternals,
createAgentCodeRunAdminConfigService,
} from '../agent-code-run-admin-config.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function parseArgs(argv) {
const options = { dryRun: false, updatedBy: null, force: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--dry-run') options.dryRun = true;
else if (arg === '--force') options.force = true;
else if (arg === '--updated-by' && argv[i + 1]) options.updatedBy = argv[++i];
else if (arg === '-h' || arg === '--help') {
console.log('Usage: node scripts/migrate-agent-code-run-config-from-env.mjs [--dry-run] [--force] [--updated-by <uuid>]');
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return options;
}
async function main() {
loadH5Environment(import.meta.dirname);
const options = parseArgs(process.argv.slice(2));
if (String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env') {
throw new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不应写入 admin-db');
}
const pool = createDbPool();
const service = createAgentCodeRunAdminConfigService(pool, { env: process.env });
const current = await service.getAdminConfig();
const envConfig = agentCodeRunAdminConfigInternals.buildConfigFromEnv(process.env);
console.log('==> migrate agent code run config from env');
console.log(` current source: ${current.source}`);
console.log(` env enabled: ${envConfig.codeRun.enabled}`);
console.log(` env clientEnabled: ${envConfig.codeRun.clientEnabled}`);
console.log(` env pageDataDev.autodetect: ${envConfig.pageDataDev.autodetect}`);
if (current.source === 'admin-db' && !options.force) {
console.log('\nadmin-db 已有配置;加 --force 才会覆盖');
await pool.end();
process.exit(0);
}
if (options.dryRun) {
console.log('\n(dry-run) 将写入:');
console.log(JSON.stringify(envConfig, null, 2));
await pool.end();
process.exit(0);
}
const saved = await service.updateAdminConfig(
{ config: envConfig, meta: { notes: 'migrated from env via migrate-agent-code-run-config-from-env.mjs' } },
{ updatedBy: options.updatedBy },
);
console.log('\n✔ 已写入 admin-db');
console.log(JSON.stringify(saved, null, 2));
await pool.end();
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+458
View File
@@ -0,0 +1,458 @@
#!/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,
};
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import {
VERIFY_PRESETS,
buildPageDataDevInstruction,
parseDevLoopArgs,
resolveDevLoopExecutor,
resolveVerifyPreset,
resolveWorkspaceCwd,
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
} from './page-data-aider-dev-loop.mjs';
test('parseDevLoopArgs applies defaults and flags', () => {
const options = parseDevLoopArgs([
'--preset',
'page-data-platform',
'--max-attempts',
'2',
'--dry-run',
'--no-insert',
]);
assert.equal(options.preset, 'page-data-platform');
assert.equal(options.maxAttempts, 2);
assert.equal(options.dryRun, true);
assert.equal(options.testInsert, false);
});
test('resolveVerifyPreset rejects unknown preset', () => {
assert.throws(() => resolveVerifyPreset('missing'), /未知 preset/);
assert.equal(resolveVerifyPreset('children-hobby-diet-survey').scenario, 'workspace');
});
test('resolveWorkspaceCwd maps workspace and platform scenarios', () => {
const root = '/repo';
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: 'user-1',
cwdOverride: null,
scenario: 'workspace',
}),
path.join(root, 'MindSpace', 'user-1'),
);
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: null,
cwdOverride: '/custom',
scenario: 'workspace',
}),
'/custom',
);
assert.equal(
resolveWorkspaceCwd({
repoRoot: root,
userId: null,
cwdOverride: null,
scenario: 'platform',
}),
root,
);
});
test('buildPageDataDevInstruction includes verify output and targets', () => {
const instruction = buildPageDataDevInstruction({
verifyOutput: '✘ 问卷提交字段: insertRow 未包含 q4_diet_meals',
cwd: '/repo/MindSpace/user-1',
targetFiles: ['public/survey.html', '.mindspace/page-data-policies/page.json'],
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
});
assert.match(instruction, /Page Data 开发修复任务/);
assert.match(instruction, /q4_diet_meals/);
assert.match(instruction, /public\/survey\.html/);
assert.match(instruction, /npm run verify:children-hobby-diet-survey/);
assert.match(instruction, /page-data-client\.js/);
});
test('resolveDevLoopExecutor escalates to openhands after N aider rounds', () => {
assert.deepEqual(resolveDevLoopExecutor(1, { escalateAfter: 2 }), {
executor: 'aider',
taskType: 'page_data_dev',
escalated: false,
});
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 2 }), {
executor: 'openhands',
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
escalated: true,
});
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 0 }), {
executor: 'aider',
taskType: 'page_data_dev',
escalated: false,
});
});
test('buildPageDataDevInstruction marks openhands escalation', () => {
const instruction = buildPageDataDevInstruction({
verifyOutput: 'failed',
cwd: '/repo/MindSpace/u1',
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
escalated: true,
verifyCommandLabel: 'npm run verify:page-data',
});
assert.match(instruction, /OpenHands 接手/);
assert.match(instruction, /page_data_dev_complex/);
});
test('children-hobby preset builds verify args with user id', () => {
const preset = VERIFY_PRESETS['children-hobby-diet-survey'];
const args = preset.buildVerifyArgs({
userId: 'abc',
testInsert: false,
checkRuns: false,
port: 9090,
});
assert.deepEqual(args, [
'scripts/verify-children-hobby-diet-survey.mjs',
'--user-id',
'abc',
'--no-insert',
'--no-runs',
'--port',
'9090',
]);
});
+3 -1
View File
@@ -386,7 +386,9 @@ export async function verifySurveyDelivery({
return false;
}
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
const surveyLike = htmlFiles.filter(
(name) => /survey|问卷|feature|order|下单|submit|form|customer/i.test(name) && !/admin|后台|manage/i.test(name),
);
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
if (surveyLike.length === 0) {
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
/**
* john4 客户下单系统(Page Data 前后台)演示搭建。
* Usage: node scripts/setup-customer-order-demo.mjs
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
import { createUserDataSpaceService } from '../user-data-space-service.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(import.meta.dirname);
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', USER_ID);
const ADMIN_PASSWORD = '88888888';
const DATASET = 'customer_orders';
const ORDER_FORM_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客户下单</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(135deg,#0f766e,#115e59); min-height: 100vh; padding: 32px 16px; }
.card { max-width: 640px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 28px; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
h1 { text-align: center; margin-bottom: 8px; color: #134e4a; }
p.sub { text-align: center; color: #64748b; margin-bottom: 24px; font-size: 14px; }
label { display: block; font-weight: 600; margin: 14px 0 6px; color: #334155; }
input, textarea { width: 100%; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; font: inherit; }
textarea { min-height: 80px; resize: vertical; }
button { width: 100%; margin-top: 20px; padding: 14px; border: 0; border-radius: 12px; background: #0f766e; color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; }
button:disabled { opacity: .6; cursor: not-allowed; }
.msg { margin-top: 16px; padding: 12px; border-radius: 10px; display: none; }
.msg.ok { display: block; background: #ecfdf5; color: #047857; }
.msg.err { display: block; background: #fef2f2; color: #b91c1c; }
</style>
</head>
<body>
<div class="card">
<h1>客户下单</h1>
<p class="sub">请填写订单信息,提交后将保存到 Page Data。</p>
<form id="orderForm">
<label>客户姓名 *</label><input id="customer_name" required>
<label>联系电话 *</label><input id="phone" required>
<label>商品名称 *</label><input id="product_name" required>
<label>购买数量 *</label><input id="quantity" type="number" min="1" value="1" required>
<label>收货地址 *</label><textarea id="address" required></textarea>
<label>备注</label><textarea id="remark"></textarea>
<button type="submit" id="submitBtn">提交订单</button>
</form>
<div class="msg" id="message"></div>
</div>
<script src="/assets/page-data-client.js"></script>
<script>
(function () {
var client = MindSpacePageData.createClient({ apiBase: '/api' });
var form = document.getElementById('orderForm');
var message = document.getElementById('message');
form.addEventListener('submit', async function (e) {
e.preventDefault();
message.className = 'msg';
message.style.display = 'none';
var payload = {
customer_name: document.getElementById('customer_name').value.trim(),
phone: document.getElementById('phone').value.trim(),
product_name: document.getElementById('product_name').value.trim(),
quantity: String(document.getElementById('quantity').value || '1'),
address: document.getElementById('address').value.trim(),
remark: document.getElementById('remark').value.trim(),
status: '待处理'
};
if (!payload.customer_name || !payload.phone || !payload.product_name || !payload.address) {
message.className = 'msg err'; message.textContent = '请填写所有必填项'; message.style.display = 'block'; return;
}
document.getElementById('submitBtn').disabled = true;
try {
await client.insertRow('customer_orders', payload);
message.className = 'msg ok'; message.textContent = '下单成功!';
message.style.display = 'block';
form.reset();
document.getElementById('quantity').value = '1';
} catch (err) {
message.className = 'msg err'; message.textContent = '提交失败:' + (err.message || err);
message.style.display = 'block';
} finally {
document.getElementById('submitBtn').disabled = false;
}
});
})();
</script>
</body>
</html>`;
const ORDER_ADMIN_HTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单管理后台</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f1f5f9; min-height: 100vh; padding: 24px 16px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.hero { background: #0f766e; color: #fff; border-radius: 14px; padding: 24px; margin-bottom: 16px; }
.panel { background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 24px rgba(15,23,42,.06); }
input, button { font: inherit; }
.auth input { width: 100%; max-width: 280px; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; margin-right: 8px; }
.auth button, .toolbar button { padding: 10px 16px; border: 0; border-radius: 8px; background: #0f766e; color: #fff; cursor: pointer; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
th, td { padding: 10px 8px; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
th { background: #f8fafc; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0; }
.stat { background: #ecfdf5; color: #065f46; padding: 10px 14px; border-radius: 10px; font-size: 13px; }
.hidden { display: none; }
.err { color: #b91c1c; margin-top: 8px; }
</style>
</head>
<body>
<div class="wrap">
<div class="hero"><h1>客户订单管理后台</h1><p>查看订单、按商品统计、导出 CSV</p></div>
<div class="panel auth" id="authBox">
<h2 style="margin-bottom:12px">请输入管理口令</h2>
<input type="password" id="passwordInput" placeholder="管理口令">
<button id="authBtn">进入后台</button>
<div class="err hidden" id="authError"></div>
</div>
<div class="panel hidden" id="dash">
<div class="toolbar">
<button id="refreshBtn">刷新</button>
<button id="exportBtn">导出 CSV</button>
</div>
<div class="stats" id="stats"></div>
<table>
<thead><tr><th>#</th><th>客户</th><th>电话</th><th>商品</th><th>数量</th><th>地址</th><th>状态</th><th>时间</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<script src="/assets/page-data-client.js"></script>
<script>
(function () {
var client = MindSpacePageData.createClient({ apiBase: '/api' });
var rowsCache = [];
document.getElementById('authBtn').addEventListener('click', async function () {
try {
await client.authenticate(document.getElementById('passwordInput').value.trim());
document.getElementById('authBox').classList.add('hidden');
document.getElementById('dash').classList.remove('hidden');
await loadRows();
} catch (e) {
var err = document.getElementById('authError');
err.textContent = '口令错误';
err.classList.remove('hidden');
}
});
document.getElementById('refreshBtn').addEventListener('click', loadRows);
document.getElementById('exportBtn').addEventListener('click', exportCsv);
async function loadRows() {
var result = await client.listRows('customer_orders', { limit: 200 });
rowsCache = result.rows || [];
renderStats(rowsCache);
renderTable(rowsCache);
}
function renderStats(rows) {
var counts = {};
rows.forEach(function (r) { counts[r.product_name] = (counts[r.product_name] || 0) + 1; });
document.getElementById('stats').innerHTML = Object.keys(counts).map(function (k) {
return '<div class="stat">' + k + ':' + counts[k] + ' 单</div>';
}).join('') || '<div class="stat">暂无订单</div>';
}
function renderTable(rows) {
var tbody = document.getElementById('tbody');
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="8">暂无数据</td></tr>'; return; }
tbody.innerHTML = rows.map(function (r, i) {
return '<tr><td>' + (i+1) + '</td><td>' + esc(r.customer_name) + '</td><td>' + esc(r.phone) +
'</td><td>' + esc(r.product_name) + '</td><td>' + esc(r.quantity) + '</td><td>' + esc(r.address) +
'</td><td>' + esc(r.status) + '</td><td>' + esc(r.created_at || '') + '</td></tr>';
}).join('');
}
function exportCsv() {
var header = ['customer_name','phone','product_name','quantity','address','remark','status','created_at'];
var lines = [header.join(',')].concat(rowsCache.map(function (r) {
return header.map(function (k) { return '"' + String(r[k] ?? '').replace(/"/g, '""') + '"'; }).join(',');
}));
var blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8' });
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'customer-orders.csv'; a.click();
}
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, function (c) { return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]); }); }
})();
</script>
</body>
</html>`;
const FORM_POLICY = {
accessMode: 'public',
datasets: {
customer_orders: {
insert: true,
read: false,
columns: {
insert: ['customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status'],
},
},
},
};
const ADMIN_POLICY = {
accessMode: 'password',
datasets: {
customer_orders: {
insert: false,
read: true,
columns: {
read: ['id', 'customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status', 'created_at'],
},
},
},
};
async function main() {
const publicDir = path.join(WORKSPACE_ROOT, 'public');
await fs.mkdir(publicDir, { recursive: true });
await fs.writeFile(path.join(publicDir, 'customer-order-form.html'), ORDER_FORM_HTML, 'utf8');
await fs.writeFile(path.join(publicDir, 'customer-order-admin.html'), ORDER_ADMIN_HTML, 'utf8');
const dataSpace = createUserDataSpaceService({ workspaceRoot: WORKSPACE_ROOT, userId: USER_ID });
await dataSpace.executeSql(`CREATE TABLE IF NOT EXISTS customer_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
quantity TEXT NOT NULL DEFAULT '1',
address TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '待处理',
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);`);
await dataSpace.upsertDataset({
name: DATASET,
table: DATASET,
description: '客户下单系统订单表',
actions: ['read', 'insert'],
columns: {
insert: FORM_POLICY.datasets.customer_orders.columns.insert,
read: ADMIN_POLICY.datasets.customer_orders.columns.read,
},
});
const pool = createDbPool();
const storageRoot = resolveMindSpaceStorageRoot(root);
const pages = [
{ relativePath: 'public/customer-order-form.html', accessMode: 'public', password: null, policy: FORM_POLICY },
{ relativePath: 'public/customer-order-admin.html', accessMode: 'password', password: ADMIN_PASSWORD, policy: ADMIN_POLICY },
];
console.log('==> 客户下单系统 Page Data 演示\n');
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root: root,
storageRoot,
userId: USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
console.log(`✓ ${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` URL: ${result.workspaceUrl}\n`);
}
console.log('后台口令:', ADMIN_PASSWORD);
await pool.end();
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* 验证 john4 客户下单系统 Page Data 链路(insert + admin read)。
*/
import { loadH5Environment } from './load-env.mjs';
import {
createReporter,
loginViaApi,
resolvePortalBase,
} from './scenario-test-lib.mjs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
import fs from 'node:fs/promises';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
const DATASET = 'customer_orders';
const FORM_PAGE = 'customer-order-form.html';
const ADMIN_PAGE = 'customer-order-admin.html';
loadH5Environment(import.meta.dirname);
async function findPageIdFromPolicy(publishKey) {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
const entries = await fs.readdir(policyDir);
for (const name of entries.filter((item) => item.endsWith('.json'))) {
const raw = await fs.readFile(path.join(policyDir, name), 'utf8');
const policy = JSON.parse(raw);
if (policy?.datasets?.[DATASET]?.insert) {
return { pageId: policy.pageId, fileName: name };
}
}
return null;
}
async function main() {
const port = Number(process.env.H5_PORT ?? 8081);
const baseUrl = resolvePortalBase(port);
const reporter = createReporter();
console.log('==> 客户下单系统 Page Data 验证');
console.log(` Portal: ${baseUrl}`);
console.log(` 用户: ${USER_ID}\n`);
const auth = await loginViaApi(baseUrl, { username: 'john4', password: '888888' }, reporter);
const publishKey = auth.user?.id ?? USER_ID;
const formUrl = `${baseUrl}/MindSpace/${publishKey}/public/${FORM_PAGE}`;
const adminUrl = `${baseUrl}/MindSpace/${publishKey}/public/${ADMIN_PAGE}`;
for (const [label, url] of [['下单页', formUrl], ['后台页', adminUrl]]) {
const res = await fetch(url, { headers: { Cookie: auth.cookie } });
const html = await res.text();
if (res.status !== 200) {
reporter.fail(`${label} HTTP`, `${res.status}`);
} else {
reporter.pass(`${label} HTTP 200`, url);
}
if (!html.includes('page-data-client.js')) {
reporter.fail(`${label} 脚本`, '缺少 page-data-client.js');
} else {
reporter.pass(`${label} Page Data 客户端`, '已引用');
}
}
const formMeta = await findPageIdFromPolicy(publishKey);
if (!formMeta?.pageId) {
reporter.fail('Page Data policy', '未找到 customer_orders insert policy');
process.exit(reporter.summary());
}
reporter.pass('Page Data policy', `${formMeta.fileName} pageId=${formMeta.pageId}`);
const insertPayload = {
customer_name: '测试客户',
phone: '13800138000',
product_name: '演示商品A',
quantity: '2',
address: '上海市浦东新区测试路 1 号',
remark: '自动化验证',
status: '待处理',
};
const insertRes = await fetch(
`${baseUrl}/api/public/pages/${formMeta.pageId}/data/${DATASET}/rows`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
body: JSON.stringify(insertPayload),
},
);
const insertBody = await insertRes.json().catch(() => ({}));
if (!insertRes.ok) {
reporter.fail('下单 insert', `${insertRes.status} ${JSON.stringify(insertBody)}`);
} else {
reporter.pass('下单 insert', `row id=${insertBody?.data?.id ?? insertBody?.id ?? 'ok'}`);
}
const adminMeta = await (async () => {
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
const entries = await fs.readdir(policyDir);
for (const name of entries.filter((item) => item.endsWith('.json'))) {
const policy = JSON.parse(await fs.readFile(path.join(policyDir, name), 'utf8'));
if (policy?.datasets?.[DATASET]?.read) return policy.pageId;
}
return null;
})();
if (!adminMeta) {
reporter.fail('后台 policy', '未找到 read policy');
} else {
const tokenRes = await fetch(`${baseUrl}/api/public/pages/${adminMeta}/data-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
body: JSON.stringify({ password: '88888888' }),
});
const tokenBody = await tokenRes.json().catch(() => ({}));
const token = tokenBody?.data?.token ?? tokenBody?.token;
if (!tokenRes.ok || !token) {
reporter.fail('后台口令认证', `${tokenRes.status}`);
} else {
const listRes = await fetch(
`${baseUrl}/api/public/pages/${adminMeta}/data/${DATASET}?limit=20`,
{ headers: { 'x-page-data-token': token, Cookie: auth.cookie } },
);
const listBody = await listRes.json().catch(() => ({}));
const rows = listBody?.data?.rows ?? listBody?.rows ?? [];
const hit = rows.some((row) => row.customer_name === insertPayload.customer_name && row.product_name === insertPayload.product_name);
if (!listRes.ok || !hit) {
reporter.fail('后台读订单', `rows=${rows.length}, 未找到测试订单`);
} else {
reporter.pass('后台读订单', `共 ${rows.length} 条,含测试订单`);
}
}
}
const sqlitePath = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'private-data.sqlite');
try {
await fs.stat(sqlitePath);
reporter.pass('用户数据空间', 'private-data.sqlite 存在');
} catch {
reporter.pass('用户数据空间', 'PostgreSQL 模式(无本地 sqlite 文件)');
}
console.log('\n--- 访问入口 ---');
console.log(`下单页: ${formUrl}`);
console.log(`后台页: ${adminUrl} (口令 88888888)`);
process.exit(reporter.summary());
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});
+38 -8
View File
@@ -216,6 +216,7 @@ import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createEpisodicMemoryService } from './episodic-memory.mjs';
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
@@ -388,6 +389,7 @@ let episodicMemoryService = null;
let memoryV2ConfigService = null;
let skillRuntimeConfigService = null;
let systemDisclosurePolicyService = null;
let agentCodeRunPolicyService = null;
let wechatScheduleLlmConfigService = null;
let mindSpace = null;
let mindSpaceAssets = null;
@@ -725,6 +727,7 @@ async function bootstrapUserAuth() {
skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname });
systemDisclosurePolicyService = createSystemDisclosurePolicyService(pool);
await systemDisclosurePolicyService.initialize();
agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
conversationMemoryService = createConversationMemoryService(pool, {
llmProviderService,
@@ -1033,6 +1036,16 @@ async function resolveSkillRuntimeForClient() {
}
}
async function resolveAgentCodeRunForClient(userId) {
if (!agentCodeRunPolicyService?.getPublicClientPolicy) return null;
try {
return await agentCodeRunPolicyService.getPublicClientPolicy(userId);
} catch (err) {
console.warn('[AgentCodeRun] public policy unavailable:', err instanceof Error ? err.message : err);
return null;
}
}
app.get('/auth/status', async (req, res) => {
await userAuthReady;
if (userAuth) {
@@ -1042,6 +1055,7 @@ app.get('/auth/status', async (req, res) => {
const row = await userAuth.getUserById(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(row);
const skillRuntime = await resolveSkillRuntimeForClient();
const agentCodeRun = await resolveAgentCodeRunForClient(me.id);
return res.json({
authenticated: true,
user: me,
@@ -1050,6 +1064,7 @@ app.get('/auth/status', async (req, res) => {
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
skillRuntime,
agentCodeRun,
});
} catch (err) {
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
@@ -2238,12 +2253,21 @@ function runtimeCsvList(value) {
}
function runtimeCodeRunPolicyStatus() {
return {
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
};
return agentCodeRunPolicyService?.getRuntimeStatusSummary
? agentCodeRunPolicyService.getRuntimeStatusSummary()
: Promise.resolve({
source: 'env',
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
clientEnabled: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_ENABLED),
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
pageDataDevAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT),
generalAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT),
envOverrideActive: String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env',
updatedAt: null,
updatedBy: null,
});
}
api.get('/runtime/status', async (_req, res) => {
@@ -2271,7 +2295,7 @@ api.get('/runtime/status', async (_req, res) => {
if (toolQueue) {
status.toolRuntime = {
...(status.toolRuntime ?? {}),
codeRunPolicy: runtimeCodeRunPolicyStatus(),
codeRunPolicy: await runtimeCodeRunPolicyStatus(),
queue: toolQueue,
};
}
@@ -5109,7 +5133,13 @@ api.post('/agent/runs', async (req, res, next) => {
[
tkmindProxy.requireUser,
tkmindProxy.ensureChatAllowed,
createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }),
createPostAgentRunsHandler({
userAuth,
sessionAccess,
agentRunGateway,
mindSpaceAssetAgent,
codeRunPolicyService: agentCodeRunPolicyService,
}),
],
req,
res,
+2
View File
@@ -42,6 +42,7 @@ import type {
} from '../types';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
import { applyAgentCodeRunClientPolicy } from '../utils/agentRunMode';
import {
API,
ApiError,
@@ -319,6 +320,7 @@ export async function checkAuth(): Promise<AuthStatus> {
return { authenticated: false };
}
if (status.authenticated) resetUnauthorizedGuard();
applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null);
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
try {
const me = await getMe();
+1 -1
View File
@@ -139,7 +139,7 @@ export function ChatSharePreviewModal({
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
<iframe src={previewUrl} title="页面预览" sandbox="allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
+1 -1
View File
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
title="页面预览"
src={pagePreviewUrl}
className="page-save-mini-page-frame"
sandbox="allow-scripts allow-same-origin allow-downloads"
sandbox="allow-scripts allow-downloads"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
+3 -2
View File
@@ -63,7 +63,7 @@ import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
} from '../utils/imageUpload';
import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards';
import { resolveAgentRunOptions } from '../utils/agentRunMode';
import { resolveAgentRunOptions, resolvePageDataDevTaskType } from '../utils/agentRunMode';
import {
buildUserMessage,
normalizeConversationMessages,
@@ -1472,8 +1472,9 @@ export function useTKMindChat(
}
try {
const pageDataDevTaskType = resolvePageDataDevTaskType(trimmed);
const runOptions = resolveAgentRunOptions(trimmed, {
taskType: 'h5_chat_code_task',
taskType: pageDataDevTaskType ?? 'h5_chat_code_task',
userId: userRef.current?.id ?? null,
requestId,
mindspaceContext: options?.mindspaceContext ?? null,
+9
View File
@@ -905,6 +905,14 @@ export type PolicyDefinition = {
risk: 'low' | 'medium' | 'high';
};
export type AgentCodeRunClientPolicy = {
codeRun?: {
enabled?: boolean;
pageDataDevAutodetect?: boolean;
generalAutodetect?: boolean;
};
};
export type AuthStatus = {
authenticated: boolean;
mode?: 'user' | 'legacy' | 'none' | 'unavailable';
@@ -913,6 +921,7 @@ export type AuthStatus = {
capabilities?: CapabilityMap;
grantedSkills?: string[];
unrestricted?: boolean;
agentCodeRun?: AgentCodeRunClientPolicy | null;
};
export type PathGrant = {
+88 -9
View File
@@ -1,4 +1,4 @@
import type { MindSpaceChatContext } from '../types';
import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types';
export type AgentRunCreateOptions = {
toolMode?: 'chat' | 'code';
@@ -28,6 +28,38 @@ export const agentCodeRunsEnabled = envFlag(import.meta.env.VITE_AGENT_CODE_RUNS
export const agentCodeRunsAutodetectEnabled = envFlag(
import.meta.env.VITE_AGENT_CODE_RUNS_AUTODETECT,
);
export const agentPageDataDevAutodetectEnabled = envFlag(
import.meta.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT,
);
let runtimeAgentCodeRunPolicy: AgentCodeRunClientPolicy | null = null;
export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) {
runtimeAgentCodeRunPolicy = policy;
}
function clientCodeRunsEnabled(): boolean {
if (runtimeAgentCodeRunPolicy?.codeRun?.enabled != null) {
return runtimeAgentCodeRunPolicy.codeRun.enabled;
}
return agentCodeRunsEnabled;
}
function clientCodeRunsAutodetectEnabled(): boolean {
if (runtimeAgentCodeRunPolicy?.codeRun?.generalAutodetect != null) {
return runtimeAgentCodeRunPolicy.codeRun.generalAutodetect;
}
return agentCodeRunsAutodetectEnabled;
}
function clientPageDataDevAutodetectEnabled(): boolean {
if (runtimeAgentCodeRunPolicy?.codeRun?.pageDataDevAutodetect != null) {
return runtimeAgentCodeRunPolicy.codeRun.pageDataDevAutodetect;
}
return agentPageDataDevAutodetectEnabled;
}
export const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
function parseUserIdSet(value: unknown): Set<string> {
return new Set(
@@ -41,11 +73,32 @@ function parseUserIdSet(value: unknown): Set<string> {
const agentCodeRunUserIds = parseUserIdSet(import.meta.env.VITE_AGENT_CODE_RUNS_USER_IDS);
export function agentCodeRunsEnabledForUser(userId?: string | null): boolean {
if (!agentCodeRunsEnabled) return false;
if (!clientCodeRunsEnabled()) return false;
if (agentCodeRunUserIds.size === 0) return true;
return Boolean(userId && agentCodeRunUserIds.has(userId));
}
const PAGE_DATA_DEV_TASK_PATTERNS = [
/columns_not_allowed/i,
/\bpage[\s-]?data[\s-]?dev\b/i,
/(?:insert|提交|写入).{0,16}(?:失败|403|报错|error)/iu,
/(?:修复|修|排查|debug|fix).{0,24}(?:问卷|page[\s-]?data|数据提交|插入|admin|后台|policy|绑定|bind)/iu,
/(?:问卷|page[\s-]?data|数据页|表单页).{0,24}(?:bug|坏了|不通|失败|有问题)/iu,
/(?:测试|verify).{0,16}(?:没过|失败|不通过)/iu,
/(?:repair|修复).{0,16}(?:bind|绑定|policy|策略)/iu,
];
export function isPageDataDevTaskText(text: string): boolean {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_DATA_DEV_TASK_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function resolvePageDataDevTaskType(text: string): string | null {
if (!clientPageDataDevAutodetectEnabled()) return null;
return isPageDataDevTaskText(text) ? PAGE_DATA_DEV_TASK_TYPE : null;
}
const CODE_TASK_PATTERNS = [
/\b(repo|repository|branch|commit|pull request|pr|diff|patch)\b/i,
/\b(aider|openhands|codex|codebase|workspace)\b/i,
@@ -185,6 +238,22 @@ export function buildAgentRunTaskValidation({
);
}
if (taskType === PAGE_DATA_DEV_TASK_TYPE) {
const taskReceiptPath = `.memind/agent-runs/${safeRequestId}-page-data-dev.json`;
expectedFiles.push({ path: taskReceiptPath, contains: PAGE_DATA_DEV_TASK_TYPE });
instructions.push(
'',
'[Memind page-data-dev validation]',
`Before finishing this Page Data dev repair task, create or update ${taskReceiptPath}.`,
'The file must be valid JSON and include:',
`- requestId: ${requestId}`,
`- taskType: ${PAGE_DATA_DEV_TASK_TYPE}`,
'- a brief summary of HTML/policy fixes or why no file change was needed.',
'Only modify public/*.html and .mindspace/page-data-policies/*.json unless explicitly told otherwise.',
'Do not recreate datasets or call private_data_execute unless the user pasted schema errors.',
);
}
return {
validation: expectedFiles.length ? { expectedFiles } : null,
instruction: instructions.join('\n'),
@@ -221,7 +290,8 @@ export function resolveAgentRunOptions(
{
taskType = 'code_task',
forceCode = false,
allowAutodetect = agentCodeRunsAutodetectEnabled,
allowAutodetect = clientCodeRunsAutodetectEnabled(),
allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(),
userId = null,
requestId = null,
mindspaceContext = null,
@@ -230,6 +300,7 @@ export function resolveAgentRunOptions(
taskType?: string;
forceCode?: boolean;
allowAutodetect?: boolean;
allowPageDataDevAutodetect?: boolean;
userId?: string | null;
requestId?: string | null;
mindspaceContext?: MindSpaceChatContext | null;
@@ -237,29 +308,37 @@ export function resolveAgentRunOptions(
} = {},
): AgentRunCreateOptions {
const normalizedText = String(text ?? '').trim();
const pageDataDevTaskType =
allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText);
const effectiveTaskType = pageDataDevTaskType ?? taskType;
const shouldUseDeepReasoning =
forceCode || DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText));
forceCode ||
Boolean(pageDataDevTaskType) ||
DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText));
if (!agentCodeRunsEnabledForUser(userId)) {
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType } : {};
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {};
}
const shouldUseCode = forceCode || (allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)));
const shouldUseCode =
forceCode ||
Boolean(pageDataDevTaskType) ||
(allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)));
if (!shouldUseCode) {
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType } : {};
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {};
}
const normalizedRequestId = requestId ?? crypto.randomUUID();
const receipt = buildAgentRunValidationReceipt(normalizedRequestId);
const taskValidation = buildAgentRunTaskValidation({
requestId: normalizedRequestId,
taskType,
taskType: effectiveTaskType,
text: normalizedText,
mindspaceContext,
pageEdit,
});
return {
toolMode: 'code',
taskType,
taskType: effectiveTaskType,
forceDeepReasoning: true,
validation: mergeAgentRunValidationFiles(receipt.validation, taskValidation.validation),
validationInstruction: `${receipt.instruction}${taskValidation.instruction}`,
+2 -1
View File
@@ -64,7 +64,8 @@ export function createToolGateway({
const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider');
const openhandsTaskTypes = csvSet(
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? 'repo_refactor,multi_file,complex_repo',
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES
?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex',
);
const stdioLimit = positiveInteger(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT);
+6 -1
View File
@@ -23,7 +23,7 @@ test('tool gateway is disabled by default and reports protocol', () => {
protocol: 'agent-run-v1',
executors: ['aider', 'openhands'],
defaultExecutor: 'aider',
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo'],
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo', 'page_data_dev_complex'],
});
});
@@ -38,6 +38,11 @@ test('tool gateway selects openhands for configured task types', () => {
assert.equal(gateway.selectExecutor({ taskType: 'small_patch' }), 'aider');
});
test('tool gateway selects openhands for page_data_dev_complex by default', () => {
const gateway = createToolGateway({ env: {} });
assert.equal(gateway.selectExecutor({ taskType: 'page_data_dev_complex' }), 'openhands');
});
test('tool gateway dry run builds executor launch plan without spawning', async () => {
const plans = [];
const gateway = createToolGateway({
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => {
proxy: {
'/api': portalTarget,
'/auth': portalTarget,
'/analytics': portalTarget,
'/admin-api': adminTarget,
'/MindSpace': portalTarget,
'/dev': portalTarget,