fix: stabilize MindSpace local workflows

Signed-off-by: john <cynell@139.com>
This commit is contained in:
john
2026-07-12 08:12:15 +08:00
parent 32fb2cdeaf
commit a3f669d4e7
9 changed files with 107 additions and 23 deletions
+16
View File
@@ -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,
};
}
+1
View File
@@ -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),
+4
View File
@@ -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, '项目周报');
+1
View File
@@ -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,
+36 -11
View File
@@ -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 });
}
}
+8 -4
View File
@@ -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 {
+32 -1
View File
@@ -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,
+8 -6
View File
@@ -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 {