diff --git a/admin-routes.mjs b/admin-routes.mjs index 8af7791..bafce0c 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -284,6 +284,14 @@ export function createAdminApi({ 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: '系统测试服务未启用' }); diff --git a/agent-code-run-admin-config.mjs b/agent-code-run-admin-config.mjs index e9c037e..6292c29 100644 --- a/agent-code-run-admin-config.mjs +++ b/agent-code-run-admin-config.mjs @@ -8,6 +8,7 @@ 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', ]); @@ -318,6 +319,39 @@ export function createAgentCodeRunAdminConfigService(pool, { env = process.env } 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 }; + }, }; } diff --git a/agent-code-run-admin-config.test.mjs b/agent-code-run-admin-config.test.mjs index 47d0fbd..5c45007 100644 --- a/agent-code-run-admin-config.test.mjs +++ b/agent-code-run-admin-config.test.mjs @@ -131,3 +131,45 @@ test('getPublicClientPolicy disables client flags when user not allowlisted', as 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'); +}); diff --git a/docs/agent-run-worker-rollout-runbook.md b/docs/agent-run-worker-rollout-runbook.md index 0f804c4..2120d97 100644 --- a/docs/agent-run-worker-rollout-runbook.md +++ b/docs/agent-run-worker-rollout-runbook.md @@ -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 +``` + +Verify effective policy after save: + +```bash +curl -fsS http://127.0.0.1:8081/auth/status -H "Cookie: h5_user=" | 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. diff --git a/docs/help-code01.md b/docs/help-code01.md index f3d78c9..5889ecc 100644 --- a/docs/help-code01.md +++ b/docs/help-code01.md @@ -329,23 +329,26 @@ OpenHands 任务类型保持现有 `repo_refactor,multi_file,complex_repo`;** > 详细设计见 **[help-code02.md](./help-code02.md)**。 -- [ ] `h5_agent_code_run_config` 表 + `agent-code-run-admin-config.mjs` -- [ ] memindadm API/UI:code run 开关、白名单、taskType、`pageDataDev.autodetect` -- [ ] `/auth/status` 下发 `agentCodeRun`,H5 不再依赖 `VITE_*` rebuild -- [ ] `agent-run-routes.mjs` 从 DB 读策略(env 仅紧急 override) -- [ ] Worker 拓扑(`MEMIND_TOOL_GATEWAY_ENABLED`)继续留 env +- [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 -- [ ] `scripts/page-data-aider-dev-loop.mjs`:读 verify 输出 → 调 Tool Gateway/Aider → 重跑 verify +- [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 -- [ ] Aider 连续 N 次 verify 失败 → 升级 OpenHands(`taskType=page_data_dev_complex`) -- [ ] 运维面板展示 page_data_dev run 历史 +- [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) --- diff --git a/docs/help-code02.md b/docs/help-code02.md index 020aa82..59e1d38 100644 --- a/docs/help-code02.md +++ b/docs/help-code02.md @@ -344,9 +344,9 @@ node scripts/migrate-agent-code-run-config-from-env.mjs --apply - [x] `server.mjs`:`/auth/status` 增加 `agentCodeRun` - [x] `agent-run-routes.mjs`:改用 `getEffectivePolicy(userId)` - [x] `agentRunMode.ts`:runtime policy 优先于 VITE_ -- [ ] Ops UI 页面(可先做 JSON 编辑,后做表单) -- [ ] `migrate-agent-code-run-config-from-env.mjs` -- [ ] 更新 `docs/agent-run-worker-rollout-runbook.md` +- [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` diff --git a/ops/src/App.tsx b/ops/src/App.tsx index ef25bc3..d37df29 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -11,6 +11,8 @@ 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'; export function App() { return ( @@ -42,6 +44,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 330fbf7..379a390 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -324,3 +324,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('/admin-api/agent-code-run/config'); +} + +export async function fetchAgentCodeRunRuntime() { + return adminFetch('/admin-api/agent-code-run/runtime'); +} + +export async function patchAgentCodeRunConfig(body: { config: AgentCodeRunConfigShape }) { + return adminFetch('/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}`, + ); +} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 94ae4d2..bf12fbc 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -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' }, ]; export function AdminLayout() { diff --git a/ops/src/pages/admin/AgentCodeRunPage.tsx b/ops/src/pages/admin/AgentCodeRunPage.tsx new file mode 100644 index 0000000..824336e --- /dev/null +++ b/ops/src/pages/admin/AgentCodeRunPage.tsx @@ -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 ( + + ); +} + +export function AgentCodeRunPage() { + const [configState, setConfigState] = useState(null); + const [runtimeState, setRuntimeState] = useState(null); + const [runHistory, setRunHistory] = useState([]); + const [form, setForm] = useState(null); + const [jsonDraft, setJsonDraft] = useState(''); + const [useJson, setUseJson] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(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; + 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

加载中…

; + if (error && !form) return

{error}

; + if (!form || !configState) return

配置不可用

; + + return ( +
+
+

Agent Code Run & Page Data Dev

+

+ 控制 code run 灰度、H5 客户端开关与 Page Data 开发修复 autodetect。Worker 拓扑仍由 env 管理。 +

+
+

+ 当前来源:{configState.source} + {configState.envOverrideActive ? ( + · MEMIND_CODE_RUN_POLICY_SOURCE=env 已锁定后台修改 + ) : null} +

+

更新时间:{formatTime(configState.updatedAt)}

+ {effectivePolicy ? ( +

+ 有效策略:server={effectivePolicy.enabled ? '开' : '关'} · client= + {effectivePolicy.clientEnabled ? '开' : '关'} · pageDataDev= + {effectivePolicy.pageDataDevAutodetect ? '开' : '关'} +

+ ) : null} +
+
+ + {locked ? ( +
+ 当前环境启用了 MEMIND_CODE_RUN_POLICY_SOURCE=env,后台无法写入 DB。请改 env 或去掉该变量后再保存。 +
+ ) : null} + +
+

开关

+ setForm((current) => (current ? { ...current, enabled } : current))} + /> + setForm((current) => (current ? { ...current, clientEnabled } : current))} + /> + + setForm((current) => (current ? { ...current, pageDataDevAutodetect } : current)) + } + /> + + setForm((current) => (current ? { ...current, generalAutodetect } : current)) + } + /> + + setForm((current) => (current ? { ...current, requireValidation } : current)) + } + /> +
+ +
+

白名单

+
+ + + +`; + +const ORDER_ADMIN_HTML = ` + + + + + 订单管理后台 + + + +
+

客户订单管理后台

查看订单、按商品统计、导出 CSV

+
+

请输入管理口令

+ + + +
+ +
+ + + +`; + +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); +}); diff --git a/scripts/verify-customer-order-system.mjs b/scripts/verify-customer-order-system.mjs new file mode 100644 index 0000000..d5108f1 --- /dev/null +++ b/scripts/verify-customer-order-system.mjs @@ -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); +}); diff --git a/tool-gateway.mjs b/tool-gateway.mjs index b2ec43c..38bca68 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -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); diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index adb3520..dd06b17 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -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({