Phase 5 可插拔 LangGraph Orchestrator Runtime #30

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