Phase 5 可插拔 LangGraph Orchestrator Runtime #30
@@ -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: '系统测试服务未启用' });
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchAgentCodeRunConfig,
|
||||
fetchAgentCodeRunHistory,
|
||||
fetchAgentCodeRunRuntime,
|
||||
patchAgentCodeRunConfig,
|
||||
type AgentCodeRunAdminConfig,
|
||||
type AgentCodeRunHistoryRow,
|
||||
type AgentCodeRunRuntimeState,
|
||||
} from '../../api/admin';
|
||||
|
||||
type FormState = {
|
||||
enabled: boolean;
|
||||
clientEnabled: boolean;
|
||||
generalAutodetect: boolean;
|
||||
requireValidation: boolean;
|
||||
pageDataDevAutodetect: boolean;
|
||||
userAllowlist: string;
|
||||
taskTypeAllowlist: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
function listToText(values: string[] | undefined) {
|
||||
return (values ?? []).join(', ');
|
||||
}
|
||||
|
||||
function textToList(value: string) {
|
||||
return value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function configToForm(config: AgentCodeRunAdminConfig['config']): FormState {
|
||||
return {
|
||||
enabled: Boolean(config.codeRun.enabled),
|
||||
clientEnabled: Boolean(config.codeRun.clientEnabled),
|
||||
generalAutodetect: Boolean(config.codeRun.generalAutodetect),
|
||||
requireValidation: Boolean(config.codeRun.requireValidation),
|
||||
pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect),
|
||||
userAllowlist: listToText(config.codeRun.userAllowlist),
|
||||
taskTypeAllowlist: listToText(config.codeRun.taskTypeAllowlist),
|
||||
notes: config.meta?.notes ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPatch(form: FormState) {
|
||||
return {
|
||||
config: {
|
||||
codeRun: {
|
||||
enabled: form.enabled,
|
||||
clientEnabled: form.clientEnabled,
|
||||
generalAutodetect: form.generalAutodetect,
|
||||
requireValidation: form.requireValidation,
|
||||
userAllowlist: textToList(form.userAllowlist),
|
||||
taskTypeAllowlist: textToList(form.taskTypeAllowlist),
|
||||
},
|
||||
pageDataDev: {
|
||||
autodetect: form.pageDataDevAutodetect,
|
||||
},
|
||||
meta: {
|
||||
notes: form.notes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value: number | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'start' }}>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
{hint ? <p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>{hint}</p> : null}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
style={{ width: 'auto', marginTop: 4 }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCodeRunPage() {
|
||||
const [configState, setConfigState] = useState<AgentCodeRunAdminConfig | null>(null);
|
||||
const [runtimeState, setRuntimeState] = useState<AgentCodeRunRuntimeState | null>(null);
|
||||
const [runHistory, setRunHistory] = useState<AgentCodeRunHistoryRow[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [jsonDraft, setJsonDraft] = useState('');
|
||||
const [useJson, setUseJson] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked = Boolean(configState?.envOverrideActive);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [config, runtime, history] = await Promise.all([
|
||||
fetchAgentCodeRunConfig(),
|
||||
fetchAgentCodeRunRuntime(),
|
||||
fetchAgentCodeRunHistory(30),
|
||||
]);
|
||||
setConfigState(config);
|
||||
setRuntimeState(runtime);
|
||||
setRunHistory(history.runs ?? []);
|
||||
setForm(configToForm(config.config));
|
||||
setJsonDraft(JSON.stringify(config.config, null, 2));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const effectivePolicy = runtimeState?.policy;
|
||||
|
||||
const previewJson = useMemo(() => {
|
||||
if (!form) return '';
|
||||
return JSON.stringify(formToPatch(form).config, null, 2);
|
||||
}, [form]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
let patch: ReturnType<typeof formToPatch>;
|
||||
if (useJson) {
|
||||
const parsed = JSON.parse(jsonDraft) as AgentCodeRunAdminConfig['config'];
|
||||
patch = { config: parsed };
|
||||
} else {
|
||||
patch = formToPatch(form);
|
||||
}
|
||||
const saved = await patchAgentCodeRunConfig(patch);
|
||||
setConfigState(saved);
|
||||
setForm(configToForm(saved.config));
|
||||
setJsonDraft(JSON.stringify(saved.config, null, 2));
|
||||
setMessage('配置已保存');
|
||||
const runtime = await fetchAgentCodeRunRuntime();
|
||||
setRuntimeState(runtime);
|
||||
const history = await fetchAgentCodeRunHistory(30);
|
||||
setRunHistory(history.runs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p>加载中…</p>;
|
||||
if (error && !form) return <p className="alert">{error}</p>;
|
||||
if (!form || !configState) return <p className="alert">配置不可用</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Agent Code Run & Page Data Dev</h2>
|
||||
<p style={{ color: '#68716c', marginTop: 0 }}>
|
||||
控制 code run 灰度、H5 客户端开关与 Page Data 开发修复 autodetect。Worker 拓扑仍由 env 管理。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
当前来源:<strong>{configState.source}</strong>
|
||||
{configState.envOverrideActive ? (
|
||||
<span className="warn"> · MEMIND_CODE_RUN_POLICY_SOURCE=env 已锁定后台修改</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>更新时间:{formatTime(configState.updatedAt)}</p>
|
||||
{effectivePolicy ? (
|
||||
<p style={{ margin: 0 }}>
|
||||
有效策略:server={effectivePolicy.enabled ? '开' : '关'} · client=
|
||||
{effectivePolicy.clientEnabled ? '开' : '关'} · pageDataDev=
|
||||
{effectivePolicy.pageDataDevAutodetect ? '开' : '关'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locked ? (
|
||||
<div className="card warn">
|
||||
当前环境启用了 <code>MEMIND_CODE_RUN_POLICY_SOURCE=env</code>,后台无法写入 DB。请改 env 或去掉该变量后再保存。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>开关</h3>
|
||||
<ToggleRow
|
||||
label="Code Run 总开关(服务端)"
|
||||
hint="POST /agent/runs 的 tool_mode=code 门禁"
|
||||
checked={form.enabled}
|
||||
disabled={locked}
|
||||
onChange={(enabled) => setForm((current) => (current ? { ...current, enabled } : current))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="H5 客户端启用"
|
||||
hint="/auth/status.agentCodeRun.codeRun.enabled"
|
||||
checked={form.clientEnabled}
|
||||
disabled={locked}
|
||||
onChange={(clientEnabled) => setForm((current) => (current ? { ...current, clientEnabled } : current))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Page Data Dev Autodetect"
|
||||
hint="聊天识别「修问卷 / verify 失败」→ page_data_dev"
|
||||
checked={form.pageDataDevAutodetect}
|
||||
disabled={locked}
|
||||
onChange={(pageDataDevAutodetect) =>
|
||||
setForm((current) => (current ? { ...current, pageDataDevAutodetect } : current))
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="通用 Code Autodetect"
|
||||
hint="普通聊天文本识别代码任务(默认建议关闭)"
|
||||
checked={form.generalAutodetect}
|
||||
disabled={locked}
|
||||
onChange={(generalAutodetect) =>
|
||||
setForm((current) => (current ? { ...current, generalAutodetect } : current))
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="强制 Validation"
|
||||
hint="code run 必须带 expectedFiles / receipt"
|
||||
checked={form.requireValidation}
|
||||
disabled={locked}
|
||||
onChange={(requireValidation) =>
|
||||
setForm((current) => (current ? { ...current, requireValidation } : current))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>白名单</h3>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>用户 UUID(逗号或换行分隔,留空=全部)</strong>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.userAllowlist}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
setForm((current) => (current ? { ...current, userAllowlist: event.target.value } : current))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>taskType 允许列表</strong>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.taskTypeAllowlist}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
setForm((current) => (current ? { ...current, taskTypeAllowlist: event.target.value } : current))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>备注</strong>
|
||||
<input
|
||||
value={form.notes}
|
||||
disabled={locked}
|
||||
onChange={(event) => setForm((current) => (current ? { ...current, notes: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 12 }}>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useJson}
|
||||
disabled={locked}
|
||||
onChange={(event) => setUseJson(event.target.checked)}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
<strong>高级:直接编辑 JSON config</strong>
|
||||
</label>
|
||||
{useJson ? (
|
||||
<textarea
|
||||
rows={16}
|
||||
value={jsonDraft}
|
||||
disabled={locked}
|
||||
onChange={(event) => setJsonDraft(event.target.value)}
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#f8f4ea',
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previewJson}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn" type="button" disabled={saving || locked} onClick={() => void handleSave()}>
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
<button className="btn secondary" type="button" disabled={loading} onClick={() => void load()}>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? <p style={{ color: '#2f6f57' }}>{message}</p> : null}
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
{runtimeState ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>运行时只读</h3>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#f8f4ea',
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(runtimeState, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Page Data Dev Run 历史</h3>
|
||||
{runHistory.length === 0 ? (
|
||||
<p style={{ color: '#68716c', margin: 0 }}>暂无 page_data_dev / page_data_dev_complex 记录</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>时间</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>用户</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>taskType</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>executor</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>状态</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>requestId</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runHistory.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{formatTime(row.updatedAt)}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{row.userId.slice(0, 8)}…
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.taskType}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.executor ?? '—'}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.status}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{row.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "customer-order-system",
|
||||
"name": "客户下单系统(Page Data 前后台)",
|
||||
"description": "用户话术 → Goose page-data-collect 生成下单页 + 管理后台 → 验证 Page Data 交付",
|
||||
"account": {
|
||||
"username": "john4",
|
||||
"password": "888888"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"action": "login",
|
||||
"label": "登录 john 账户"
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "创建客户下单系统(前端下单 + 后台管理)",
|
||||
"message": "帮我做一个客户下单系统,分前端和后端两个页面:\n\n【前端下单页】客户填写:客户姓名、联系电话、商品名称、购买数量、收货地址、备注(选填),提交后保存到数据库。\n\n【管理后台页】密码 88888888,能查看所有订单列表,显示下单时间和状态,能按商品名称做简单统计,支持导出 CSV。\n\n要求:使用 Page Data 公开 API(page-data-client.js),不要自建后端;页面简洁可用;做完把下单页链接和后台链接发给我。",
|
||||
"selectedChatSkill": "page-data-collect",
|
||||
"expect": {
|
||||
"assistantMinChars": 80,
|
||||
"timeoutMs": 600000,
|
||||
"replyKeywords": ["下单", "后台"],
|
||||
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
|
||||
"survey": {
|
||||
"requirePolicy": true,
|
||||
"requireDataset": true,
|
||||
"forbidHtmlPatterns": ["localStorage", "sessionStorage", "fetch('/api/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verify(Phase 2)。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/page-data-aider-dev-loop.mjs
|
||||
* node scripts/page-data-aider-dev-loop.mjs --preset children-hobby-diet-survey
|
||||
* node scripts/page-data-aider-dev-loop.mjs --preset page-data-platform --max-attempts 2
|
||||
* node scripts/page-data-aider-dev-loop.mjs --dry-run
|
||||
* node scripts/page-data-aider-dev-loop.mjs --verify-cmd "node scripts/verify-children-hobby-diet-survey.mjs --no-insert"
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
import { CHILDREN_HOBBY_DIET_SURVEY } from './scenario-test-lib.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
|
||||
export const PAGE_DATA_DEV_COMPLEX_TASK_TYPE = 'page_data_dev_complex';
|
||||
|
||||
export const VERIFY_PRESETS = Object.freeze({
|
||||
'children-hobby-diet-survey': {
|
||||
label: 'john4 儿童爱好问卷饮食偏好',
|
||||
scenario: 'workspace',
|
||||
defaultUserId: CHILDREN_HOBBY_DIET_SURVEY.userId,
|
||||
buildVerifyArgs: (options) => [
|
||||
'scripts/verify-children-hobby-diet-survey.mjs',
|
||||
'--user-id',
|
||||
options.userId,
|
||||
...(options.testInsert === false ? ['--no-insert'] : []),
|
||||
...(options.checkRuns === false ? ['--no-runs'] : []),
|
||||
...(options.port ? ['--port', String(options.port)] : []),
|
||||
],
|
||||
defaultTargetFiles: () => [
|
||||
`public/${CHILDREN_HOBBY_DIET_SURVEY.surveyHtml}`,
|
||||
`public/${CHILDREN_HOBBY_DIET_SURVEY.adminHtml}`,
|
||||
],
|
||||
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
|
||||
},
|
||||
'page-data-platform': {
|
||||
label: 'Memind Page Data 平台单测',
|
||||
scenario: 'platform',
|
||||
defaultUserId: null,
|
||||
buildVerifyArgs: () => ['--test', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs'],
|
||||
defaultTargetFiles: () => [
|
||||
'page-data-public-service.mjs',
|
||||
'page-data-service.mjs',
|
||||
'public/assets/page-data-client.js',
|
||||
],
|
||||
verifyCommandLabel: 'npm run verify:page-data',
|
||||
},
|
||||
});
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/page-data-aider-dev-loop.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --preset <name> children-hobby-diet-survey (default) | page-data-platform',
|
||||
' --verify-cmd <shell> 自定义 verify 命令(覆盖 preset)',
|
||||
' --user-id <uuid> workspace 用户 ID(workspace 场景)',
|
||||
' --cwd <dir> 覆盖 Aider 工作目录',
|
||||
' --target-file <path> 可多次指定;未指定时用 preset 默认',
|
||||
' --max-attempts <n> 最大修复轮次(默认 3)',
|
||||
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 2,0=禁用)',
|
||||
' --executor <aider|openhands> 强制全程使用指定执行器',
|
||||
' --no-insert 传给 preset verify',
|
||||
' --no-runs 传给 preset verify(跳过 orphan run 检查)',
|
||||
' --port <n> Portal 端口(默认 H5_PORT 或 8081)',
|
||||
' --dry-run 只打印 instruction,不执行 Aider',
|
||||
' --skip-initial-verify 跳过首轮 verify,直接跑 Aider(调试用)',
|
||||
' -h, --help',
|
||||
'',
|
||||
'Requires: DATABASE_URL(Aider launch plan)、MEMIND_TOOL_GATEWAY_ENABLED=1(或 --dry-run)',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
export function parseDevLoopArgs(argv, env = process.env) {
|
||||
const options = {
|
||||
preset: 'children-hobby-diet-survey',
|
||||
verifyCmd: null,
|
||||
userId: null,
|
||||
cwd: null,
|
||||
targetFiles: [],
|
||||
maxAttempts: 3,
|
||||
escalateAfter: 2,
|
||||
executor: 'aider',
|
||||
testInsert: true,
|
||||
checkRuns: true,
|
||||
port: Number(env.H5_PORT ?? 8081),
|
||||
dryRun: false,
|
||||
skipInitialVerify: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '-h' || arg === '--help') options.help = true;
|
||||
else if (arg === '--preset' && argv[i + 1]) options.preset = argv[++i];
|
||||
else if (arg === '--verify-cmd' && argv[i + 1]) options.verifyCmd = argv[++i];
|
||||
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
|
||||
else if (arg === '--cwd' && argv[i + 1]) options.cwd = path.resolve(argv[++i]);
|
||||
else if (arg === '--target-file' && argv[i + 1]) options.targetFiles.push(argv[++i]);
|
||||
else if (arg === '--max-attempts' && argv[i + 1]) options.maxAttempts = Math.max(1, Number(argv[++i]) || 3);
|
||||
else if (arg === '--escalate-after' && argv[i + 1]) {
|
||||
options.escalateAfter = Math.max(0, Number(argv[++i]) || 0);
|
||||
}
|
||||
else if (arg === '--executor' && argv[i + 1]) options.executor = String(argv[++i]).trim().toLowerCase();
|
||||
else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
|
||||
else if (arg === '--no-insert') options.testInsert = false;
|
||||
else if (arg === '--no-runs') options.checkRuns = false;
|
||||
else if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--skip-initial-verify') options.skipInitialVerify = true;
|
||||
else throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function resolveVerifyPreset(presetName) {
|
||||
const preset = VERIFY_PRESETS[presetName];
|
||||
if (!preset) {
|
||||
throw new Error(`未知 preset: ${presetName}(可选: ${Object.keys(VERIFY_PRESETS).join(', ')})`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCwd({ repoRoot: root, userId, cwdOverride, scenario }) {
|
||||
if (cwdOverride) return cwdOverride;
|
||||
if (scenario === 'platform') return root;
|
||||
if (!userId) {
|
||||
throw new Error('workspace 场景需要 --user-id 或 preset 默认 userId');
|
||||
}
|
||||
return path.join(root, PUBLISH_ROOT_DIR, userId);
|
||||
}
|
||||
|
||||
export function resolveDevLoopExecutor(
|
||||
attempt,
|
||||
{ executor = 'aider', escalateAfter = 2 } = {},
|
||||
) {
|
||||
if (executor !== 'aider') {
|
||||
return {
|
||||
executor,
|
||||
taskType: executor === 'openhands' ? PAGE_DATA_DEV_COMPLEX_TASK_TYPE : PAGE_DATA_DEV_TASK_TYPE,
|
||||
escalated: false,
|
||||
};
|
||||
}
|
||||
if (escalateAfter > 0 && attempt > escalateAfter) {
|
||||
return {
|
||||
executor: 'openhands',
|
||||
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
escalated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executor: 'aider',
|
||||
taskType: PAGE_DATA_DEV_TASK_TYPE,
|
||||
escalated: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPageDataDevInstruction({
|
||||
verifyOutput,
|
||||
cwd,
|
||||
taskType = PAGE_DATA_DEV_TASK_TYPE,
|
||||
targetFiles = [],
|
||||
verifyCommandLabel = 'verify script',
|
||||
escalated = false,
|
||||
}) {
|
||||
const targets = targetFiles.length
|
||||
? targetFiles.map((item) => `- ${item}`).join('\n')
|
||||
: '- public/*.html\n- .mindspace/page-data-policies/*.json';
|
||||
|
||||
return [
|
||||
'[Page Data 开发修复任务]',
|
||||
escalated ? '[Escalation] 前序 Aider 修复未通过 verify,现由 OpenHands 接手。' : '',
|
||||
'',
|
||||
`工作目录: ${cwd}`,
|
||||
`taskType: ${taskType}`,
|
||||
'',
|
||||
'验证失败输出(必须原样附上):',
|
||||
String(verifyOutput ?? '').trim() || '(verify 未返回输出)',
|
||||
'',
|
||||
'目标文件(仅可改这些):',
|
||||
targets,
|
||||
'',
|
||||
'约束:',
|
||||
'- 必须使用 /assets/page-data-client.js 公开 API',
|
||||
'- 禁止 localStorage / sessionStorage / IndexedDB',
|
||||
'- 禁止自建 Express 或独立后端',
|
||||
'- 列名必须与 dataset register 的 insert/read 白名单一致',
|
||||
'- 不要修改 Memind 平台源码(workspace 场景)',
|
||||
'- 不要调用 private_data_execute 或重建 dataset(除非 verify 明确报 table_not_found)',
|
||||
'',
|
||||
'验收:',
|
||||
`- 修改后应能通过: ${verifyCommandLabel}`,
|
||||
'- 完成后简要说明改了哪些文件、为何能修复 verify 失败项',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function runVerifyCommand({ command, args, cwd, env }) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
output: output || `(exit ${result.status ?? 'unknown'}, no output)`,
|
||||
commandLine: `${command} ${args.join(' ')}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVerifyRun(options, preset) {
|
||||
if (options.verifyCmd) {
|
||||
return {
|
||||
label: options.verifyCmd,
|
||||
...runVerifyCommand({
|
||||
command: '/bin/sh',
|
||||
args: ['-lc', options.verifyCmd],
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = options.userId ?? preset.defaultUserId;
|
||||
if (preset.scenario === 'workspace') {
|
||||
options.userId = userId;
|
||||
}
|
||||
|
||||
const verifyArgs = preset.buildVerifyArgs({
|
||||
userId,
|
||||
testInsert: options.testInsert,
|
||||
checkRuns: options.checkRuns,
|
||||
port: options.port,
|
||||
});
|
||||
|
||||
const useNpmForPlatform = preset.scenario === 'platform'
|
||||
&& verifyArgs[0] === '--test';
|
||||
|
||||
if (useNpmForPlatform) {
|
||||
return {
|
||||
label: preset.verifyCommandLabel,
|
||||
...runVerifyCommand({
|
||||
command: 'npm',
|
||||
args: ['run', 'verify:page-data'],
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: preset.verifyCommandLabel,
|
||||
...runVerifyCommand({
|
||||
command: process.execPath,
|
||||
args: verifyArgs,
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCodeExecutorFix({
|
||||
instruction,
|
||||
cwd,
|
||||
executor,
|
||||
taskType,
|
||||
dryRun,
|
||||
llmProviderService,
|
||||
}) {
|
||||
const previousGatewayFlag = process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||||
if (!dryRun) {
|
||||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = '1';
|
||||
}
|
||||
const gateway = createToolGateway({ llmProviderService, env: process.env });
|
||||
const status = gateway.getStatus();
|
||||
if (!status.enabled && !dryRun) {
|
||||
throw new Error('MEMIND_TOOL_GATEWAY_ENABLED 未开启;请设 1 或使用 --dry-run');
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: instruction }],
|
||||
metadata: {
|
||||
memindRun: {
|
||||
executor,
|
||||
taskType,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await gateway.executeJob({
|
||||
runId: `dev-loop-${Date.now()}`,
|
||||
requestId: `dev-loop-${Date.now()}`,
|
||||
userId: 'dev-loop',
|
||||
userMessage,
|
||||
taskType,
|
||||
cwd,
|
||||
timeoutMs: Number(process.env.MEMIND_PAGE_DATA_DEV_LOOP_TIMEOUT_MS ?? 20 * 60 * 1000),
|
||||
});
|
||||
} finally {
|
||||
if (previousGatewayFlag == null) {
|
||||
delete process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||||
} else {
|
||||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = previousGatewayFlag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadH5Environment(import.meta.dirname);
|
||||
const options = parseDevLoopArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const preset = resolveVerifyPreset(options.preset);
|
||||
const userId = options.userId ?? preset.defaultUserId;
|
||||
const workspaceCwd = resolveWorkspaceCwd({
|
||||
repoRoot,
|
||||
userId,
|
||||
cwdOverride: options.cwd,
|
||||
scenario: preset.scenario,
|
||||
});
|
||||
|
||||
if (preset.scenario === 'workspace' && !fs.existsSync(workspaceCwd)) {
|
||||
throw new Error(`workspace 不存在: ${workspaceCwd}`);
|
||||
}
|
||||
|
||||
const targetFiles = options.targetFiles.length
|
||||
? options.targetFiles
|
||||
: preset.defaultTargetFiles();
|
||||
|
||||
console.log('==> Page Data Aider dev loop');
|
||||
console.log(` preset: ${options.preset} (${preset.label})`);
|
||||
console.log(` cwd: ${workspaceCwd}`);
|
||||
console.log(` max attempts: ${options.maxAttempts}`);
|
||||
console.log(` escalate after: ${options.escalateAfter} aider round(s)`);
|
||||
console.log(` dry-run: ${options.dryRun ? 'yes' : 'no'}\n`);
|
||||
|
||||
let pool = null;
|
||||
let llmProviderService = null;
|
||||
if (!options.dryRun) {
|
||||
pool = createDbPool();
|
||||
llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
});
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
if (pool) await pool.end().catch(() => {});
|
||||
};
|
||||
|
||||
try {
|
||||
let verifyResult = options.skipInitialVerify
|
||||
? { ok: false, output: '(跳过首轮 verify)', label: options.verifyCmd ?? preset.verifyCommandLabel }
|
||||
: null;
|
||||
|
||||
if (!options.skipInitialVerify) {
|
||||
console.log('--- 首轮 verify ---');
|
||||
verifyResult = resolveVerifyRun(options, preset);
|
||||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||||
if (verifyResult.ok) {
|
||||
console.log('\n✔ verify 已通过,无需 Aider 修复');
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(verifyResult.output.slice(-8000));
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
||||
const execution = resolveDevLoopExecutor(attempt, {
|
||||
executor: options.executor,
|
||||
escalateAfter: options.escalateAfter,
|
||||
});
|
||||
const instruction = buildPageDataDevInstruction({
|
||||
verifyOutput: verifyResult.output,
|
||||
cwd: workspaceCwd,
|
||||
targetFiles,
|
||||
verifyCommandLabel: verifyResult.label,
|
||||
taskType: execution.taskType,
|
||||
escalated: execution.escalated,
|
||||
});
|
||||
|
||||
console.log(`\n--- 修复第 ${attempt}/${options.maxAttempts} 轮 (${execution.executor}) ---`);
|
||||
console.log(instruction.slice(0, 2000));
|
||||
if (instruction.length > 2000) {
|
||||
console.log(`... (${instruction.length - 2000} more chars)`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n(dry-run: 未执行 Aider)');
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n--- 执行 ${execution.executor} ---`);
|
||||
const result = await runCodeExecutorFix({
|
||||
instruction,
|
||||
cwd: workspaceCwd,
|
||||
executor: execution.executor,
|
||||
taskType: execution.taskType,
|
||||
dryRun: false,
|
||||
llmProviderService,
|
||||
});
|
||||
console.log(`${execution.executor} 完成: exit=${result.exitCode ?? 0} taskType=${execution.taskType}`);
|
||||
if (result.stderr) {
|
||||
console.log(result.stderr.slice(-2000));
|
||||
}
|
||||
|
||||
console.log('\n--- 重跑 verify ---');
|
||||
verifyResult = resolveVerifyRun(options, preset);
|
||||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||||
if (verifyResult.ok) {
|
||||
console.log(`\n✔ 第 ${attempt} 轮修复后 verify 通过`);
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(verifyResult.output.slice(-4000));
|
||||
}
|
||||
|
||||
console.error(`\n✘ ${options.maxAttempts} 轮修复后 verify 仍未通过`);
|
||||
await shutdown();
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
await shutdown();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export const pageDataAiderDevLoopInternals = {
|
||||
PAGE_DATA_DEV_TASK_TYPE,
|
||||
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
VERIFY_PRESETS,
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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(', ') || '(空)'}`);
|
||||
|
||||
@@ -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 ({'&':'&','<':'<','>':'>','"':'"'}[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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user