feat(vscode): sessionBundle 会话文件查找/安装/去重原语

Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
2026-08-26 15:44:00 +08:00
parent ad82808dd0
commit 569f24c11a
2 changed files with 247 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
codexRolloutRelPathFor,
findClaudeSessionFiles,
installClaudeSession,
installCodexSession,
listClaudeSessionCopies,
} from './sessionBundle'
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 SID = '11111111-2222-4333-8444-555555555555'
function writeSession(projectsRoot: string, encoded: string, sid: string, body: string, withDir = false): string {
const dir = path.join(projectsRoot, encoded)
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, `${sid}.jsonl`), body)
if (withDir) {
mkdirSync(path.join(dir, sid, 'subagents'), { recursive: true })
writeFileSync(path.join(dir, sid, 'subagents', 'agent-a.jsonl'), '{}\n')
}
return dir
}
describe('listClaudeSessionCopies / findClaudeSessionFiles', () => {
it('列出所有 project 目录下的同 sid 副本,含子目录', async () => {
const root = tempDir('cc-projects-')
const a = writeSession(root, '-home-a-proj', SID, 'a\n', true)
const b = writeSession(root, '-home-a-wt', SID, 'b\n')
const copies = await listClaudeSessionCopies(SID, root)
expect(copies.map(c => c.projectsDir).sort()).toEqual([a, b].sort())
expect(copies.find(c => c.projectsDir === a)?.dir).toBe(path.join(a, SID))
expect(copies.find(c => c.projectsDir === b)?.dir).toBeUndefined()
})
it('多份副本取 jsonl mtime 最新的', async () => {
const root = tempDir('cc-projects-')
const old = writeSession(root, '-old', SID, 'old\n')
const fresh = writeSession(root, '-fresh', SID, 'fresh\n')
utimesSync(path.join(old, `${SID}.jsonl`), new Date(0), new Date(0))
const found = await findClaudeSessionFiles(SID, root)
expect(found?.projectsDir).toBe(fresh)
})
it('projectsRoot 不存在 → 空 / undefined', async () => {
expect(await listClaudeSessionCopies(SID, '/nonexistent/x')).toEqual([])
expect(await findClaudeSessionFiles(SID, '/nonexistent/x')).toBeUndefined()
})
})
describe('installClaudeSession', () => {
it('从外部目录装入目标 project 目录,并清掉本机其它副本', async () => {
const root = tempDir('cc-projects-')
const stale = writeSession(root, '-stale', SID, 'stale\n', true)
const src = tempDir('cc-src-')
mkdirSync(path.join(src, SID, 'tool-results'), { recursive: true })
writeFileSync(path.join(src, `${SID}.jsonl`), 'new\n')
writeFileSync(path.join(src, SID, 'tool-results', 'r.txt'), 'r')
const dst = path.join(root, '-home-me-wt')
await installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: dst, projectsRoot: root })
expect(readFileSync(path.join(dst, `${SID}.jsonl`), 'utf8')).toBe('new\n')
expect(existsSync(path.join(dst, SID, 'tool-results', 'r.txt'))).toBe(true)
expect(existsSync(path.join(stale, `${SID}.jsonl`))).toBe(false)
expect(existsSync(path.join(stale, SID))).toBe(false)
})
it('源目录就在 projectsRoot 下(搬迁):目标保留,源删除', async () => {
const root = tempDir('cc-projects-')
const from = writeSession(root, '-wt', SID, 'x\n', true)
const to = path.join(root, '-proj')
await installClaudeSession({ sid: SID, srcDir: from, dstProjectsDir: to, projectsRoot: root })
expect(readFileSync(path.join(to, `${SID}.jsonl`), 'utf8')).toBe('x\n')
expect(existsSync(path.join(to, SID, 'subagents', 'agent-a.jsonl'))).toBe(true)
expect(existsSync(path.join(from, `${SID}.jsonl`))).toBe(false)
})
it('源就是目标 → 幂等不报错', async () => {
const root = tempDir('cc-projects-')
const dir = writeSession(root, '-proj', SID, 'x\n')
await installClaudeSession({ sid: SID, srcDir: dir, dstProjectsDir: dir, projectsRoot: root })
expect(readFileSync(path.join(dir, `${SID}.jsonl`), 'utf8')).toBe('x\n')
})
it('源缺 jsonl → 抛错', async () => {
const root = tempDir('cc-projects-')
const src = tempDir('cc-src-')
await expect(installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: path.join(root, '-x'), projectsRoot: root })).rejects.toThrow(SID)
})
})
describe('codex', () => {
const UUID = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
it('按文件名尾部 uuid 定位 rollout,返回相对路径', async () => {
const codexRoot = tempDir('codex-')
const day = path.join(codexRoot, '2026', '08', '26')
mkdirSync(day, { recursive: true })
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${UUID}.jsonl`), '{}\n')
expect(await codexRolloutRelPathFor(UUID.toUpperCase(), codexRoot)).toBe(path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`))
expect(await codexRolloutRelPathFor('00000000-0000-4000-8000-000000000000', codexRoot)).toBeUndefined()
})
it('installCodexSession 按 relPath 落到 codexRoot 下并覆盖', async () => {
const codexRoot = tempDir('codex-')
const src = tempDir('codex-src-')
const rel = path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`)
writeFileSync(path.join(src, 'r.jsonl'), 'new\n')
await installCodexSession(rel, path.join(src, 'r.jsonl'), codexRoot)
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* claude / codex 会话文件的搬运原语。
*
* claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选 `<projectsDir>/<sid>/`
* subagents、tool-results),两者必须一起搬。新版 claude 在任意目录
* `--resume` 时会搜全部 project 目录,同一 sid 出现两份就拒绝 resume——
* 所以「安装」总是先拷再删本机其它副本。
*/
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ROLLOUT_UUID_REGEX } from './codexSessionWatcher'
export interface ClaudeSessionFiles {
projectsDir: string
jsonl: string
dir?: string
}
export function claudeProjectsRoot(): string {
return path.join(os.homedir(), '.claude', 'projects')
}
async function exists(p: string): Promise<boolean> {
try {
await fsp.stat(p)
return true
}
catch {
return false
}
}
async function sessionFilesIn(projectsDir: string, sid: string): Promise<ClaudeSessionFiles | undefined> {
const jsonl = path.join(projectsDir, `${sid}.jsonl`)
if (!(await exists(jsonl)))
return undefined
const dir = path.join(projectsDir, sid)
return (await exists(dir)) ? { projectsDir, jsonl, dir } : { projectsDir, jsonl }
}
export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]> {
let entries: string[]
try {
entries = await fsp.readdir(projectsRoot)
}
catch {
return []
}
const out: ClaudeSessionFiles[] = []
for (const name of entries) {
const found = await sessionFilesIn(path.join(projectsRoot, name), sid)
if (found)
out.push(found)
}
return out
}
export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined> {
const copies = await listClaudeSessionCopies(sid, projectsRoot)
if (copies.length === 0)
return undefined
const stamped = await Promise.all(copies.map(async c => ({ c, mtime: (await fsp.stat(c.jsonl)).mtimeMs })))
stamped.sort((a, b) => b.mtime - a.mtime)
return stamped[0].c
}
export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await fsp.copyFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl)))
if (files.dir)
await fsp.cp(files.dir, path.join(dstDir, path.basename(files.dir)), { recursive: true, force: true })
}
async function removeClaudeSessionFiles(files: ClaudeSessionFiles): Promise<void> {
await fsp.rm(files.jsonl, { force: true })
if (files.dir)
await fsp.rm(files.dir, { recursive: true, force: true })
}
export async function installClaudeSession(opts: {
sid: string
srcDir: string
dstProjectsDir: string
projectsRoot: string
}): Promise<void> {
const src = await sessionFilesIn(path.resolve(opts.srcDir), opts.sid)
if (!src)
throw new Error(`源目录缺少会话文件 ${opts.sid}.jsonl${opts.srcDir}`)
const dst = path.resolve(opts.dstProjectsDir)
if (src.projectsDir !== dst)
await copyClaudeSessionFiles(src, dst)
for (const copy of await listClaudeSessionCopies(opts.sid, opts.projectsRoot)) {
if (path.resolve(copy.projectsDir) !== dst)
await removeClaudeSessionFiles(copy)
}
}
/** codex rollout 全局存放,按文件名尾部 uuid 定位;返回相对 codexRoot 的路径。 */
export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined> {
const want = sid.toLowerCase()
let entries: string[]
try {
entries = await fsp.readdir(codexRoot, { recursive: true })
}
catch {
return undefined
}
for (const entry of entries) {
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
if (m && m[1].toLowerCase() === want)
return entry
}
return undefined
}
export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void> {
const dst = path.join(codexRoot, relPath)
await fsp.mkdir(path.dirname(dst), { recursive: true })
await fsp.copyFile(srcFile, dst)
}