✨ feat(vscode): 移交时显示步骤,会话包超限先停
This commit is contained in:
1 parent
9220bad599
commit
162134dcc0
6 files changed
+412
-32
No files matched your search
@@ -1,8 +1,37 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { promises as fsp, statSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
function tar(args: string[]): Promise<void> {
|
||||
/** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */
|
||||
export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
export function formatByteSize(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
if (mb >= 10)
|
||||
return `${Math.round(mb)}MB`
|
||||
if (mb >= 1)
|
||||
return `${mb.toFixed(1)}MB`
|
||||
const kb = bytes / 1024
|
||||
if (kb >= 10)
|
||||
return `${Math.round(kb)}KB`
|
||||
if (kb >= 1)
|
||||
return `${kb.toFixed(1)}KB`
|
||||
return `${bytes}B`
|
||||
}
|
||||
|
||||
export class HandoffArchiveTooLargeError extends Error {
|
||||
readonly packedBytes: number
|
||||
readonly limitBytes: number
|
||||
|
||||
constructor(packedBytes: number, limitBytes: number) {
|
||||
super(`压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`)
|
||||
this.name = 'HandoffArchiveTooLargeError'
|
||||
this.packedBytes = packedBytes
|
||||
this.limitBytes = limitBytes
|
||||
}
|
||||
}
|
||||
|
||||
function tarUnpack(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
||||
if (err) {
|
||||
@@ -14,12 +43,74 @@ function tar(args: string[]): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function packHandoff(stagingDir: string, outFile: string): Promise<void> {
|
||||
export async function packHandoff(
|
||||
stagingDir: string,
|
||||
outFile: string,
|
||||
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
|
||||
): Promise<void> {
|
||||
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
|
||||
await fsp.mkdir(path.dirname(outFile), { recursive: true })
|
||||
await tar(['-czf', outFile, '-C', stagingDir, '.'])
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('tar', ['-czf', outFile, '-C', stagingDir, '.'], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
const decode = new TextDecoder()
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
let poll: ReturnType<typeof setInterval> | undefined
|
||||
child.stderr?.on('data', (chunk: Uint8Array) => {
|
||||
stderr += decode.decode(chunk, { stream: true })
|
||||
})
|
||||
const finish = (err?: Error): void => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (poll)
|
||||
clearInterval(poll)
|
||||
if (err)
|
||||
reject(err)
|
||||
else
|
||||
resolve()
|
||||
}
|
||||
const checkSize = (): void => {
|
||||
let size = 0
|
||||
try {
|
||||
size = statSync(outFile).size
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
opts?.onProgress?.(size)
|
||||
// 产出一超过上限就杀掉。等 tar 自己打完再失败,大会话会卡很久。
|
||||
if (size > maxBytes) {
|
||||
child.kill('SIGKILL')
|
||||
finish(new HandoffArchiveTooLargeError(size, maxBytes))
|
||||
}
|
||||
}
|
||||
poll = setInterval(checkSize, 200)
|
||||
child.on('error', err => finish(err))
|
||||
child.on('close', (code) => {
|
||||
if (settled)
|
||||
return
|
||||
let size = 0
|
||||
try {
|
||||
size = statSync(outFile).size
|
||||
}
|
||||
catch {
|
||||
// 包还没写出来
|
||||
}
|
||||
opts?.onProgress?.(size)
|
||||
if (size > maxBytes) {
|
||||
finish(new HandoffArchiveTooLargeError(size, maxBytes))
|
||||
return
|
||||
}
|
||||
if (code === 0)
|
||||
finish()
|
||||
else
|
||||
finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
|
||||
await fsp.mkdir(dstDir, { recursive: true })
|
||||
await tar(['-xzf', archive, '-C', dstDir])
|
||||
await tarUnpack(['-xzf', archive, '-C', dstDir])
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import * as path from 'node:path'
|
||||
import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest'
|
||||
import {
|
||||
codexRolloutRelPathFor,
|
||||
copyClaudeSessionFiles,
|
||||
findClaudeSessionFiles,
|
||||
installClaudeSession,
|
||||
installCodexSession,
|
||||
@@ -27,6 +26,31 @@ export interface HandoffRoots {
|
||||
}
|
||||
|
||||
/** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
|
||||
async function placeFile(src: string, dst: string): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||||
try {
|
||||
await fsp.link(src, dst)
|
||||
}
|
||||
catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
// 跨卷硬链接会失败,这时才真正复制。
|
||||
if (code !== 'EXDEV' && code !== 'EPERM' && code !== 'ENOTSUP')
|
||||
throw err
|
||||
await fsp.copyFile(src, dst)
|
||||
}
|
||||
}
|
||||
|
||||
async function placeTree(src: string, dst: string): Promise<void> {
|
||||
const st = await fsp.lstat(src)
|
||||
if (st.isDirectory()) {
|
||||
await fsp.mkdir(dst, { recursive: true })
|
||||
for (const name of await fsp.readdir(src))
|
||||
await placeTree(path.join(src, name), path.join(dst, name))
|
||||
return
|
||||
}
|
||||
await placeFile(src, dst)
|
||||
}
|
||||
|
||||
export async function stageHandoffBundle(
|
||||
src: HandoffSource,
|
||||
stagingDir: string,
|
||||
@@ -46,7 +70,9 @@ export async function stageHandoffBundle(
|
||||
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
|
||||
if (!files)
|
||||
continue
|
||||
await copyClaudeSessionFiles(files, claudeDir)
|
||||
await placeFile(files.jsonl, path.join(claudeDir, path.basename(files.jsonl)))
|
||||
if (files.dir)
|
||||
await placeTree(files.dir, path.join(claudeDir, path.basename(files.dir)))
|
||||
claude.push({ id, kind })
|
||||
}
|
||||
|
||||
@@ -55,8 +81,7 @@ export async function stageHandoffBundle(
|
||||
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
|
||||
if (relPath) {
|
||||
const dst = path.join(codexDir, relPath)
|
||||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||||
await fsp.copyFile(path.join(roots.codexRoot, relPath), dst)
|
||||
await placeFile(path.join(roots.codexRoot, relPath), dst)
|
||||
codex.push({ id: src.sessions.reviewSessionId, relPath })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive'
|
||||
import { ensureHandoffFits, HandoffSourcesTooLargeError, measureHandoffSources } from './handoffSize'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
|
||||
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
|
||||
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||||
|
||||
function fixture(implBytes: Buffer): { projectsRoot: string, codexRoot: string } {
|
||||
const projectsRoot = tempDir('handoff-size-projects-')
|
||||
const codexRoot = tempDir('handoff-size-codex-')
|
||||
const proj = path.join(projectsRoot, '-Users-chw-proj')
|
||||
const wt = path.join(projectsRoot, '-Users-chw-wt-x')
|
||||
mkdirSync(proj, { recursive: true })
|
||||
mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true })
|
||||
writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n')
|
||||
writeFileSync(path.join(wt, `${IMPL}.jsonl`), implBytes)
|
||||
writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n')
|
||||
const day = path.join(codexRoot, '2026', '08', '26')
|
||||
mkdirSync(day, { recursive: true })
|
||||
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n')
|
||||
return { projectsRoot, codexRoot }
|
||||
}
|
||||
|
||||
function source(roots: { projectsRoot: string, codexRoot: string }) {
|
||||
return {
|
||||
src: {
|
||||
issueNumber: 448,
|
||||
from: 'chw',
|
||||
sessions: {
|
||||
sessionId: BRAIN,
|
||||
implementSessionId: IMPL,
|
||||
testSessionId: 'missing-0000-4000-8000-000000000000',
|
||||
reviewSessionId: REVIEW,
|
||||
},
|
||||
profiles: {},
|
||||
},
|
||||
roots,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatByteSize', () => {
|
||||
it('100MB 上限按整数 MB 显示', () => {
|
||||
expect(formatByteSize(HANDOFF_ATTACHMENT_MAX_BYTES)).toBe('100MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('measureHandoffSources', () => {
|
||||
it('jsonl、会话目录、codex 加在一起,缺失的会话不出现', async () => {
|
||||
const roots = fixture(Buffer.from('impl\n'))
|
||||
const { src } = source(roots)
|
||||
const items = await measureHandoffSources(src, roots)
|
||||
expect(items).toEqual([
|
||||
{ role: '头脑风暴', bytes: Buffer.byteLength('brain\n') },
|
||||
{ role: '实施', bytes: Buffer.byteLength('impl\n') + Buffer.byteLength('{}\n') },
|
||||
{ role: '审查', bytes: Buffer.byteLength('review\n') },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureHandoffFits', () => {
|
||||
it('原始大小不超过上限时直接通过', async () => {
|
||||
const roots = fixture(Buffer.from('impl\n'))
|
||||
const { src } = source(roots)
|
||||
const items = await ensureHandoffFits(src, roots, { maxBytes: 1024 * 1024 })
|
||||
expect(items.map(item => item.role)).toEqual(['头脑风暴', '实施', '审查'])
|
||||
})
|
||||
|
||||
it('压缩后超过上限就停,并说明还没开始移交', async () => {
|
||||
const roots = fixture(randomBytes(80 * 1024))
|
||||
const { src } = source(roots)
|
||||
await expect(ensureHandoffFits(src, roots, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffSourcesTooLargeError)
|
||||
try {
|
||||
await ensureHandoffFits(src, roots, { maxBytes: 1024 })
|
||||
}
|
||||
catch (err) {
|
||||
expect(err).toBeInstanceOf(HandoffSourcesTooLargeError)
|
||||
expect((err as Error).message).toContain('没有开始移交')
|
||||
expect((err as Error).message).toContain('实施')
|
||||
expect((err as Error).message).toContain('1.0KB')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('packHandoff', () => {
|
||||
it('产出超过 maxBytes 就失败,不等到打完', async () => {
|
||||
const staging = tempDir('handoff-pack-')
|
||||
writeFileSync(path.join(staging, 'big.bin'), randomBytes(80 * 1024))
|
||||
const out = path.join(tempDir('handoff-pack-out-'), 'bundle.tgz')
|
||||
await expect(packHandoff(staging, out, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffArchiveTooLargeError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { 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))
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user