122 lines
4.7 KiB
TypeScript
122 lines
4.7 KiB
TypeScript
/**
|
||
* 枚举本机 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
|
||
})
|
||
}
|