export const RECOVERABLE_FINISH_ERROR_CODES = new Set([ 'AGENT_RUN_TIMEOUT', 'SESSION_REPLY_TIMEOUT', 'SESSION_REPLY_INCOMPLETE', 'AGENT_RUN_STALE_RECOVERY', ]); export const SESSION_FINISHED_STALE_GRACE_MS = 90 * 1000; export const WORKSPACE_DELIVERABLE_LOOKBACK_MS = 60 * 1000; export function isRecoverableFinishError(error) { const code = String(error?.code ?? '').trim(); return RECOVERABLE_FINISH_ERROR_CODES.has(code); } export function hasRecoverableSessionDeliverables(summary) { const pageCount = Number(summary?.pageCount ?? 0); const publicationCount = Number(summary?.publicationCount ?? 0); return pageCount > 0 || publicationCount > 0; } function mapDeliverableRows(rows) { const pages = (rows ?? []).map((row) => ({ pageId: String(row.page_id ?? '').trim(), title: String(row.title ?? '').trim(), publicationId: row.publication_id ? String(row.publication_id) : null, publicationStatus: row.publication_status ? String(row.publication_status) : null, publicUrl: row.public_url ? String(row.public_url) : null, workspaceRelativePath: row.workspace_relative_path ? String(row.workspace_relative_path) : null, })).filter((row) => row.pageId); const publicationCount = pages.filter((row) => row.publicationId).length; return { pageCount: pages.length, publicationCount, pages, }; } export function mergeDeliverableSummaries(...summaries) { const pagesById = new Map(); for (const summary of summaries) { for (const page of summary?.pages ?? []) { if (!page?.pageId) continue; pagesById.set(page.pageId, page); } } const pages = [...pagesById.values()]; return { pageCount: pages.length, publicationCount: pages.filter((row) => row.publicationId).length, pages, }; } export async function detectSessionDeliverables(pool, userId, sessionId, { sinceMs = null } = {}) { if (!pool?.query || !userId || !sessionId) { return { pageCount: 0, publicationCount: 0, pages: [] }; } const sinceClause = sinceMs != null ? 'AND p.updated_at >= ?' : ''; const params = sinceMs != null ? [userId, sessionId, sinceMs] : [userId, sessionId]; const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.workspace_relative_path, pr.id AS publication_id, pr.status AS publication_status, pr.public_url FROM h5_page_records p LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' WHERE p.user_id = ? AND p.source_session_id = ? ${sinceClause} AND p.status <> 'deleted' ORDER BY p.created_at ASC`, params, ); return mapDeliverableRows(rows); } export async function detectWorkspaceSyncedDeliverables( pool, userId, { sinceMs = null, onlyRelativePaths = null } = {}, ) { if (!pool?.query || !userId) { return { pageCount: 0, publicationCount: 0, pages: [] }; } const normalizedPaths = onlyRelativePaths == null ? null : [...new Set(onlyRelativePaths.map((item) => String(item ?? '').trim()).filter(Boolean))]; if (normalizedPaths?.length === 0) { return { pageCount: 0, publicationCount: 0, pages: [] }; } const sinceClause = sinceMs != null ? 'AND p.updated_at >= ?' : ''; const pathClause = normalizedPaths == null ? '' : `AND p.workspace_relative_path IN (${normalizedPaths.map(() => '?').join(', ')})`; const params = [userId]; if (sinceMs != null) params.push(sinceMs); if (normalizedPaths != null) params.push(...normalizedPaths); const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.workspace_relative_path, pr.id AS publication_id, pr.status AS publication_status, pr.public_url FROM h5_page_records p JOIN h5_page_versions pv ON pv.id = p.current_version_id LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' WHERE p.user_id = ? AND p.status <> 'deleted' ${sinceClause} ${pathClause} AND ( p.workspace_relative_path LIKE 'public/%.html' OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) LIKE 'public/%.html' ) AND ( JSON_EXTRACT(pv.source_snapshot_json, '$.auto_synced') = true OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.content_mode')) = 'static_html' ) ORDER BY p.updated_at DESC`, params, ); return mapDeliverableRows(rows); } export async function prepareAndDetectSessionDeliverables({ pool, userId, sessionId, runStartedAtMs = null, prepareDeliverables = null, workspaceRelativePaths = null, } = {}) { let prepared = null; if (typeof prepareDeliverables === 'function') { prepared = await prepareDeliverables({ userId, sessionId }).catch(() => null); } const sinceMs = runStartedAtMs == null ? null : Math.max(0, Number(runStartedAtMs) - WORKSPACE_DELIVERABLE_LOOKBACK_MS); const bySession = await detectSessionDeliverables(pool, userId, sessionId, { sinceMs }); const byWorkspace = await detectWorkspaceSyncedDeliverables(pool, userId, { sinceMs, onlyRelativePaths: workspaceRelativePaths ?? prepared?.pageDataRelativePaths ?? null, }); return mergeDeliverableSummaries(bySession, byWorkspace); } export async function tryRecoverRunFromDeliverables({ pool, userId, sessionId, error = null, requireRecoverableError = false, prepareDeliverables = null, runStartedAtMs = null, } = {}) { if (requireRecoverableError && !isRecoverableFinishError(error)) { return null; } const deliverables = await prepareAndDetectSessionDeliverables({ pool, userId, sessionId, runStartedAtMs, prepareDeliverables, }); if (!hasRecoverableSessionDeliverables(deliverables)) { return null; } return { deliverables, originalError: error ? { code: error?.code ?? null, message: error instanceof Error ? error.message : String(error ?? ''), } : null, }; } export async function tryRecoverRunFromSessionDeliverables({ pool, userId, sessionId, error, prepareDeliverables = null, runStartedAtMs = null, } = {}) { return tryRecoverRunFromDeliverables({ pool, userId, sessionId, error, requireRecoverableError: true, prepareDeliverables, runStartedAtMs, }); }