fix: stabilize MindSpace local workflows
Signed-off-by: john <cynell@139.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const JOB_TYPES = new Set(['generate_page', 'analyze_asset', 'summarize']);
|
||||
@@ -625,6 +626,20 @@ export function createAgentJobService(pool, options = {}) {
|
||||
return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now });
|
||||
};
|
||||
|
||||
const setSessionId = async (jobId, token, sessionId) => {
|
||||
await requireJobToken(jobId, token);
|
||||
const normalizedSessionId = String(sessionId ?? '').trim();
|
||||
if (!normalizedSessionId) {
|
||||
throw agentJobError('Agent 会话不能为空', 'invalid_agent_job_input');
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE h5_agent_jobs
|
||||
SET session_id = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[normalizedSessionId, nowFactory(), jobId],
|
||||
);
|
||||
};
|
||||
|
||||
const completeJob = async (jobId, token, input) => {
|
||||
const job = await requireJobToken(jobId, token);
|
||||
const now = nowFactory();
|
||||
@@ -722,6 +737,7 @@ export function createAgentJobService(pool, options = {}) {
|
||||
reapStaleJobs,
|
||||
getAssetForJob,
|
||||
heartbeat,
|
||||
setSessionId,
|
||||
completeJob,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,6 +362,7 @@ export function createMindSpaceAgentRunner({
|
||||
if (!sessionId) {
|
||||
throw runnerError('Agent 会话启动失败', 'worker_unavailable');
|
||||
}
|
||||
await agentJobService.setSessionId(jobId, claim.jobToken, sessionId);
|
||||
await sessionStore.registerAgentSession(claim.userId, sessionId);
|
||||
await reconcileAgentSession(
|
||||
(pathname, init) => apiFetch(pathname, init),
|
||||
|
||||
@@ -121,6 +121,9 @@ test('runner claims job, executes reply, bills usage, and completes job', async
|
||||
path: file,
|
||||
};
|
||||
},
|
||||
async setSessionId(jobId, token, sessionId) {
|
||||
calls.push(['setSessionId', jobId, token, sessionId]);
|
||||
},
|
||||
async completeJob(jobId, token, payload) {
|
||||
calls.push(['completeJob', jobId, token, payload]);
|
||||
return { id: jobId, status: payload.status ?? 'completed', resultPageId: 'page-1' };
|
||||
@@ -150,6 +153,7 @@ test('runner claims job, executes reply, bills usage, and completes job', async
|
||||
|
||||
const result = await runner.runJob('job-1');
|
||||
assert.equal(result.status, 'completed');
|
||||
assert.equal(calls.some((item) => item[0] === 'setSessionId' && item[3] === 'session-1'), true);
|
||||
assert.equal(calls.some((item) => item[0] === 'billSessionUsage'), true);
|
||||
const completeCall = calls.find((item) => item[0] === 'completeJob');
|
||||
assert.equal(completeCall[3].title, '项目周报');
|
||||
|
||||
@@ -30,6 +30,7 @@ test('createMindSpaceServerAdapter returns a contract-complete local adapter by
|
||||
maxFileBytes: 4096,
|
||||
publicPageLimit: 3,
|
||||
resolveUserIdForAgentSession: async () => 'user-1',
|
||||
resolveWorkspaceRoot: async () => '/tmp/workspace',
|
||||
logger: { log() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "问卷增加饮食偏好(每日几顿)",
|
||||
"message": "问卷页面再增加一个饮食偏好,比如每日几顿这种",
|
||||
"message": "请给问卷页面新增 q4_diet_meals(每日几顿)饮食字段,并同步更新 children-hobby-admin.html 后台列表来展示该字段;问卷、后台、Page Data 策略三处必须一致。",
|
||||
"selectedChatSkill": "page-data-collect",
|
||||
"expect": {
|
||||
"assistantMinChars": 80,
|
||||
|
||||
@@ -31,12 +31,22 @@ const BASE_URL = `http://127.0.0.1:${portalPort}`;
|
||||
const HTML_NAME = 'fruit-theme-test.html';
|
||||
const USERNAME = 'john';
|
||||
const JOHN_PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj';
|
||||
const publishDir = path.join(root, 'MindSpace', USERNAME);
|
||||
const htmlPath = path.join(publishDir, HTML_NAME);
|
||||
const thumbRel = workspaceThumbnailRelativePath(HTML_NAME);
|
||||
const publicPageUrl = buildPublicUrl(BASE_URL, USERNAME, HTML_NAME);
|
||||
const publicThumbUrl = buildPublicUrl(BASE_URL, USERNAME, thumbRel);
|
||||
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
|
||||
const thumbPngRel = thumbRel.replace(/\.svg$/i, '.png');
|
||||
let publishDir = null;
|
||||
let htmlPath = null;
|
||||
let publicPageUrl = null;
|
||||
let publicThumbUrl = null;
|
||||
const fixtureHtml = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="mindspace-cover" content="夏日鲜果指南,美食与水果主题">
|
||||
<title>夏日鲜果指南</title>
|
||||
</head>
|
||||
<body><main><h1>夏日鲜果指南</h1><p>鲜果美食主题测试页面。</p></main></body>
|
||||
</html>`;
|
||||
let createdFixture = false;
|
||||
|
||||
const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: root,
|
||||
@@ -111,7 +121,7 @@ async function ensureJohnPassword() {
|
||||
});
|
||||
assert.equal(response.status, 200, 'john HTTP 登录应成功');
|
||||
await pool.end();
|
||||
return cookieHeader(setCookie);
|
||||
return { cookie: cookieHeader(setCookie), userId: login.user.id };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -119,6 +129,20 @@ async function main() {
|
||||
await waitForPortal();
|
||||
console.log(`BASE_URL: ${BASE_URL}`);
|
||||
|
||||
const { cookie, userId } = await ensureJohnPassword();
|
||||
console.log('✓ john 本地登录成功');
|
||||
publishDir = path.join(root, 'MindSpace', userId, 'public');
|
||||
htmlPath = path.join(publishDir, HTML_NAME);
|
||||
publicPageUrl = buildPublicUrl(BASE_URL, userId, `public/${HTML_NAME}`);
|
||||
publicThumbUrl = buildPublicUrl(BASE_URL, userId, `public/${thumbPngRel}`);
|
||||
|
||||
try {
|
||||
await fs.access(htmlPath);
|
||||
} catch {
|
||||
await fs.mkdir(publishDir, { recursive: true });
|
||||
await fs.writeFile(htmlPath, fixtureHtml, 'utf8');
|
||||
createdFixture = true;
|
||||
}
|
||||
const html = await fs.readFile(htmlPath, 'utf8');
|
||||
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
|
||||
|
||||
@@ -135,17 +159,14 @@ async function main() {
|
||||
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
|
||||
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
|
||||
|
||||
const thumbCheck = await request(`/MindSpace/${USERNAME}/${thumbRel}`);
|
||||
const thumbCheck = await request(`/MindSpace/${userId}/public/${thumbPngRel}`);
|
||||
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
|
||||
assert.match(String(thumbCheck.payload), /width="540" height="720"/, '线上预览图为 3:4');
|
||||
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
|
||||
|
||||
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
|
||||
assert.match(agentReplyLink, /^\[.+\]\(https?:\/\/.+\)$/);
|
||||
console.log(`✓ Agent 应回复的可点击链接:\n ${agentReplyLink}`);
|
||||
|
||||
const cookie = await ensureJohnPassword();
|
||||
console.log('✓ john 本地登录成功');
|
||||
|
||||
const { response: spaceRes, payload: spacePayload } = await request('/api/mindspace/v1/space', {
|
||||
cookie,
|
||||
});
|
||||
@@ -182,4 +203,8 @@ try {
|
||||
await main();
|
||||
} finally {
|
||||
portal.kill('SIGTERM');
|
||||
if (createdFixture && htmlPath && publishDir) {
|
||||
await fs.rm(htmlPath, { force: true });
|
||||
await fs.rm(path.join(publishDir, thumbRel), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,7 @@ try {
|
||||
|
||||
const finalJob = await pollJob(owner.cookie, jobId);
|
||||
assert.equal(finalJob.status, 'completed', JSON.stringify(finalJob));
|
||||
assert.ok(finalJob.sessionId, '任务完成后应关联 Agent 会话');
|
||||
assert.ok(finalJob.resultPageId, '任务完成后应生成页面草稿');
|
||||
|
||||
const pageDetail = await request(`/api/mindspace/v1/pages/${finalJob.resultPageId}`, {
|
||||
@@ -348,11 +349,14 @@ try {
|
||||
assert.equal(draftPages.response.status, 200, JSON.stringify(draftPages.body));
|
||||
assert.equal(draftPages.body.data.length, 1);
|
||||
|
||||
const [usageRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS count FROM h5_usage_records WHERE user_id = ?`,
|
||||
[owner.id],
|
||||
const [billingRows] = await pool.query(
|
||||
`SELECT last_input_tokens, last_output_tokens
|
||||
FROM h5_session_billing_state
|
||||
WHERE agent_session_id = ? AND user_id = ?`,
|
||||
[finalJob.sessionId, owner.id],
|
||||
);
|
||||
assert.equal(Number(usageRows[0]?.count ?? 0), 1, 'Agent 运行后应记录一次扣费');
|
||||
assert.equal(Number(billingRows[0]?.last_input_tokens ?? 0), 12, 'Agent 运行后应记录输入 Token');
|
||||
assert.equal(Number(billingRows[0]?.last_output_tokens ?? 0), 20, 'Agent 运行后应记录输出 Token');
|
||||
|
||||
console.log('MindSpace agent jobs E2E passed');
|
||||
} finally {
|
||||
|
||||
@@ -30,6 +30,7 @@ const portal = spawn(process.execPath, ['server.mjs'], {
|
||||
env: {
|
||||
...process.env,
|
||||
H5_PORT: String(portalPort),
|
||||
H5_PUBLIC_BASE_URL: baseUrl,
|
||||
TKMIND_API_TARGET: 'http://127.0.0.1:9',
|
||||
MINDSPACE_STORAGE_ROOT: storageRoot,
|
||||
MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '1',
|
||||
@@ -59,7 +60,8 @@ async function waitForPortal() {
|
||||
}
|
||||
|
||||
async function request(pathname, options = {}) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`, options);
|
||||
const url = /^https?:\/\//i.test(pathname) ? pathname : `${baseUrl}${pathname}`;
|
||||
const response = await fetch(url, options);
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
const body = contentType.includes('application/json')
|
||||
? await response.json()
|
||||
@@ -91,9 +93,35 @@ function authHeaders(cookie) {
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
let originalPublicPageLimit = null;
|
||||
|
||||
async function setPublicPageLimitForTest() {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT value FROM mindspace_config WHERE `key` = 'public_page_limit' LIMIT 1",
|
||||
);
|
||||
originalPublicPageLimit = rows[0]?.value ?? null;
|
||||
await pool.query(
|
||||
`INSERT INTO mindspace_config (\`key\`, value, description, updated_at)
|
||||
VALUES ('public_page_limit', '1', '公开页面数量上限', ?)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)`,
|
||||
[Date.now()],
|
||||
);
|
||||
}
|
||||
|
||||
async function restorePublicPageLimit() {
|
||||
if (originalPublicPageLimit == null) {
|
||||
await pool.query("DELETE FROM mindspace_config WHERE `key` = 'public_page_limit'");
|
||||
return;
|
||||
}
|
||||
await pool.query(
|
||||
"UPDATE mindspace_config SET value = ?, updated_at = ? WHERE `key` = 'public_page_limit'",
|
||||
[originalPublicPageLimit, Date.now()],
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForPortal();
|
||||
await setPublicPageLimitForTest();
|
||||
const owner = await registerAndLogin(users[0]);
|
||||
const other = await registerAndLogin(users[1]);
|
||||
|
||||
@@ -243,6 +271,7 @@ try {
|
||||
|
||||
let currentPublicationId = republished.body.data.id;
|
||||
const offlineCurrentPublication = async () => {
|
||||
if (!currentPublicationId) return;
|
||||
const response = await request(
|
||||
`/api/mindspace/v1/publications/${currentPublicationId}/offline`,
|
||||
{
|
||||
@@ -318,6 +347,7 @@ try {
|
||||
]);
|
||||
const afterExpiry = await request(publicUrl);
|
||||
assert.equal(afterExpiry.response.status, 404);
|
||||
currentPublicationId = null;
|
||||
|
||||
const finalPublication = await publishMode('public');
|
||||
const finalView = await request(publicUrl, {
|
||||
@@ -410,6 +440,7 @@ try {
|
||||
|
||||
console.log('MindSpace publication API E2E passed');
|
||||
} finally {
|
||||
await restorePublicPageLimit();
|
||||
await pool.query(`DELETE FROM h5_users WHERE username IN (?, ?)`, [
|
||||
users[0].username,
|
||||
users[1].username,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { USER_COOKIE } from '../user-auth.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const scenarioH5Root = path.resolve(process.env.MEMIND_SCENARIO_H5_ROOT ?? repoRoot);
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -246,7 +247,7 @@ async function listPublicHtmlFiles(publishDir) {
|
||||
}
|
||||
|
||||
export async function snapshotPublicHtml(publishKey) {
|
||||
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
|
||||
const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
|
||||
return listPublicHtmlFiles(publishDir);
|
||||
}
|
||||
|
||||
@@ -304,7 +305,7 @@ export async function verifyPageAccess({
|
||||
let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null;
|
||||
|
||||
if (!pageUrl && publishKey) {
|
||||
const htmlAfter = await listPublicHtmlFiles(path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey));
|
||||
const htmlAfter = await listPublicHtmlFiles(path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey));
|
||||
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
|
||||
const fresh = htmlAfter
|
||||
.filter((item) => !beforeSet.has(item.fullPath))
|
||||
@@ -359,7 +360,7 @@ export async function verifySurveyDelivery({
|
||||
expect = {},
|
||||
reporter,
|
||||
}) {
|
||||
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
|
||||
const publishDir = path.join(scenarioH5Root, 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');
|
||||
@@ -469,7 +470,7 @@ async function readJsonIfExists(filePath) {
|
||||
}
|
||||
|
||||
async function findPublicSurveyPolicy(publishKey, datasetName) {
|
||||
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
|
||||
const policyDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(policyDir);
|
||||
@@ -493,7 +494,7 @@ export async function verifyChildrenHobbyDietSurvey({
|
||||
testInsert = true,
|
||||
spec = CHILDREN_HOBBY_DIET_SURVEY,
|
||||
} = {}) {
|
||||
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
|
||||
const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey);
|
||||
const publicDir = path.join(publishDir, 'public');
|
||||
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
|
||||
let ok = true;
|
||||
@@ -524,7 +525,8 @@ export async function verifyChildrenHobbyDietSurvey({
|
||||
} else {
|
||||
reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`);
|
||||
}
|
||||
if (!adminHtml.includes('每日饮食') || !adminHtml.includes(spec.dietField)) {
|
||||
const adminHasDietLabel = spec.dietKeywords.some((keyword) => adminHtml.includes(keyword));
|
||||
if (!adminHasDietLabel || !adminHtml.includes(spec.dietField)) {
|
||||
reporter.fail('后台饮食列', `未展示 ${spec.dietField}`);
|
||||
ok = false;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user