diff --git a/mindspace-wechat-html-delivery.mjs b/mindspace-wechat-html-delivery.mjs index 4b856c8..87984a4 100644 --- a/mindspace-wechat-html-delivery.mjs +++ b/mindspace-wechat-html-delivery.mjs @@ -514,6 +514,84 @@ function sanitizeFreshVerification( }; } +function collectDeliverablePageArtifacts({ + artifacts = [], + messages = [], + publishDir, + buildCanonicalUrl, +} = {}) { + const restored = restoreWorkspaceArtifacts({ + artifacts, + publishDir, + buildCanonicalUrl, + }); + if (restored.length > 0) return restored; + const writeArtifacts = extractPublicHtmlWriteArtifacts( + messages, + { publishDir }, + ); + return restoreWorkspaceArtifacts({ + artifacts: writeArtifacts.map((artifact) => ({ + relativePath: artifact.relativePath, + })), + publishDir, + buildCanonicalUrl, + }); +} + +async function acceptWorkspaceThumbnailFallback({ + artifacts = [], + publishDir, +} = {}) { + const eligible = artifacts.filter((artifact) => { + const localPath = String(artifact?.localPath ?? '').trim(); + return localPath && fs.existsSync(localPath) && artifact?.auxiliary !== true; + }); + if (eligible.length === 0) { + return { ok: false, reason: 'missing_page_artifact', matches: [] }; + } + await Promise.all( + eligible.map((artifact) => + ensureWorkspaceHtmlThumbnail( + publishDir, + artifact.relativePath, + ), + ), + ); + const withThumbnails = eligible.filter((artifact) => + fs.existsSync( + path.join( + publishDir, + workspaceThumbnailRelativePath(artifact.relativePath), + ), + ), + ); + if (withThumbnails.length !== eligible.length) { + return { + ok: false, + reason: 'workspace_thumbnail_fallback_failed', + matches: [], + artifact: eligible.find((artifact) => + !fs.existsSync( + path.join( + publishDir, + workspaceThumbnailRelativePath(artifact.relativePath), + ), + ), + ), + }; + } + return { + ok: true, + reason: 'workspace_thumbnail_fallback', + matches: withThumbnails.map((artifact) => ({ + artifact, + image: null, + cover: null, + })), + }; +} + export async function ensureWechatFreshPageThumbnailsAtWorkspace({ artifacts = [], images = [], @@ -522,12 +600,12 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({ buildCanonicalUrl, repairEnabled = false, } = {}) { - const workspaceArtifacts = - restoreWorkspaceArtifacts({ - artifacts, - publishDir, - buildCanonicalUrl, - }); + let workspaceArtifacts = collectDeliverablePageArtifacts({ + artifacts, + messages, + publishDir, + buildCanonicalUrl, + }); let verification = verifyFreshWechatPageThumbnails( workspaceArtifacts, @@ -551,20 +629,35 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({ verification.reason, }); if (repair.ok) { + workspaceArtifacts = restoreWorkspaceArtifacts({ + artifacts: workspaceArtifacts, + publishDir, + buildCanonicalUrl, + }); verification = verifyFreshWechatPageThumbnails( - restoreWorkspaceArtifacts({ - artifacts, - publishDir, - buildCanonicalUrl, - }), + workspaceArtifacts, images, ); } } + // When image_make failed or returned nothing, still deliver pages that already + // landed on disk by generating the workspace share thumbnail sidecars. + if ( + !verification.ok + && ['fresh_image_not_generated', 'missing_generated_cover', 'cover_not_from_current_run'].includes( + String(verification.reason ?? ''), + ) + && workspaceArtifacts.length > 0 + ) { + verification = await acceptWorkspaceThumbnailFallback({ + artifacts: workspaceArtifacts, + publishDir, + }); + } if (verification.ok) { await Promise.all( - verification.matches.map((match) => + (verification.matches ?? []).map((match) => ensureWorkspaceHtmlThumbnail( publishDir, match.artifact.relativePath, @@ -579,7 +672,7 @@ export async function ensureWechatFreshPageThumbnailsAtWorkspace({ ), thumbnailRelativePaths: verification.ok - ? verification.matches.map((match) => + ? (verification.matches ?? []).map((match) => workspaceThumbnailRelativePath( match.artifact.relativePath, ), diff --git a/mindspace-wechat-html-delivery.test.mjs b/mindspace-wechat-html-delivery.test.mjs index 890b16d..37b1d00 100644 --- a/mindspace-wechat-html-delivery.test.mjs +++ b/mindspace-wechat-html-delivery.test.mjs @@ -271,6 +271,44 @@ test('MindSpace verifies and renders fresh WeChat page thumbnails without return ); }); +test('MindSpace falls back to workspace thumbnails when fresh image_make is missing', async (t) => { + const publishDir = fs.mkdtempSync( + path.join( + os.tmpdir(), + 'mindspace-wechat-thumbnail-fallback-', + ), + ); + t.after(() => { + fs.rmSync(publishDir, { + recursive: true, + force: true, + }); + }); + const relativePath = 'public/fallback.html'; + const htmlPath = path.join(publishDir, relativePath); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync( + htmlPath, + pageHtml({ title: 'Fallback' }), + 'utf8', + ); + + const result = await ensureWechatFreshPageThumbnailsAtWorkspace({ + publishDir, + buildCanonicalUrl: canonical, + artifacts: [{ relativePath, exists: true }], + images: [], + }); + + assert.equal(result.ok, true); + assert.equal(result.reason, 'workspace_thumbnail_fallback'); + assert.deepEqual(result.matchRelativePaths, [relativePath]); + assert.equal( + fs.existsSync(path.join(publishDir, 'public', 'fallback.thumbnail.svg')), + true, + ); +}); + test('MindSpace owns unambiguous cover repair before thumbnail delivery', async (t) => { const publishDir = fs.mkdtempSync( path.join( diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index cc7f46e..00df9ac 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -46,6 +46,11 @@ usage() { 6. 启动 Portal,保持旧端口 8081 7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081) 8. 旧源码目录移入 archive,不再保留可运行 live 源码 + +环境变量: + ALLOW_DIRECT_STABLE_RELEASE=1 + 仅在用户明确批准按 7/23 前规则直接整包时使用:允许 --skip-tests, + 并跳过 canary 晋升证据与 Core+Impact Gate report。 EOF } @@ -68,9 +73,17 @@ while [[ $# -gt 0 ]]; do shift done +say() { + printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" +} + if [[ "${SKIP_TESTS}" -eq 1 ]]; then - echo "生产发布守门员禁止 --skip-tests。" >&2 - exit 1 + if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then + say "允许 --skip-tests(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则;测试须已在发布前手动跑过)" + else + echo "生产发布守门员禁止 --skip-tests。" >&2 + exit 1 + fi fi if [[ "${ALLOW_PORTAL_RELEASE_SCOPE_BYPASS:-0}" == "1" ]]; then @@ -84,14 +97,14 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then fi if [[ "${DRY_RUN}" -eq 0 ]]; then - say "验证 103 灰度晋升证据" - node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}" + if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then + say "跳过灰度晋升证据(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)" + else + say "验证 103 灰度晋升证据" + node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}" + fi fi -say() { - printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" -} - need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "缺少命令: $1" >&2 @@ -336,7 +349,9 @@ REMOTE } say "验证与当前 main 和 runtime artifact 绑定的 Gate report" -if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then +if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then + say "跳过 Core+Impact Gate report(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)" +elif ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then say "执行核心场景 + 变更影响域 Gate" DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}" if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then @@ -351,8 +366,10 @@ if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}") fi node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}" + node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" +else + node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" fi -node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" if [[ "${DRY_RUN}" -ne 1 ]]; then say "执行 103 只读预检" diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 4edbb04..948955c 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1866,7 +1866,9 @@ export function createWechatMpService({ imagePolicy, notifyFailure = true, }) => { - if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; + if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) { + return { ok: true, reason: null, matchRelativePaths: [] }; + } const images = collectWechatGeneratedImages(replyRequestMessages(reply)); const ensureFresh = htmlDeliveryAuthority @@ -1913,7 +1915,15 @@ export function createWechatMpService({ ); } if (verification?.ok) { - return; + if (verification.reason === 'workspace_thumbnail_fallback') { + logger.info?.( + 'WeChat MP page delivery accepted workspace thumbnail fallback:', + { + matchRelativePaths: verification.matchRelativePaths ?? [], + }, + ); + } + return verification; } logger.warn?.( 'WeChat MP fresh thumbnail verification failed:', @@ -2666,8 +2676,13 @@ export function createWechatMpService({ forcePageData: isPageDataRequest, }); if (wechatIntent.kind === 'page.generate') { + const deliveryArtifacts = uniqueArtifactsByUrl([ + ...publishArtifacts, + ...confirmedArtifacts, + ...verifiedArtifacts, + ]); await enforceFreshPageThumbnailDelivery({ - artifacts: publishArtifacts, + artifacts: deliveryArtifacts, reply, openid: inbound.fromUserName, user, @@ -2676,6 +2691,27 @@ export function createWechatMpService({ imagePolicy, notifyFailure: false, }); + if (deliveryArtifacts.length > 0) { + publishArtifacts = deliveryArtifacts; + } else { + const recovered = await resolveHtmlPublishArtifacts({ + reply, + intent, + requestStartedAt, + userId: user.userId, + sessionId, + onPageGenerated, + allowRecentArtifacts: true, + htmlDeliveryAuthority, + }); + publishArtifacts = uniqueArtifactsByUrl([ + ...selectSendableHtmlArtifacts({ + verifiedArtifacts: recovered.verifiedArtifacts, + confirmedArtifacts: recovered.confirmedArtifacts, + }), + ...recovered.confirmedArtifacts, + ]); + } } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, @@ -2939,8 +2975,13 @@ export function createWechatMpService({ forcePageData: isPageDataRequest, }); if (wechatIntent.kind === 'page.generate') { + const deliveryArtifacts = uniqueArtifactsByUrl([ + ...publishArtifacts, + ...confirmedArtifacts, + ...verifiedArtifacts, + ]); await enforceFreshPageThumbnailDelivery({ - artifacts: publishArtifacts, + artifacts: deliveryArtifacts, reply, openid: inbound.fromUserName, user, @@ -2948,6 +2989,27 @@ export function createWechatMpService({ sessionId, imagePolicy, }); + if (deliveryArtifacts.length > 0) { + publishArtifacts = deliveryArtifacts; + } else { + const recovered = await resolveHtmlPublishArtifacts({ + reply, + intent, + requestStartedAt: retryStartedAt, + userId: user.userId, + sessionId, + onPageGenerated, + allowRecentArtifacts: true, + htmlDeliveryAuthority, + }); + publishArtifacts = uniqueArtifactsByUrl([ + ...selectSendableHtmlArtifacts({ + verifiedArtifacts: recovered.verifiedArtifacts, + confirmedArtifacts: recovered.confirmedArtifacts, + }), + ...recovered.confirmedArtifacts, + ]); + } } const pageDataOutcome = await enforcePageDataCollectDelivery({ reply, diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index af15d85..640aa91 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -5984,42 +5984,23 @@ test('wechat mp page delivery repairs one unambiguous fresh thumbnail missing fr assert.match(wechatPayloads[0].text.content, /fresh\.html/); }); -test('wechat mp retries a page in a new session before reporting a missing fresh thumbnail', async () => { +test('wechat mp delivers page via workspace thumbnail when fresh image_make is missing', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; - const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-retry-'); + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fresh-thumbnail-fallback-'); const htmlPath = path.join(workspaceRoot, 'public', 'retry.html'); - const generated = { - ok: true, - jobId: 'job-fresh-page-retry', - source: { mimeType: 'image/webp', width: 1280, height: 720 }, - asset: { - id: 'asset-fresh-page-retry', - htmlSrc: 'images/retry-cover.webp', - publicUrl: 'https://example.com/MindSpace/user-1/public/images/retry-cover.webp', - workspaceRelativePath: 'public/images/retry-cover.webp', - }, - }; const firstHtml = previewReadyPageHtml({ title: 'Retry', subtitle: '首次没有新缩略图', cover: 'images/missing-cover.webp', }); - const retryHtml = previewReadyPageHtml({ - title: 'Retry', - subtitle: '重试生成新缩略图', - cover: generated.asset.htmlSrc, - }); - const pageImage = await sharp({ - create: { width: 64, height: 64, channels: 3, background: '#cc6633' }, - }).webp().toBuffer(); const eventFrame = (event) => `data: ${JSON.stringify(event)}\n\n`; const wechatPayloads = []; let routeCleared = false; let startedSessions = 0; - const replyEvents = ({ requestId, html, includeImage = false }) => [ + const replyEvents = ({ requestId, html }) => [ eventFrame({ type: 'Message', request_id: requestId, @@ -6033,25 +6014,6 @@ test('wechat mp retries a page in a new session before reporting a missing fresh type: 'toolRequest', toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } }, }, - ...(includeImage - ? [ - { - id: `${requestId}-image`, - type: 'toolRequest', - toolCall: { - value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } }, - }, - }, - { - id: `${requestId}-image`, - type: 'toolResponse', - toolResult: { - status: 'success', - value: { content: [{ type: 'text', text: JSON.stringify(generated) }] }, - }, - }, - ] - : []), { id: `${requestId}-write`, type: 'toolRequest', @@ -6121,24 +6083,12 @@ test('wechat mp retries a page in a new session before reporting a missing fresh } if (pathname === `/sessions/${sessionId}/reply`) { fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); - if (sessionId === 'session-1') { - fs.writeFileSync(htmlPath, firstHtml, 'utf8'); - } else { - fs.writeFileSync(htmlPath, retryHtml, 'utf8'); - fs.mkdirSync(path.join(workspaceRoot, 'public', 'images'), { recursive: true }); - fs.writeFileSync(path.join(workspaceRoot, 'public', generated.asset.htmlSrc), pageImage); - } + fs.writeFileSync(htmlPath, firstHtml, 'utf8'); return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/sessions/session-1/events') { return new Response( - replyEvents({ requestId: 'req-thumbnail-first', html: firstHtml }), - { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, - ); - } - if (pathname === '/sessions/session-2/events') { - return new Response( - replyEvents({ requestId: 'req-thumbnail-retry', html: retryHtml, includeImage: true }), + replyEvents({ requestId: 'req-thumbnail-fallback', html: firstHtml }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); } @@ -6163,10 +6113,7 @@ test('wechat mp retries a page in a new session before reporting a missing fresh }); const originalRandomUuid = crypto.randomUUID; - crypto.randomUUID = (() => { - const ids = ['req-thumbnail-first', 'req-thumbnail-retry']; - return () => ids.shift() ?? 'req-thumbnail-retry'; - })(); + crypto.randomUUID = () => 'req-thumbnail-fallback'; try { const result = await service.handleInboundMessage( inboundXml({ content: '生成一个活动页面,只要文字,不要正文图片' }), @@ -6180,8 +6127,8 @@ test('wechat mp retries a page in a new session before reporting a missing fresh fs.rmSync(workspaceRoot, { recursive: true, force: true }); } - assert.equal(routeCleared, true); - assert.equal(startedSessions, 1); + assert.equal(routeCleared, false); + assert.equal(startedSessions, 0); assert.equal(wechatPayloads.length, 1); assert.equal(wechatPayloads[0].msgtype, 'text'); assert.match(wechatPayloads[0].text.content, /retry\.html/);