Files
superwork/vscode/src/sessions/sessionFiles.ts
T

122 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
*
* 会话 id 存在工单的共享状态里,别的机器上开的会话本机没有 transcript,
* resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。
*
* - claude:`~/.claude/projects/<编码后的 cwd>/<sid>.jsonl`。新版 `claude --resume`
* 会搜全部 project 目录,任一 project 目录下有 `<sid>.jsonl` 即可 resume;
* `<sid>/` 子目录放的是 subagent 等附属文件,不用往下扫。
* - codex:`~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,全局存放,
* 与 worktree 无关。
*/
import type { Issue } from '../issues/types'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher'
import { CLAUDE_SESSION_FIELDS } from '../cc/handoffManifest'
import { claudeProjectsRoot, codexRolloutRelPathFor, listClaudeSessionCopies } from '../cc/sessionBundle'
/** codex 会话根目录(全局,不随工作区变化)。 */
export function defaultCodexSessionsDir(): string {
return path.join(os.homedir(), '.codex', 'sessions')
}
/**
* 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id
* 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按
* 「本机没有会话文件」处理即可。
*/
export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise<Set<string>> {
const ids = new Set<string>()
let entries: string[]
try {
entries = await fsp.readdir(baseDir, { recursive: true })
}
catch {
return ids
}
for (const entry of entries) {
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
if (m)
ids.add(m[1].toLowerCase())
}
return ids
}
/**
* 扫 projects 根目录下每个 project 目录(只看这一层),收集 `<sid>.jsonl`
* 的会话 id(统一小写)。根目录不存在返回空 Set。
*/
export async function listClaudeSessionIds(projectsRoot: string = claudeProjectsRoot()): Promise<Set<string>> {
const ids = new Set<string>()
let projects: string[]
try {
projects = await fsp.readdir(projectsRoot)
}
catch {
return ids
}
await Promise.all(projects.map(async (name) => {
let files: string[]
try {
files = await fsp.readdir(path.join(projectsRoot, name))
}
// 根目录下混着的普通文件(.DS_Store 等)readdir 抛 ENOTDIR,跳过
catch {
return
}
for (const file of files) {
if (file.endsWith('.jsonl'))
ids.add(file.slice(0, -'.jsonl'.length).toLowerCase())
}
}))
return ids
}
/** 单个 claude 会话 id 本机是否有 transcript;resume 前的权威判定。 */
export async function claudeSessionFileExists(sid: string, projectsRoot: string = claudeProjectsRoot()): Promise<boolean> {
return (await listClaudeSessionCopies(sid, projectsRoot)).length > 0
}
/** 单个 codex 会话 id 本机是否有 rollout 文件;resume 前的权威判定。 */
export async function codexSessionFileExists(sid: string, codexRoot: string = defaultCodexSessionsDir()): Promise<boolean> {
return (await codexRolloutRelPathFor(sid, codexRoot)) !== undefined
}
type SessionIdFields = Pick<Issue, 'sessionId' | 'implementSessionId' | 'testSessionId' | 'prototypeSessionId' | 'reviewSessionId'>
function claudeIdsOf(item: SessionIdFields): string[] {
return CLAUDE_SESSION_FIELDS
.map(({ field }) => item[field])
.filter((id): id is string => typeof id === 'string' && id.length > 0)
}
function codexIdsOf(item: SessionIdFields): string[] {
return item.reviewSessionId ? [item.reviewSessionId] : []
}
/**
* 给工单附加计算字段 `missingSessionIds`(不落任何持久化):它引用的会话 id
* 里本机没有会话文件的那些;全部能 resume 时不加字段。每种会话目录整批最多
* 扫一次,没有任何工单引用该种 id 时连扫都不扫。
*/
export async function annotateMissingSessionIds<T extends SessionIdFields>(
items: T[],
opts: { claudeRoot?: string, codexRoot?: string } = {},
): Promise<Array<T & { missingSessionIds?: string[] }>> {
const [claudeIds, codexIds] = await Promise.all([
items.some(i => claudeIdsOf(i).length > 0) ? listClaudeSessionIds(opts.claudeRoot) : undefined,
items.some(i => codexIdsOf(i).length > 0) ? listCodexSessionIds(opts.codexRoot) : undefined,
])
return items.map((item) => {
const missing = [
...claudeIdsOf(item).filter(id => !claudeIds?.has(id.toLowerCase())),
...codexIdsOf(item).filter(id => !codexIds?.has(id.toLowerCase())),
]
return missing.length > 0 ? { ...item, missingSessionIds: missing } : item
})
}