✨ feat(vscode): handoff 会话包 stage/pack/unpack/install
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
function tar(args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function packHandoff(stagingDir: string, outFile: string): Promise<void> {
|
||||||
|
await fsp.mkdir(path.dirname(outFile), { recursive: true })
|
||||||
|
await tar(['-czf', outFile, '-C', stagingDir, '.'])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
|
||||||
|
await fsp.mkdir(dstDir, { recursive: true })
|
||||||
|
await tar(['-xzf', archive, '-C', dstDir])
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { packHandoff, unpackHandoff } from './handoffArchive'
|
||||||
|
import { installHandoffBundle, stageHandoffBundle } from './handoffBundle'
|
||||||
|
import { HANDOFF_MANIFEST_FILE, parseHandoffManifest } from './handoffManifest'
|
||||||
|
|
||||||
|
const tempDirs: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const d = tempDirs.pop()
|
||||||
|
if (d)
|
||||||
|
rmSync(d, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
function tempDir(prefix: string): string {
|
||||||
|
const d = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||||
|
tempDirs.push(d)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
|
||||||
|
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
|
||||||
|
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||||||
|
|
||||||
|
function senderRoots(): { projectsRoot: string, codexRoot: string } {
|
||||||
|
const projectsRoot = tempDir('sender-projects-')
|
||||||
|
const codexRoot = tempDir('sender-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`), 'impl\n')
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stageHandoffBundle', () => {
|
||||||
|
it('收集存在的会话,缺失的不进列表', async () => {
|
||||||
|
const roots = senderRoots()
|
||||||
|
const staging = tempDir('staging-')
|
||||||
|
const manifest = await stageHandoffBundle({
|
||||||
|
issueNumber: 7,
|
||||||
|
from: 'chw',
|
||||||
|
branch: 'feature/x',
|
||||||
|
sessions: { sessionId: BRAIN, implementSessionId: IMPL, testSessionId: 'missing-0000-4000-8000-000000000000', reviewSessionId: REVIEW },
|
||||||
|
profiles: { profilePath: '/Users/chw/p.json' },
|
||||||
|
}, staging, roots, new Date('2026-08-26T00:00:00Z'))
|
||||||
|
expect(manifest.claude).toEqual([{ id: BRAIN, kind: 'brainstorm' }, { id: IMPL, kind: 'implement' }])
|
||||||
|
expect(manifest.codex).toEqual([{ id: REVIEW, relPath: path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`) }])
|
||||||
|
expect(manifest.sessions.testSessionId).toBe('missing-0000-4000-8000-000000000000')
|
||||||
|
expect(manifest.createdAt).toBe('2026-08-26T00:00:00.000Z')
|
||||||
|
expect(existsSync(path.join(staging, 'claude', `${IMPL}.jsonl`))).toBe(true)
|
||||||
|
expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true)
|
||||||
|
expect(existsSync(path.join(staging, 'codex', manifest.codex[0].relPath))).toBe(true)
|
||||||
|
expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pack → unpack → install', () => {
|
||||||
|
it('接管方装到对应 project 目录与 codex 目录', async () => {
|
||||||
|
const roots = senderRoots()
|
||||||
|
const staging = tempDir('staging-')
|
||||||
|
const manifest = await stageHandoffBundle({
|
||||||
|
issueNumber: 7,
|
||||||
|
from: 'chw',
|
||||||
|
branch: 'feature/x',
|
||||||
|
sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW },
|
||||||
|
profiles: {},
|
||||||
|
}, staging, roots)
|
||||||
|
const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz')
|
||||||
|
await packHandoff(staging, archive)
|
||||||
|
expect(existsSync(archive)).toBe(true)
|
||||||
|
|
||||||
|
const extracted = tempDir('extracted-')
|
||||||
|
await unpackHandoff(archive, extracted)
|
||||||
|
const parsed = parseHandoffManifest(readFileSync(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), 7)
|
||||||
|
expect(parsed).toEqual(manifest)
|
||||||
|
|
||||||
|
const projectsRoot = tempDir('receiver-projects-')
|
||||||
|
const codexRoot = tempDir('receiver-codex-')
|
||||||
|
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
|
||||||
|
const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x')
|
||||||
|
// 本机残留的同 sid 副本必须被清掉
|
||||||
|
mkdirSync(path.join(projectsRoot, '-stale'), { recursive: true })
|
||||||
|
writeFileSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`), 'stale\n')
|
||||||
|
await installHandoffBundle(extracted, parsed, { projectsRoot, codexRoot, workspaceProjectsDir, worktreeProjectsDir })
|
||||||
|
expect(readFileSync(path.join(workspaceProjectsDir, `${BRAIN}.jsonl`), 'utf8')).toBe('brain\n')
|
||||||
|
expect(readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')).toBe('impl\n')
|
||||||
|
expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true)
|
||||||
|
expect(existsSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false)
|
||||||
|
expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无 worktreeProjectsDir 时实施会话也装到工作区目录', async () => {
|
||||||
|
const roots = senderRoots()
|
||||||
|
const staging = tempDir('staging-')
|
||||||
|
const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', sessions: { implementSessionId: IMPL }, profiles: {} }, staging, roots)
|
||||||
|
const projectsRoot = tempDir('receiver-projects-')
|
||||||
|
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
|
||||||
|
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
|
||||||
|
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from './handoffManifest'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest'
|
||||||
|
import {
|
||||||
|
codexRolloutRelPathFor,
|
||||||
|
copyClaudeSessionFiles,
|
||||||
|
findClaudeSessionFiles,
|
||||||
|
installClaudeSession,
|
||||||
|
installCodexSession,
|
||||||
|
} from './sessionBundle'
|
||||||
|
|
||||||
|
export interface HandoffSource {
|
||||||
|
issueNumber: number
|
||||||
|
from: string
|
||||||
|
branch?: string
|
||||||
|
sessions: HandoffSessions
|
||||||
|
profiles: HandoffProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandoffRoots {
|
||||||
|
projectsRoot: string
|
||||||
|
codexRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
|
||||||
|
export async function stageHandoffBundle(
|
||||||
|
src: HandoffSource,
|
||||||
|
stagingDir: string,
|
||||||
|
roots: HandoffRoots,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Promise<HandoffManifest> {
|
||||||
|
const claudeDir = path.join(stagingDir, 'claude')
|
||||||
|
const codexDir = path.join(stagingDir, 'codex')
|
||||||
|
await fsp.mkdir(claudeDir, { recursive: true })
|
||||||
|
await fsp.mkdir(codexDir, { recursive: true })
|
||||||
|
|
||||||
|
const claude: HandoffManifest['claude'] = []
|
||||||
|
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
|
||||||
|
await copyClaudeSessionFiles(files, claudeDir)
|
||||||
|
claude.push({ id, kind })
|
||||||
|
}
|
||||||
|
|
||||||
|
const codex: HandoffManifest['codex'] = []
|
||||||
|
if (src.sessions.reviewSessionId) {
|
||||||
|
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)
|
||||||
|
codex.push({ id: src.sessions.reviewSessionId, relPath })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: HandoffManifest = {
|
||||||
|
version: 1,
|
||||||
|
issue: src.issueNumber,
|
||||||
|
from: src.from,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
...(src.branch ? { branch: src.branch } : {}),
|
||||||
|
sessions: src.sessions,
|
||||||
|
profiles: src.profiles,
|
||||||
|
claude,
|
||||||
|
codex,
|
||||||
|
}
|
||||||
|
await fsp.writeFile(path.join(stagingDir, HANDOFF_MANIFEST_FILE), JSON.stringify(manifest, null, 2))
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandoffInstallTargets extends HandoffRoots {
|
||||||
|
workspaceProjectsDir: string
|
||||||
|
worktreeProjectsDir?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installHandoffBundle(
|
||||||
|
extractedDir: string,
|
||||||
|
manifest: HandoffManifest,
|
||||||
|
targets: HandoffInstallTargets,
|
||||||
|
): Promise<void> {
|
||||||
|
const claudeDir = path.join(extractedDir, 'claude')
|
||||||
|
for (const { id, kind } of manifest.claude) {
|
||||||
|
const dstProjectsDir = kind === 'brainstorm'
|
||||||
|
? targets.workspaceProjectsDir
|
||||||
|
: (targets.worktreeProjectsDir ?? targets.workspaceProjectsDir)
|
||||||
|
await installClaudeSession({ sid: id, srcDir: claudeDir, dstProjectsDir, projectsRoot: targets.projectsRoot })
|
||||||
|
}
|
||||||
|
for (const { relPath } of manifest.codex)
|
||||||
|
await installCodexSession(relPath, path.join(extractedDir, 'codex', relPath), targets.codexRoot)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user