🐛 fix(vscode): 接管时把会话文件里的发送方路径重写为本机路径
Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph
This commit is contained in:
@@ -106,4 +106,45 @@ describe('pack → unpack → install', () => {
|
||||
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
|
||||
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
|
||||
})
|
||||
|
||||
it('带 worktreePath + pathRewrite:装机文件里的发送方路径全部改写成接收方路径', async () => {
|
||||
const WORKTREE_FROM = '/Users/chw/wt/x'
|
||||
const WORKTREE_TO = '/home/me/wt/x'
|
||||
const roots = senderRoots()
|
||||
// sender 会话文件里嵌入发送方的 worktree 路径(plain / file:// 两种形式)
|
||||
const wt = path.join(roots.projectsRoot, '-Users-chw-wt-x')
|
||||
writeFileSync(path.join(wt, `${IMPL}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`)
|
||||
writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), `{"cwd":"${WORKTREE_FROM}"}\n`)
|
||||
const day = path.join(roots.codexRoot, '2026', '08', '26')
|
||||
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), `{"cwd":"${WORKTREE_FROM}"}\n{"cwd":"file://${WORKTREE_FROM}"}\n`)
|
||||
|
||||
const staging = tempDir('staging-')
|
||||
const manifest = await stageHandoffBundle({
|
||||
issueNumber: 7,
|
||||
from: 'chw',
|
||||
worktreePath: WORKTREE_FROM,
|
||||
sessions: { implementSessionId: IMPL, reviewSessionId: REVIEW },
|
||||
profiles: {},
|
||||
}, staging, roots)
|
||||
expect(manifest.worktreePath).toBe(WORKTREE_FROM)
|
||||
|
||||
const projectsRoot = tempDir('receiver-projects-')
|
||||
const codexRoot = tempDir('receiver-codex-')
|
||||
const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x')
|
||||
await installHandoffBundle(staging, manifest, {
|
||||
projectsRoot,
|
||||
codexRoot,
|
||||
workspaceProjectsDir: path.join(projectsRoot, '-home-me-proj'),
|
||||
worktreeProjectsDir,
|
||||
pathRewrite: { from: WORKTREE_FROM, to: WORKTREE_TO },
|
||||
})
|
||||
|
||||
const installedJsonl = readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')
|
||||
const installedSubagent = readFileSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'), 'utf8')
|
||||
const installedRollout = readFileSync(path.join(codexRoot, manifest.codex[0].relPath), 'utf8')
|
||||
for (const content of [installedJsonl, installedSubagent, installedRollout]) {
|
||||
expect(content).toContain(WORKTREE_TO)
|
||||
expect(content).not.toContain(WORKTREE_FROM)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
findClaudeSessionFiles,
|
||||
installClaudeSession,
|
||||
installCodexSession,
|
||||
rewritePathInFile,
|
||||
rewritePathInTree,
|
||||
} from './sessionBundle'
|
||||
|
||||
export interface HandoffSource {
|
||||
issueNumber: number
|
||||
from: string
|
||||
branch?: string
|
||||
worktreePath?: string
|
||||
sessions: HandoffSessions
|
||||
profiles: HandoffProfiles
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export async function stageHandoffBundle(
|
||||
from: src.from,
|
||||
createdAt: now.toISOString(),
|
||||
...(src.branch ? { branch: src.branch } : {}),
|
||||
...(src.worktreePath ? { worktreePath: src.worktreePath } : {}),
|
||||
sessions: src.sessions,
|
||||
profiles: src.profiles,
|
||||
claude,
|
||||
@@ -76,6 +80,8 @@ export async function stageHandoffBundle(
|
||||
export interface HandoffInstallTargets extends HandoffRoots {
|
||||
workspaceProjectsDir: string
|
||||
worktreeProjectsDir?: string
|
||||
/** 发送方与接收方的 worktree 路径不同:安装前先把会话文件里的旧路径重写成新路径。 */
|
||||
pathRewrite?: { from: string, to: string }
|
||||
}
|
||||
|
||||
export async function installHandoffBundle(
|
||||
@@ -84,6 +90,18 @@ export async function installHandoffBundle(
|
||||
targets: HandoffInstallTargets,
|
||||
): Promise<void> {
|
||||
const claudeDir = path.join(extractedDir, 'claude')
|
||||
const rewrite = targets.pathRewrite
|
||||
// 发送方 worktree 在接收方上不存在:装文件前先把 cwd 类路径全部改写,
|
||||
// 否则 `codex resume` / MCP 起进程时照着旧路径找目录会直接 ENOENT。
|
||||
if (rewrite && rewrite.from !== rewrite.to) {
|
||||
for (const { id } of manifest.claude) {
|
||||
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), rewrite.from, rewrite.to)
|
||||
await rewritePathInTree(path.join(claudeDir, id), rewrite.from, rewrite.to)
|
||||
}
|
||||
for (const { relPath } of manifest.codex)
|
||||
await rewritePathInFile(path.join(extractedDir, 'codex', relPath), rewrite.from, rewrite.to)
|
||||
}
|
||||
|
||||
for (const { id, kind } of manifest.claude) {
|
||||
const dstProjectsDir = kind === 'brainstorm'
|
||||
? targets.workspaceProjectsDir
|
||||
|
||||
@@ -43,4 +43,11 @@ describe('parseHandoffManifest', () => {
|
||||
it('不是 JSON → 抛错', () => {
|
||||
expect(() => parseHandoffManifest('nope', 42)).toThrow()
|
||||
})
|
||||
it('带 worktreePath → 原样带出', () => {
|
||||
const withPath = { ...good, worktreePath: '/Users/chw/wt/x' }
|
||||
expect(parseHandoffManifest(JSON.stringify(withPath), 42)).toEqual(withPath)
|
||||
})
|
||||
it('不带 worktreePath → 结果里没有这个 key', () => {
|
||||
expect(parseHandoffManifest(JSON.stringify(good), 42)).not.toHaveProperty('worktreePath')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface HandoffManifest {
|
||||
from: string
|
||||
createdAt: string
|
||||
branch?: string
|
||||
worktreePath?: string
|
||||
sessions: HandoffSessions
|
||||
profiles: HandoffProfiles
|
||||
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
||||
@@ -96,6 +97,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
|
||||
from,
|
||||
createdAt,
|
||||
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
||||
...(optString(raw.worktreePath) ? { worktreePath: optString(raw.worktreePath) } : {}),
|
||||
sessions,
|
||||
profiles,
|
||||
claude,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
installClaudeSession,
|
||||
installCodexSession,
|
||||
listClaudeSessionCopies,
|
||||
rewritePathInFile,
|
||||
rewritePathInTree,
|
||||
} from './sessionBundle'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
@@ -123,3 +125,41 @@ describe('codex', () => {
|
||||
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewritePathInFile / rewritePathInTree', () => {
|
||||
it('文件里出现 3 次 → 返回 3 且内容被替换', async () => {
|
||||
const dir = tempDir('rewrite-')
|
||||
const file = path.join(dir, 'a.jsonl')
|
||||
writeFileSync(file, '{"cwd":"/Users/chw/wt/x"}\n{"cwd":"file:///Users/chw/wt/x"}\n/Users/chw/wt/x\n')
|
||||
const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(count).toBe(3)
|
||||
const content = readFileSync(file, 'utf8')
|
||||
expect(content).not.toContain('/Users/chw/wt/x')
|
||||
expect(content).toBe('{"cwd":"/home/me/wt/x"}\n{"cwd":"file:///home/me/wt/x"}\n/home/me/wt/x\n')
|
||||
})
|
||||
|
||||
it('文件里没出现 → 返回 0 且内容不变', async () => {
|
||||
const dir = tempDir('rewrite-')
|
||||
const file = path.join(dir, 'a.jsonl')
|
||||
writeFileSync(file, '{"cwd":"/other/path"}\n')
|
||||
const count = await rewritePathInFile(file, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(count).toBe(0)
|
||||
expect(readFileSync(file, 'utf8')).toBe('{"cwd":"/other/path"}\n')
|
||||
})
|
||||
|
||||
it('递归重写目录下所有文件,返回总替换次数', async () => {
|
||||
const dir = tempDir('rewrite-tree-')
|
||||
writeFileSync(path.join(dir, 'a.jsonl'), '/Users/chw/wt/x\n')
|
||||
mkdirSync(path.join(dir, 'sub', 'subagents'), { recursive: true })
|
||||
writeFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), '/Users/chw/wt/x /Users/chw/wt/x\n')
|
||||
writeFileSync(path.join(dir, 'sub', 'unrelated.txt'), 'no match here\n')
|
||||
const total = await rewritePathInTree(dir, '/Users/chw/wt/x', '/home/me/wt/x')
|
||||
expect(total).toBe(3)
|
||||
expect(readFileSync(path.join(dir, 'a.jsonl'), 'utf8')).toBe('/home/me/wt/x\n')
|
||||
expect(readFileSync(path.join(dir, 'sub', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('/home/me/wt/x /home/me/wt/x\n')
|
||||
})
|
||||
|
||||
it('目录不存在 → 返回 0', async () => {
|
||||
expect(await rewritePathInTree('/nonexistent/dir', '/a', '/b')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,3 +120,35 @@ export async function installCodexSession(relPath: string, srcFile: string, code
|
||||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||||
await fsp.copyFile(srcFile, dst)
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移会话里发送方的 worktree 路径重写成接收方的路径。
|
||||
* split/join 而非正则替换——路径里的 `.` `(` 等字符不用转义,出现次数就是替换次数。
|
||||
*/
|
||||
export async function rewritePathInFile(file: string, from: string, to: string): Promise<number> {
|
||||
const content = await fsp.readFile(file, 'utf8')
|
||||
const parts = content.split(from)
|
||||
const count = parts.length - 1
|
||||
if (count > 0)
|
||||
await fsp.writeFile(file, parts.join(to))
|
||||
return count
|
||||
}
|
||||
|
||||
export async function rewritePathInTree(dir: string, from: string, to: string): Promise<number> {
|
||||
let entries: Array<{ name: string, isDirectory: () => boolean, isFile: () => boolean }>
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
}
|
||||
catch {
|
||||
return 0
|
||||
}
|
||||
let total = 0
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory())
|
||||
total += await rewritePathInTree(full, from, to)
|
||||
else if (entry.isFile())
|
||||
total += await rewritePathInFile(full, from, to)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -171,11 +171,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
||||
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
|
||||
const assignees = (issue.assignees ?? []).map(a => a.login)
|
||||
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) {
|
||||
// 区分「不是我的工单」和「已在移交中/已完成」两种拒绝原因,措辞不同。
|
||||
const notMine = assignees.length > 0 && !(me !== undefined && assignees.includes(me))
|
||||
toast(panel, 'error', notMine
|
||||
? `#${issueNumber} 不是你负责的工单,不能移交`
|
||||
: `#${issueNumber} 已在移交中或已完成,不能再次移交`)
|
||||
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,7 +262,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
||||
const outDir = await scratchDir('spx-handoff-out-')
|
||||
try {
|
||||
manifest = await stageHandoffBundle(
|
||||
{ issueNumber, from: me, branch, sessions, profiles },
|
||||
{ issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles },
|
||||
staging,
|
||||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
||||
)
|
||||
@@ -453,19 +449,24 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
||||
})
|
||||
}
|
||||
|
||||
// ③ 装会话文件
|
||||
// ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的
|
||||
// cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着
|
||||
// 发送方那条本机不存在的路径找目录,直接 ENOENT。
|
||||
const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined
|
||||
await installHandoffBundle(extracted, manifest, {
|
||||
projectsRoot: claudeProjectsRoot(),
|
||||
codexRoot: defaultCodexSessionsDir(),
|
||||
workspaceProjectsDir: projectsDirFor(workspaceRoot),
|
||||
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
|
||||
...(pathRewrite ? { pathRewrite } : {}),
|
||||
})
|
||||
|
||||
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
||||
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists);
|
||||
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。
|
||||
const reviewSessionFileExists = !!manifest.sessions.reviewSessionId
|
||||
&& manifest.codex.some(c => c.id.toLowerCase() === manifest.sessions.reviewSessionId!.toLowerCase())
|
||||
const reviewSessionId = manifest.sessions.reviewSessionId
|
||||
const reviewSessionFileExists = !!reviewSessionId
|
||||
&& manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase())
|
||||
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
||||
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
||||
try {
|
||||
@@ -482,7 +483,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
||||
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }),
|
||||
})
|
||||
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
||||
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) })
|
||||
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs, pathRewrite }) })
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
Reference in New Issue
Block a user