From 332205a2feeba475ec2b5d60d3509aeb59aef5d4 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 15:54:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20handoff=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8C=85=20stage/pack/unpack/install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/cc/handoffArchive.ts | 25 +++++++ vscode/src/cc/handoffBundle.test.ts | 109 ++++++++++++++++++++++++++++ vscode/src/cc/handoffBundle.ts | 95 ++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 vscode/src/cc/handoffArchive.ts create mode 100644 vscode/src/cc/handoffBundle.test.ts create mode 100644 vscode/src/cc/handoffBundle.ts diff --git a/vscode/src/cc/handoffArchive.ts b/vscode/src/cc/handoffArchive.ts new file mode 100644 index 0000000..c1cb29b --- /dev/null +++ b/vscode/src/cc/handoffArchive.ts @@ -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 { + 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 { + await fsp.mkdir(path.dirname(outFile), { recursive: true }) + await tar(['-czf', outFile, '-C', stagingDir, '.']) +} + +export async function unpackHandoff(archive: string, dstDir: string): Promise { + await fsp.mkdir(dstDir, { recursive: true }) + await tar(['-xzf', archive, '-C', dstDir]) +} diff --git a/vscode/src/cc/handoffBundle.test.ts b/vscode/src/cc/handoffBundle.test.ts new file mode 100644 index 0000000..d90b8c9 --- /dev/null +++ b/vscode/src/cc/handoffBundle.test.ts @@ -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) + }) +}) diff --git a/vscode/src/cc/handoffBundle.ts b/vscode/src/cc/handoffBundle.ts new file mode 100644 index 0000000..60a7c05 --- /dev/null +++ b/vscode/src/cc/handoffBundle.ts @@ -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 { + 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 { + 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) +}