diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index e3a01dd..9274807 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -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( diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index d426ee7..faa1300 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -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 = []; diff --git a/chat-agent-run-gate.mjs b/chat-agent-run-gate.mjs index 2445459..11e8dcf 100644 --- a/chat-agent-run-gate.mjs +++ b/chat-agent-run-gate.mjs @@ -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. diff --git a/chat-agent-run-gate.test.mjs b/chat-agent-run-gate.test.mjs index 46f6682..013a613 100644 --- a/chat-agent-run-gate.test.mjs +++ b/chat-agent-run-gate.test.mjs @@ -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); diff --git a/server.mjs b/server.mjs index eecada0..7bdf230 100644 --- a/server.mjs +++ b/server.mjs @@ -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 }); } diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index e88be94..aa3a44f 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -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);