115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
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<number> {
|
|
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<HandoffSourceSize[]> {
|
|
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<HandoffSourceSize[]> {
|
|
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(() => {})
|
|
}
|
|
}
|