fix(mindspace): fail closed on invalid page data delivery

This commit is contained in:
john
2026-07-14 19:40:47 +08:00
parent 192548b0b9
commit 72fd88219c
6 changed files with 114 additions and 11 deletions
+8 -1
View File
@@ -817,12 +817,19 @@ export function createAgentRunGateway({
let deliverables = null;
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
const latest = await getRunById(runId);
// `[]` is a strict allowlist meaning no workspace page may be examined.
// Static page runs commonly have no Page Data artifact list, so use
// `null` to discover the current run's newly synced workspace output.
const workspaceRelativePaths = Array.isArray(deliveryResult?.pageDataRelativePaths)
&& deliveryResult.pageDataRelativePaths.length > 0
? deliveryResult.pageDataRelativePaths
: null;
deliverables = await prepareAndDetectSessionDeliverables({
pool,
userId: row.user_id,
sessionId,
runStartedAtMs: latest?.started_at ?? row.started_at ?? null,
workspaceRelativePaths: deliveryResult?.pageDataRelativePaths ?? [],
workspaceRelativePaths,
});
if (requiresPageDeliverable && deliverables.pageCount < 1) {
const error = new Error(
+41
View File
@@ -619,6 +619,47 @@ test('agent run succeeds when Finish is missing but session pages were already c
);
});
test('static page run succeeds when no Page Data artifact path is recorded', async () => {
const pool = createFakePool({
workspaceDeliverables: {
'user-1': [{
page_id: 'page-static-workspace',
title: '都市时尚',
publication_id: 'pub-static-workspace',
publication_status: 'online',
public_url: 'http://127.0.0.1:5173/MindSpace/user-1/public/urban-fashion.html',
workspace_relative_path: 'public/urban-fashion.html',
updated_at: Date.now(),
}],
},
});
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-static-workspace' };
},
async submitSessionReplyAndAwaitFinishForUser() {
return { ok: true, finishEvent: { type: 'Finish' } };
},
},
// This mirrors a static-page-publish run: no Page Data artifact list.
syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] }, pageDataRelativePaths: [] }),
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-static-workspace-page',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '生成一个都市时尚展示页面' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
});
test('agent run uses direct chat service for eligible chat messages', async () => {
const pool = createFakePool();
const directRuns = [];
+12
View File
@@ -31,6 +31,18 @@ export function shouldPromoteSessionIdToStreaming(chatState) {
return chatState !== 'idle';
}
/**
* Only transport uncertainty may continue a run after submit fails. A
* deterministic gateway error already has a terminal outcome and must return
* the chat UI to an error/idle state instead of leaving a Stop button active.
*
* @param {number | undefined | null} status
* @returns {boolean}
*/
export function shouldKeepStreamingAfterRunError(status) {
return status === 0 || status === 409 || Number(status) >= 500;
}
/**
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
+9
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldKeepStreamingAfterRunError,
shouldPromoteSessionIdToStreaming,
} from './chat-agent-run-gate.mjs';
@@ -47,6 +48,14 @@ test('completed run wins when the direct-chat snapshot is not ready', () => {
);
});
test('only ambiguous transport failures keep the chat streaming', () => {
assert.equal(shouldKeepStreamingAfterRunError(0), true);
assert.equal(shouldKeepStreamingAfterRunError(409), true);
assert.equal(shouldKeepStreamingAfterRunError(503), true);
assert.equal(shouldKeepStreamingAfterRunError(400), false);
assert.equal(shouldKeepStreamingAfterRunError(undefined), false);
});
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
+38 -4
View File
@@ -138,8 +138,11 @@ import {
} from './mindspace-public-finish-sync.mjs';
import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs';
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
import { evaluatePageDataHtmlContent, maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs';
import { readPageAccessPolicy } from './page-data-policy-store.mjs';
import { policyAllowsAction } from './page-access-policy.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
@@ -736,6 +739,30 @@ async function bootstrapUserAuth() {
isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0,
validateRunDeliverables: async ({ userId, deliverables }) => {
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const pageDataErrors = [];
for (const page of deliverables?.pages ?? []) {
const relativePath = normalizeWorkspaceRelativePath(page.workspaceRelativePath);
if (!relativePath?.startsWith('public/')) continue;
const filePath = path.resolve(publishDir, relativePath);
if (!filePath.startsWith(`${path.resolve(publishDir)}${path.sep}`) || !fs.existsSync(filePath)) continue;
const html = fs.readFileSync(filePath, 'utf8');
const evaluation = evaluatePageDataHtmlContent(html, { relativePath });
if (!evaluation.usesPageDataApi) continue;
for (const issue of evaluation.issues) {
pageDataErrors.push({ code: issue, message: `${relativePath} Page Data HTML 不可交付:${issue}` });
}
const policy = page.pageId ? readPageAccessPolicy(publishDir, page.pageId) : null;
for (const [dataset, actions] of detectPageDataDatasetUsageFromHtml(html)) {
for (const action of ['read', 'insert']) {
if (actions?.[action] && !policyAllowsAction(policy, dataset, action)) {
pageDataErrors.push({
code: 'page_data_policy_action_missing',
message: `${relativePath}${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`,
});
}
}
}
}
const violations = scanWorkspaceFilesForProhibitedBrowserStorage({
publishDir,
relativePaths: (deliverables?.pages ?? [])
@@ -743,10 +770,10 @@ async function bootstrapUserAuth() {
.filter(Boolean),
});
return {
errors: violations.map((violation) => ({
errors: [...pageDataErrors, ...violations.map((violation) => ({
code: 'browser_storage_forbidden',
message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`,
})),
}))],
};
},
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
@@ -3286,9 +3313,16 @@ async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null
// Agent-run/Finish delivery must stay scoped to the current conversation.
// A stale Page Data page elsewhere in the user's workspace must not turn a
// successfully completed current task into a failed run.
const pageDataRelativePaths = sessionId
const discoveredRelativePaths = sessionId
? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs })
: null;
// An empty artifact package does not prove that this run wrote no page: a
// normal static-page-publish flow may only leave a public HTML workspace
// file. `null` means "discover current workspace output"; `[]` would tell
// the sync service to inspect nothing and make a completed page run fail.
const pageDataRelativePaths = discoveredRelativePaths?.length
? discoveredRelativePaths
: null;
if (workspacePageDeliver?.syncAndDeliver) {
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
}
+6 -6
View File
@@ -54,6 +54,7 @@ import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldKeepStreamingAfterRunError,
shouldPromoteSessionIdToStreaming,
} from '../../chat-agent-run-gate.mjs';
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
@@ -233,11 +234,6 @@ function mergeMessagePages(older: Message[], current: Message[]): Message[] {
return merged;
}
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
}
const SESSION_LIST_RETRY_ATTEMPTS = 3;
const SESSION_LIST_RETRY_DELAY_MS = 450;
@@ -1619,7 +1615,11 @@ export function useTKMindChat(
}
}
} catch (err) {
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
if (
activeSessionId &&
err instanceof ApiError &&
shouldKeepStreamingAfterRunError(err.status)
) {
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);