import type { HandoffRoots, HandoffSource } from './handoffBundle' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive' import { HANDOFF_SKIPPED_DIR, stageHandoffBundle } from './handoffBundle' import { CLAUDE_SESSION_FIELDS } from './handoffManifest' import { codexRolloutRelPathFor, findClaudeSessionFiles } from './sessionBundle' const ROLE_LABEL = { brainstorm: '头脑风暴', implement: '实施', test: '测试', prototype: '原型', review: '审查', } as const export interface HandoffSourceSize { role: string bytes: number } export class HandoffSourcesTooLargeError extends Error { readonly items: HandoffSourceSize[] readonly limitBytes: number constructor(items: HandoffSourceSize[], limitBytes: number) { super(formatHandoffTooLarge(items, limitBytes)) this.name = 'HandoffSourcesTooLargeError' this.items = items this.limitBytes = limitBytes } } export function formatHandoffTooLarge(items: HandoffSourceSize[], limitBytes: number): string { const parts = items.filter(item => item.bytes > 0).map(item => `${item.role} ${formatByteSize(item.bytes)}`) const total = items.reduce((sum, item) => sum + item.bytes, 0) return `会话太大,没有开始移交:${parts.join('、')},合计 ${formatByteSize(total)}。压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}` } async function treeBytes(abs: string): Promise { const st = await fsp.lstat(abs) if (st.isSymbolicLink()) return 0 if (!st.isDirectory()) return st.size let total = 0 for (const name of await fsp.readdir(abs)) { if (name === HANDOFF_SKIPPED_DIR) continue total += await treeBytes(path.join(abs, name)) } return total } /** 统计即将打进会话包的文件。缺失的会话不出现。 */ export async function measureHandoffSources(src: HandoffSource, roots: HandoffRoots): Promise { const items: HandoffSourceSize[] = [] for (const { field, kind } of CLAUDE_SESSION_FIELDS) { const id = src.sessions[field] if (!id) continue const files = await findClaudeSessionFiles(id, roots.projectsRoot) if (!files) continue let bytes = await treeBytes(files.jsonl) if (files.dir) bytes += await treeBytes(files.dir) items.push({ role: ROLE_LABEL[kind], bytes }) } if (src.sessions.reviewSessionId) { const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot) if (relPath) { items.push({ role: ROLE_LABEL.review, bytes: await treeBytes(path.join(roots.codexRoot, relPath)), }) } } return items } /** * 关终端、上传之前调用。 * 原始大小不超过上限时直接过(压缩只会更小,最多多出可忽略的包头)。 * 超过才真正压缩,产出一过上限就停。 */ export async function ensureHandoffFits( src: HandoffSource, roots: HandoffRoots, opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void }, ): Promise { const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES const items = await measureHandoffSources(src, roots) const total = items.reduce((sum, item) => sum + item.bytes, 0) if (total <= maxBytes) return items const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-')) const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-out-')) try { await stageHandoffBundle(src, staging, roots) await packHandoff(staging, path.join(outDir, 'probe.tgz'), { maxBytes, onProgress: opts?.onProgress }) return items } catch (err) { if (err instanceof HandoffArchiveTooLargeError) throw new HandoffSourcesTooLargeError(items, maxBytes) throw err } finally { await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}) await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) } }