✨ feat(vscode): sessionBundle 会话文件查找/安装/去重原语
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user