#!/usr/bin/env node /** * Help escalation remediation: fix delivery contract + notify user + optional Goose resend. * * Usage: * node scripts/help-remediate-page-delivery.mjs \ * --user-id= --session-id= --page=public/foo.html [--via-goose] */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { setTimeout as sleep } from 'node:timers/promises'; import { createDbPool } from '../db.mjs'; import { getPageDeliveryContract, normalizeDeliveryRelativePath, releaseMaterializedPageDeliveryContracts, } from '../mindspace-delivery-contract.mjs'; import { createMindSpacePublicPageUrl } from '../mindspace-canonical-url.mjs'; import { createScheduleService } from '../schedule-service.mjs'; import { PUBLISH_ROOT_DIR, resolvePageDataDeliveryBaseUrl } from '../user-publish.mjs'; import { loadH5Environment } from './load-env.mjs'; import { createAgentRun, extractAssistantTexts, extractPublicLinks, getSession, loginViaApi, resolvePortalBase, waitForAssistantGrowth, waitForRunTerminal, } from './scenario-test-lib.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); loadH5Environment(path.dirname(fileURLToPath(import.meta.url))); function parseArgs(argv) { const options = { userId: '', sessionId: '', page: '', viaGoose: false, port: Number(process.env.H5_PORT ?? 8081), username: process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john', password: process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888', }; for (const arg of argv) { if (arg.startsWith('--user-id=')) options.userId = arg.slice('--user-id='.length).trim(); else if (arg.startsWith('--session-id=')) options.sessionId = arg.slice('--session-id='.length).trim(); else if (arg.startsWith('--page=')) options.page = arg.slice('--page='.length).trim(); else if (arg.startsWith('--port=')) options.port = Number(arg.slice('--port='.length)); else if (arg === '--via-goose') options.viaGoose = true; else if (arg === '--help' || arg === '-h') { console.log(`Usage: node scripts/help-remediate-page-delivery.mjs \\ --user-id= --session-id= --page=public/foo.html [--via-goose]`); process.exit(0); } } return options; } function pageTitleFromPath(relativePath) { const base = path.basename(relativePath, '.html'); return base.replace(/[-_]+/g, ' ').trim() || '页面'; } async function main() { const options = parseArgs(process.argv.slice(2)); const relativePath = normalizeDeliveryRelativePath(options.page); if (!options.userId || !relativePath) { console.error('缺少 --user-id 或有效 --page=public/xxx.html'); process.exit(1); } const publishDir = path.join(root, PUBLISH_ROOT_DIR, options.userId); const htmlPath = path.join(publishDir, relativePath); if (!fs.existsSync(htmlPath)) { console.error(`页面文件不存在: ${htmlPath}`); process.exit(1); } const pool = createDbPool(); const scheduleService = createScheduleService(pool, { defaultTimezone: process.env.MEMIND_DEFAULT_TIMEZONE ?? 'Asia/Shanghai', }); try { const released = await releaseMaterializedPageDeliveryContracts({ pool, userId: options.userId, relativePaths: [relativePath], publishDir, }); const contract = await getPageDeliveryContract({ pool, userId: options.userId, relativePath, }); const publicBaseUrl = resolvePageDataDeliveryBaseUrl(process.env); const pageUrl = createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey: options.userId, filename: relativePath.replace(/^public\//, ''), }); const pageTitle = pageTitleFromPath(relativePath); const userMessage = [ '工程师已修复页面交付,请打开链接:', `[${pageTitle}](${pageUrl})`, '', `页面地址:${pageUrl}`, ].join('\n'); let gooseReply = null; if (options.viaGoose && options.sessionId) { const baseUrl = resolvePortalBase(options.port); const reporter = { pass() {}, fail() {} }; const auth = await loginViaApi(baseUrl, { username: options.username, password: options.password, }, reporter); if (auth.user?.id && auth.user.id !== options.userId) { console.warn('[help-remediate] 登录用户与 --user-id 不一致,仍继续'); } const before = await getSession(baseUrl, auth.cookie, options.sessionId); const beforeTexts = before.ok ? extractAssistantTexts(before.session) : []; const followUpText = '请把刚才生成的诗歌 HTML 页面链接发给我'; const run = await createAgentRun(baseUrl, auth.cookie, { message: followUpText, sessionId: options.sessionId, }); const terminal = await waitForRunTerminal( baseUrl, auth.cookie, run.runId, Number(process.env.MEMIND_HELP_REMEDIATE_GOOSE_TIMEOUT_MS ?? 600_000), ); if (terminal.status !== 'succeeded') { throw new Error(`Goose 补救 run 失败: ${terminal.error ?? terminal.status}`); } const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, options.sessionId, { previousCount: beforeTexts.length, previousCombinedLength: beforeTexts.join('\n').length, minChars: 5, timeoutMs: 120_000, }); gooseReply = reply?.combined ?? null; const links = extractPublicLinks(gooseReply ?? '', baseUrl); if (!links.some((url) => url.includes(relativePath.replace(/^public\//, '')))) { console.warn('[help-remediate] Goose 回复未含预期页面链接,将依赖通知补发'); } } await scheduleService.createUserNotification({ userId: options.userId, channel: 'web', notificationType: 'help_escalation_result', title: '工程师已补发页面链接', body: userMessage, data: { pageUrl, relativePath, sessionId: options.sessionId || null, remediatedBy: 'help-remediate-page-delivery', }, }); const result = { status: 'succeeded', userMessage: `页面链接已修复并补发。\n\n${userMessage}`, pageUrl, relativePath, contractStatus: contract?.status ?? null, releasedPaths: released, gooseReplyPreview: gooseReply ? gooseReply.slice(0, 400) : null, }; console.log(JSON.stringify(result, null, 2)); } finally { await pool.end(); } } main().catch((error) => { console.error(error instanceof Error ? error.stack ?? error.message : error); process.exit(1); });