🐛 fix(vscode): 别的机器上开的会话在本机没有会话文件时,会话 id 行置灰不可双击,不再 resume 报错
This commit is contained in:
1 parent
2b1e660cc8
commit
bf4661e364
20 files changed
+508
-234
No files matched your search
@@ -1,85 +0,0 @@
|
||||
import type { Issue } from '../gitea/types'
|
||||
import { mkdirSync, mkdtempSync, 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 { annotateReviewSessionFileExists, listCodexSessionIds } from './codexSessions'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||||
const UUID_B = '11111111-2222-4333-8444-555555555555'
|
||||
|
||||
function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void {
|
||||
const dayDir = path.join(baseDir, ...ymd)
|
||||
mkdirSync(dayDir, { recursive: true })
|
||||
writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n')
|
||||
}
|
||||
|
||||
describe('listCodexSessionIds', () => {
|
||||
it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
writeRollout(base, ['2026', '05', '01'], UUID_B)
|
||||
const ids = await listCodexSessionIds(base)
|
||||
expect(ids).toEqual(new Set([UUID_A, UUID_B]))
|
||||
})
|
||||
|
||||
it('忽略不符合 rollout-*-<uuid>.jsonl 的文件', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
const dayDir = path.join(base, '2026', '04', '27')
|
||||
mkdirSync(dayDir, { recursive: true })
|
||||
writeFileSync(path.join(dayDir, 'notes.txt'), 'x')
|
||||
writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
const ids = await listCodexSessionIds(base)
|
||||
expect(ids).toEqual(new Set([UUID_A]))
|
||||
})
|
||||
|
||||
it('目录不存在返回空 Set', async () => {
|
||||
const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope'))
|
||||
expect(ids.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotateReviewSessionFileExists', () => {
|
||||
const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue
|
||||
|
||||
it('有会话文件的 issue 标 true,没有的标 false,无 reviewSessionId 的不加字段', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
const issues: Issue[] = [
|
||||
{ ...baseIssue, number: 1, reviewSessionId: UUID_A },
|
||||
{ ...baseIssue, number: 2, reviewSessionId: UUID_B },
|
||||
{ ...baseIssue, number: 3 },
|
||||
]
|
||||
const out = await annotateReviewSessionFileExists(issues, base)
|
||||
expect(out[0].reviewSessionFileExists).toBe(true)
|
||||
expect(out[1].reviewSessionFileExists).toBe(false)
|
||||
expect('reviewSessionFileExists' in out[2]).toBe(false)
|
||||
})
|
||||
|
||||
it('id 大小写不影响判定', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
const out = await annotateReviewSessionFileExists(
|
||||
[{ ...baseIssue, reviewSessionId: UUID_A.toUpperCase() }],
|
||||
base,
|
||||
)
|
||||
expect(out[0].reviewSessionFileExists).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 枚举本机 codex 会话文件,判定「审查会话id」能否 resume。
|
||||
*
|
||||
* codex 把每个会话的 transcript 全局存在
|
||||
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,resume 只依赖
|
||||
* 这个文件,与 worktree 无关——多人协作下 assignee 换机后,只要把会话文件
|
||||
* 拷到目标机器就能 resume。
|
||||
*/
|
||||
|
||||
import type { Issue } from '../gitea/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'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 给带 reviewSessionId 的工单附加计算字段 `reviewSessionFileExists`
|
||||
* (不落任何持久化)。整批工单只扫一次 sessions 目录;没有任何工单带
|
||||
* id 时连扫都不扫。
|
||||
*/
|
||||
export async function annotateReviewSessionFileExists(issues: Issue[], baseDir?: string): Promise<Issue[]> {
|
||||
if (!issues.some(i => typeof i.reviewSessionId === 'string' && i.reviewSessionId.length > 0))
|
||||
return issues
|
||||
const ids = await listCodexSessionIds(baseDir)
|
||||
return issues.map(i => (i.reviewSessionId
|
||||
? { ...i, reviewSessionFileExists: ids.has(i.reviewSessionId.toLowerCase()) }
|
||||
: i))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Issue } from '../gitea/types'
|
||||
import { promises as fsp, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
annotateMissingSessionIds,
|
||||
claudeSessionFileExists,
|
||||
codexSessionFileExists,
|
||||
listClaudeSessionIds,
|
||||
listCodexSessionIds,
|
||||
} from './sessionFiles'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||||
const UUID_B = '11111111-2222-4333-8444-555555555555'
|
||||
const SID_1 = 'aaaaaaaa-1111-4111-8111-111111111111'
|
||||
const SID_2 = 'bbbbbbbb-2222-4222-8222-222222222222'
|
||||
const SID_3 = 'cccccccc-3333-4333-8333-333333333333'
|
||||
|
||||
function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void {
|
||||
const dayDir = path.join(baseDir, ...ymd)
|
||||
mkdirSync(dayDir, { recursive: true })
|
||||
writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n')
|
||||
}
|
||||
|
||||
function writeTranscript(projectsRoot: string, project: string, sid: string): void {
|
||||
const dir = path.join(projectsRoot, project)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path.join(dir, `${sid}.jsonl`), '{}\n')
|
||||
}
|
||||
|
||||
describe('listCodexSessionIds', () => {
|
||||
it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
writeRollout(base, ['2026', '05', '01'], UUID_B)
|
||||
const ids = await listCodexSessionIds(base)
|
||||
expect(ids).toEqual(new Set([UUID_A, UUID_B]))
|
||||
})
|
||||
|
||||
it('忽略不符合 rollout-*-<uuid>.jsonl 的文件', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
const dayDir = path.join(base, '2026', '04', '27')
|
||||
mkdirSync(dayDir, { recursive: true })
|
||||
writeFileSync(path.join(dayDir, 'notes.txt'), 'x')
|
||||
writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
const ids = await listCodexSessionIds(base)
|
||||
expect(ids).toEqual(new Set([UUID_A]))
|
||||
})
|
||||
|
||||
it('目录不存在返回空 Set', async () => {
|
||||
const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope'))
|
||||
expect(ids.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listClaudeSessionIds', () => {
|
||||
it('收集各 project 目录下的 <sid>.jsonl,统一小写', async () => {
|
||||
const root = tempDir('claude-projects-')
|
||||
writeTranscript(root, '-Users-a-proj', SID_1.toUpperCase())
|
||||
writeTranscript(root, '-Users-b-wt', SID_2)
|
||||
const ids = await listClaudeSessionIds(root)
|
||||
expect(ids).toEqual(new Set([SID_1, SID_2]))
|
||||
})
|
||||
|
||||
it('不下钻 <sid>/ 子目录,忽略非 jsonl 文件和根目录下的普通文件', async () => {
|
||||
const root = tempDir('claude-projects-')
|
||||
writeTranscript(root, '-Users-a-proj', SID_1)
|
||||
const subagents = path.join(root, '-Users-a-proj', SID_1, 'subagents')
|
||||
mkdirSync(subagents, { recursive: true })
|
||||
writeFileSync(path.join(subagents, `${SID_3}.jsonl`), '{}\n')
|
||||
writeFileSync(path.join(root, '-Users-a-proj', 'notes.txt'), 'x')
|
||||
writeFileSync(path.join(root, '.DS_Store'), 'x')
|
||||
const ids = await listClaudeSessionIds(root)
|
||||
expect(ids).toEqual(new Set([SID_1]))
|
||||
})
|
||||
|
||||
it('根目录不存在返回空 Set', async () => {
|
||||
const ids = await listClaudeSessionIds(path.join(tempDir('claude-projects-'), 'nope'))
|
||||
expect(ids.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('单个会话文件判定', () => {
|
||||
it('claudeSessionFileExists:任一 project 目录下有 transcript 即为 true', async () => {
|
||||
const root = tempDir('claude-projects-')
|
||||
writeTranscript(root, '-Users-b-wt', SID_1)
|
||||
expect(await claudeSessionFileExists(SID_1, root)).toBe(true)
|
||||
expect(await claudeSessionFileExists(SID_2, root)).toBe(false)
|
||||
})
|
||||
|
||||
it('codexSessionFileExists:按 rollout 文件名尾部 uuid 判定', async () => {
|
||||
const base = tempDir('codex-sessions-')
|
||||
writeRollout(base, ['2026', '04', '27'], UUID_A)
|
||||
expect(await codexSessionFileExists(UUID_A.toUpperCase(), base)).toBe(true)
|
||||
expect(await codexSessionFileExists(UUID_B, base)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotateMissingSessionIds', () => {
|
||||
const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue
|
||||
|
||||
it('claude + codex 混合:只列出本机没有会话文件的 id,全在本机的不加字段', async () => {
|
||||
const claudeRoot = tempDir('claude-projects-')
|
||||
const codexRoot = tempDir('codex-sessions-')
|
||||
writeTranscript(claudeRoot, '-Users-a-proj', SID_1)
|
||||
writeTranscript(claudeRoot, '-Users-b-wt', SID_2)
|
||||
writeRollout(codexRoot, ['2026', '04', '27'], UUID_A)
|
||||
const issues: Issue[] = [
|
||||
{ ...baseIssue, number: 1, sessionId: SID_1.toUpperCase(), implementSessionId: SID_3, reviewSessionId: UUID_A },
|
||||
{ ...baseIssue, number: 2, testSessionId: SID_3, prototypeSessionId: SID_2, reviewSessionId: UUID_B },
|
||||
{ ...baseIssue, number: 3, sessionId: SID_2, reviewSessionId: UUID_A.toUpperCase() },
|
||||
{ ...baseIssue, number: 4 },
|
||||
]
|
||||
const out = await annotateMissingSessionIds(issues, { claudeRoot, codexRoot })
|
||||
expect(out[0].missingSessionIds).toEqual([SID_3])
|
||||
expect(out[1].missingSessionIds).toEqual([SID_3, UUID_B])
|
||||
expect('missingSessionIds' in out[2]).toBe(false)
|
||||
expect('missingSessionIds' in out[3]).toBe(false)
|
||||
})
|
||||
|
||||
it('没有任何工单带会话 id 时一个目录都不扫', async () => {
|
||||
const readdir = vi.spyOn(fsp, 'readdir')
|
||||
const out = await annotateMissingSessionIds([{ ...baseIssue }], {
|
||||
claudeRoot: tempDir('claude-projects-'),
|
||||
codexRoot: tempDir('codex-sessions-'),
|
||||
})
|
||||
expect(readdir).not.toHaveBeenCalled()
|
||||
expect('missingSessionIds' in out[0]).toBe(false)
|
||||
})
|
||||
|
||||
it('只带 claude 会话 id 时不扫 codex 目录', async () => {
|
||||
const claudeRoot = tempDir('claude-projects-')
|
||||
const codexRoot = tempDir('codex-sessions-')
|
||||
const readdir = vi.spyOn(fsp, 'readdir')
|
||||
const out = await annotateMissingSessionIds([{ ...baseIssue, sessionId: SID_1 }], { claudeRoot, codexRoot })
|
||||
expect(readdir.mock.calls.some(([p]) => p === codexRoot)).toBe(false)
|
||||
expect(readdir.mock.calls.some(([p]) => p === claudeRoot)).toBe(true)
|
||||
expect(out[0].missingSessionIds).toEqual([SID_1])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
|
||||
*
|
||||
* 会话 id 存在 Gitea 共享状态里,别的机器上开的会话本机没有 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 '../gitea/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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user