fix(mindspace): fail closed on invalid page data delivery
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user