Add page data delivery and publication guards

This commit is contained in:
john
2026-07-08 21:10:47 +08:00
parent 8d629e7a4e
commit eea14c1855
66 changed files with 3035 additions and 413 deletions
+62 -39
View File
@@ -112,49 +112,63 @@ function buildTempUser() {
};
}
async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) {
const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
async function waitForAgentRunCompletion(fetchImpl, baseUrl, cookie, timeoutMs, createRun) {
const { runId, sessionId } = await createRun();
const deadline = Date.now() + timeoutMs;
const seen = [];
const response = await fetchImpl(`${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}/events`, {
headers: {
Accept: 'text/event-stream',
Cookie: cookie,
},
});
if (!response.ok || !response.body) {
const payload = await parseResponseBody(response);
throw new Error(`session events failed: ${response.status} ${payload.text}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const seen = [];
const runId = await runTrigger();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
seen.push(trimmed);
if (trimmed.includes('type":"Error"')) {
throw new Error(`session stream error: ${trimmed}`);
}
if (trimmed.includes('type":"Finish"')) {
await reader.cancel().catch(() => {});
return { runId, seen };
if (response.ok && response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
seen.push(trimmed);
if (trimmed.includes('"status":"failed"')) {
await reader.cancel().catch(() => {});
return { runId, sessionId, seen, terminalStatus: 'failed' };
}
if (trimmed.includes('"status":"succeeded"')) {
await reader.cancel().catch(() => {});
return { runId, sessionId, seen, terminalStatus: 'succeeded' };
}
}
}
await reader.cancel().catch(() => {});
}
await reader.cancel().catch(() => {});
throw new Error(`session stream timeout after ${timeoutMs}ms`);
while (Date.now() < deadline) {
const run = await requestJson(fetchImpl, `${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}`, {
headers: { Cookie: cookie },
timeoutMs: Math.min(10000, timeoutMs),
});
const status = run.json?.run?.status ?? null;
const resolvedSessionId = run.json?.run?.agent_session_id ?? sessionId;
if (status === 'succeeded' || status === 'failed') {
return { runId, sessionId: resolvedSessionId, seen, terminalStatus: status };
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
throw new Error(`agent run timeout after ${timeoutMs}ms`);
}
export { waitForAgentRunCompletion };
export async function runMemoryV2SessionFlowCli({
argv = process.argv.slice(2),
env = process.env,
@@ -219,10 +233,9 @@ export async function runMemoryV2SessionFlowCli({
}
const requestId = crypto.randomUUID();
const { runId, seen } = await waitForSessionFinish(
const { runId, sessionId: activeSessionId, seen, terminalStatus } = await waitForAgentRunCompletion(
fetchImpl,
options.baseUrl,
sessionId,
cookie,
options.timeoutMs,
async () => {
@@ -248,14 +261,18 @@ export async function runMemoryV2SessionFlowCli({
if (!created.ok || created.status !== 202 || !created.json?.run?.id) {
throw new Error(`agent run failed: ${created.status} ${created.text}`);
}
return created.json.run.id;
return {
runId: created.json.run.id,
sessionId: created.json?.run?.agent_session_id ?? sessionId,
};
},
);
checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), {
checks.push(makeCheck('agent_run_terminal', terminalStatus === 'succeeded', {
terminalStatus,
eventCount: seen.length,
}));
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(activeSessionId)}`, {
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
@@ -272,17 +289,22 @@ export async function runMemoryV2SessionFlowCli({
const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
body: { sessionId: activeSessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
status: remember.status,
}));
checks.push(makeCheck('remember_recent_extracted', remember.ok && Number(remember.json?.analyzed ?? 0) > 0, {
status: remember.status,
analyzed: remember.json?.analyzed ?? 0,
memories: remember.json?.memories ?? 0,
}));
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
body: { sessionId: activeSessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, {
@@ -310,7 +332,7 @@ export async function runMemoryV2SessionFlowCli({
ok: checks.every((item) => item.ok),
baseUrl: options.baseUrl,
user: user.username,
sessionId,
sessionId: activeSessionId,
runId,
summary: {
assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null,
@@ -326,6 +348,7 @@ export async function runMemoryV2SessionFlowCli({
}
: null,
sessionEventsTail: seen.slice(-4),
terminalStatus,
},
checks,
};
@@ -104,10 +104,10 @@ test('runMemoryV2SessionFlowCli passes against a mocked live session flow', asyn
if (url.endsWith('/api/agent/start')) {
return jsonResponse({ id: 'session-1' });
}
if (url.endsWith('/api/sessions/session-1/events')) {
if (url.endsWith('/api/agent/runs/run-1/events')) {
return sseResponse([
'id: 1\ndata: {"type":"Message","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"metadata":{"userVisible":true}}}\n\n',
'id: 2\ndata: {"type":"Finish","reason":"stop"}\n\n',
'event: run\ndata: {"run":{"id":"run-1","status":"running","agent_session_id":"session-1"}}\n\n',
'event: run\ndata: {"run":{"id":"run-1","status":"succeeded","agent_session_id":"session-1"}}\n\n',
]);
}
if (url.endsWith('/api/agent/runs')) {
+3 -2
View File
@@ -95,13 +95,14 @@ const useConfiguredPublicBase = ['1', 'true', 'yes', 'on'].includes(
const mindSpacePublicBase = (
useConfiguredPublicBase && process.env.H5_PUBLIC_BASE_URL
? process.env.H5_PUBLIC_BASE_URL
: localMindSpacePublicBase
: portalUrl
).replace(/\/$/, '');
process.env.H5_PUBLIC_BASE_URL = mindSpacePublicBase;
const viteEnv = {
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
H5_DEV_PORTAL: portalUrl,
};
const opsEnv = {
@@ -158,7 +159,7 @@ try {
console.log('');
console.log('本地服务:');
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
console.log(` MindSpace URL ${mindSpacePublicBase}`);
console.log(` MindSpace URL ${mindSpacePublicBase} (Portal 直链,Agent 交付链接)`);
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
console.log(` API / Portal ${portalUrl}`);
console.log(` memind_adm ${adminUrl}`);
+101
View File
@@ -0,0 +1,101 @@
/**
* 修复 john 教育问卷:问卷页 public + 独立后台页 password。
* 用法:node scripts/repair-child-education-survey.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '88888888';
const DATASET = 'child_edu_survey';
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const pages = [
{
relativePath: 'public/child-education-analysis.html',
accessMode: 'public',
password: null,
policy: {
accessMode: 'public',
datasets: {
[DATASET]: {
insert: true,
read: false,
columns: { insert: ['q1_pressure', 'q2_concern', 'q3_action'] },
},
},
},
},
{
relativePath: 'public/child-education-analysis-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: {
accessMode: 'password',
datasets: {
[DATASET]: {
insert: false,
read: true,
columns: {
read: ['id', 'q1_pressure', 'q2_concern', 'q3_action', 'created_at'],
},
},
},
},
},
];
console.log('修复教育问卷发布配置…\n');
const results = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
results.push(result);
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` accessMode: ${result.publicationAccessMode}`);
console.log(` workspace: ${result.workspaceUrl}\n`);
}
console.log('完成。测试入口:');
console.log(` 问卷:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis.html`);
console.log(` 后台:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis-admin.html`);
console.log(` 后台口令:${ADMIN_PASSWORD}`);
await pool.end();
+97
View File
@@ -0,0 +1,97 @@
/**
* 重新绑定并刷新 john 的体验调研页发布快照与 Page Data 策略。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '66668888';
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const DATASET = {
name: 'tkmind_exp_survey',
columns: {
insert: ['satisfaction', 'best_features', 'improvement'],
read: ['id', 'satisfaction', 'best_features', 'improvement', 'created_at'],
},
};
const pages = [
{
relativePath: 'public/tkmind-experience-survey.html',
accessMode: 'public',
password: null,
policy: {
accessMode: 'public',
datasets: {
[DATASET.name]: {
insert: true,
read: false,
columns: { insert: DATASET.columns.insert },
},
},
},
},
{
relativePath: 'public/tkmind-survey-experience-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: {
accessMode: 'password',
datasets: {
[DATASET.name]: {
insert: false,
read: true,
columns: { read: DATASET.columns.read },
},
},
},
},
];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_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(` workspace: ${result.workspaceUrl}`);
console.log(` publication: ${result.publicationUrl}`);
console.log(` policy datasets: ${Object.keys(result.policy?.datasets ?? {}).join(', ')}\n`);
}
await pool.end();
+18
View File
@@ -17,6 +17,7 @@ import {
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
verifySurveyDelivery,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
@@ -112,6 +113,7 @@ async function runScenario(scenario, port) {
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
selectedChatSkill: step.selectedChatSkill ?? null,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
@@ -174,6 +176,22 @@ async function runScenario(scenario, port) {
});
}
if (step.expect?.survey) {
await verifySurveyDelivery({
publishKey,
replyText: reply.combined,
expect: step.expect.survey,
reporter,
});
}
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && reply.combined.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
continue;
}
+104 -4
View File
@@ -75,20 +75,24 @@ export async function loginViaApi(baseUrl, { username, password }, reporter) {
};
}
function buildUserMessage(text) {
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
const metadata = { userVisible: true, displayText: text };
if (selectedChatSkill) {
metadata.memindRun = { selectedChatSkill };
}
return {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text }],
metadata: { userVisible: true, displayText: text },
metadata,
};
}
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null }) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message),
user_message: buildUserMessage(message, { selectedChatSkill }),
};
if (sessionId) body.session_id = sessionId;
@@ -301,6 +305,102 @@ export async function verifyPageAccess({
return true;
}
export async function verifySurveyDelivery({
publishKey,
replyText = '',
expect = {},
reporter,
}) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const policyDir = path.join(publishDir, '.mindspace', 'page-data-policies');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
const forbidReply = expect.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && replyText.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
if (forbidReply.length && !forbidReply.some((pattern) => pattern && replyText.includes(pattern))) {
reporter.pass('回复禁用模式', '未出现旁路 API / PLACEHOLDER');
}
let htmlFiles = [];
try {
const entries = await fs.readdir(publicDir, { withFileTypes: true });
htmlFiles = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
.map((entry) => entry.name);
} catch {
reporter.fail('问卷 HTML', 'public/ 目录不存在');
return false;
}
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
if (surveyLike.length === 0) {
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
} else {
reporter.pass('问卷 HTML', surveyLike.join(', '));
}
if (adminLike.length === 0) {
reporter.fail('后台 HTML', `public/ 中未找到后台页,现有: ${htmlFiles.join(', ') || '(空)'}`);
} else {
reporter.pass('后台 HTML', adminLike.join(', '));
}
const forbidHtml = expect.forbidHtmlPatterns ?? [];
for (const name of [...surveyLike, ...adminLike]) {
const html = await fs.readFile(path.join(publicDir, name), 'utf8');
if (!html.includes('page-data-client.js')) {
reporter.fail(`${name} 脚本`, '未引用 page-data-client.js');
}
for (const pattern of forbidHtml) {
if (pattern && html.includes(pattern)) {
reporter.fail(`${name} 禁用模式`, `命中 ${pattern}`);
}
}
}
if (surveyLike.length && adminLike.length) {
reporter.pass('Page Data 客户端', '问卷/后台 HTML 已引用 page-data-client.js');
}
if (expect.requirePolicy) {
try {
const policies = await fs.readdir(policyDir);
const jsonPolicies = policies.filter((name) => name.endsWith('.json'));
if (jsonPolicies.length === 0) {
reporter.fail('Page Data 策略', 'page-data-policies/ 为空');
} else {
reporter.pass('Page Data 策略', `${jsonPolicies.length} 个 policy 文件`);
}
} catch {
reporter.fail('Page Data 策略', '缺少 .mindspace/page-data-policies/');
}
}
if (expect.requireDataset) {
try {
await fs.stat(sqlitePath);
reporter.pass('私有 SQLite', 'private-data.sqlite 存在');
} catch {
reporter.fail('私有 SQLite', 'private-data.sqlite 不存在');
}
}
const links = extractPublicLinks(replyText, 'http://127.0.0.1:8081');
if (links.length >= 2) {
reporter.pass('交付链接', `${links.length} 个链接`);
} else if (links.length === 1) {
reporter.fail('交付链接', '仅 1 个链接,期望问卷 + 后台');
} else {
reporter.fail('交付链接', '回复中未找到 MindSpace 链接');
}
return true;
}
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
+106
View File
@@ -0,0 +1,106 @@
/**
* 绑定并发布 john 的 TKMind 问卷页面(Page Data API 演示)。
* 用法:node scripts/setup-page-data-survey-demo.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '88888888';
const SURVEY_FORM_POLICY = {
accessMode: 'public',
datasets: {
survey_responses: {
insert: true,
read: false,
columns: {
insert: ['q1_feature', 'q2_usage', 'q3_suggestion'],
},
},
},
};
const SURVEY_ADMIN_POLICY = {
accessMode: 'password',
datasets: {
survey_responses: {
insert: false,
read: true,
columns: {
read: ['id', 'q1_feature', 'q2_usage', 'q3_suggestion', 'created_at'],
},
},
},
};
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const pages = [
{
relativePath: 'public/tkmind-survey.html',
accessMode: 'public',
password: null,
policy: SURVEY_FORM_POLICY,
},
{
relativePath: 'public/tkmind-survey-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: SURVEY_ADMIN_POLICY,
},
];
console.log('绑定并发布 Page Data 问卷演示页…\n');
const results = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
results.push(result);
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` workspace: ${result.workspaceUrl}`);
console.log(` publication: ${result.publicationUrl}\n`);
}
console.log('完成。测试入口:');
console.log(` 问卷:${results[0].workspaceUrl}`);
console.log(` 后台:${results[1].workspaceUrl}`);
console.log(` 后台密码:${ADMIN_PASSWORD}`);
await pool.end();
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Verify publication delivery chain: file layer vs /u/ publication layer.
*/
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
const username = process.env.VERIFY_USER || 'john';
const password = process.env.VERIFY_PASSWORD || '888888';
const userId = process.env.VERIFY_USER_ID || '1c99b83b-0454-474f-a5d2-129d34506a32';
const checks = [];
function record(name, ok, details = {}) {
checks.push({ name, ok: Boolean(ok), ...details });
const mark = ok ? 'PASS' : 'FAIL';
console.log(`${mark} ${name}${details.detail ? `${details.detail}` : ''}`);
}
async function login() {
const res = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const cookie = res.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
record('login', res.ok && cookie, { detail: `status=${res.status}` });
return cookie;
}
async function headOrGet(url, { cookie } = {}) {
const res = await fetch(url, {
headers: cookie ? { Cookie: cookie } : {},
redirect: 'manual',
});
const text = res.status >= 400 ? await res.text().catch(() => '') : '';
return { status: res.status, text: text.slice(0, 120) };
}
async function waitRun(cookie, sessionId, runId) {
for (let i = 0; i < 60; i += 1) {
const res = await fetch(`${base}/api/agent/runs/${runId}`, { headers: { Cookie: cookie } });
const json = await res.json().catch(() => null);
const status = json?.run?.status;
if (status === 'succeeded' || status === 'failed') return { status, run: json?.run };
await new Promise((r) => setTimeout(r, 1500));
}
return { status: 'timeout' };
}
async function generateTestPage(cookie) {
const started = await fetch(`${base}/api/agent/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: '{}',
});
const sessionId = (await started.json()).id;
const requestId = crypto.randomUUID();
const prompt = `请用 write_file 创建 public/verify-deliver-${Date.now()}.html,标题「发布链路验证页」,内容简单即可。完成后只回复「已生成」。`;
const created = await fetch(`${base}/api/agent/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: prompt }],
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
},
}),
});
const runId = (await created.json())?.run?.id;
const finished = await waitRun(cookie, sessionId, runId);
record('agent_run_generate_page', finished.status === 'succeeded', {
detail: `run=${runId} status=${finished.status}`,
});
return { sessionId, runId, finished };
}
async function findLatestVerifyPage(cookie) {
const res = await fetch(`${base}/api/mindspace/v1/pages?limit=20`, {
headers: { Cookie: cookie },
});
const json = await res.json().catch(() => null);
const items = json?.data ?? [];
const match = items.find((p) => /发布链路验证|verify-deliver/i.test(String(p.title ?? '')))
?? items.find((p) => String(p.workspaceRelativePath ?? '').includes('verify-deliver'));
return match ?? items[0] ?? null;
}
async function main() {
console.log(`\n=== Publication delivery verify @ ${base} ===\n`);
const cookie = await login();
// 1. Existing publication route (/u/...)
for (const slug of ['page-2b201736', 'page-0360d67a']) {
const url = `${base}/u/john/pages/${slug}`;
const { status, text } = await headOrGet(url);
record(`publication_route /u/john/pages/${slug}`, status === 200, {
detail: `HTTP ${status}${status >= 500 ? ` ${text}` : ''}`,
});
}
// 2. File layer (/MindSpace/.../public/...)
const fileUrl = `${base}/MindSpace/${userId}/public/supplier-submit.html`;
const fileRes = await headOrGet(fileUrl);
record('file_layer MindSpace/public/supplier-submit.html', fileRes.status === 200, {
detail: `HTTP ${fileRes.status}`,
});
// 3. Runtime public base
const runtime = await fetch(`${base}/api/runtime/status`).then((r) => r.json()).catch(() => null);
const publicBase = runtime?.publicBaseUrl ?? runtime?.publish?.publicBaseUrl ?? null;
record('runtime public base uses portal', String(publicBase ?? '').includes(':8081'), {
detail: publicBase ?? 'missing',
});
// 4. Generate new page and check publication closure
await generateTestPage(cookie);
await new Promise((r) => setTimeout(r, 2000));
const page = await findLatestVerifyPage(cookie);
if (page) {
const detail = await fetch(`${base}/api/mindspace/v1/pages/${page.id}`, {
headers: { Cookie: cookie },
}).then((r) => r.json()).catch(() => null);
const pub = detail?.data?.publication;
record('new_page_has_publication', Boolean(pub?.id && pub?.status === 'online'), {
detail: pub ? `${pub.status} ${pub.publicUrl ?? ''}` : 'no publication',
});
if (pub?.publicUrl) {
const pubPath = pub.publicUrl.replace(/^https?:\/\/[^/]+/, '');
const pubCheck = await headOrGet(`${base}${pubPath.startsWith('/') ? pubPath : `/${pubPath}`}`);
record('new_page_publication_url_accessible', pubCheck.status === 200, {
detail: `HTTP ${pubCheck.status} ${pub.publicUrl}`,
});
}
const rel = page.workspaceRelativePath ?? page.publicationUrl;
if (page.workspaceRelativePath) {
const wsUrl = `${base}/MindSpace/${userId}/${page.workspaceRelativePath}`;
const ws = await headOrGet(wsUrl);
record('new_page_file_layer', ws.status === 200, { detail: `HTTP ${ws.status}` });
}
} else {
record('new_page_found_in_list', false, { detail: 'no matching page after generate' });
}
const ok = checks.every((c) => c.ok);
console.log(`\n=== ${ok ? 'ALL PASS' : 'SOME FAILED'} (${checks.filter((c) => c.ok).length}/${checks.length}) ===\n`);
process.exitCode = ok ? 0 : 1;
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});