🐛 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 })
|
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
|
||||||
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
|
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,
|
findClaudeSessionFiles,
|
||||||
installClaudeSession,
|
installClaudeSession,
|
||||||
installCodexSession,
|
installCodexSession,
|
||||||
|
rewritePathInFile,
|
||||||
|
rewritePathInTree,
|
||||||
} from './sessionBundle'
|
} from './sessionBundle'
|
||||||
|
|
||||||
export interface HandoffSource {
|
export interface HandoffSource {
|
||||||
issueNumber: number
|
issueNumber: number
|
||||||
from: string
|
from: string
|
||||||
branch?: string
|
branch?: string
|
||||||
|
worktreePath?: string
|
||||||
sessions: HandoffSessions
|
sessions: HandoffSessions
|
||||||
profiles: HandoffProfiles
|
profiles: HandoffProfiles
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ export async function stageHandoffBundle(
|
|||||||
from: src.from,
|
from: src.from,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
...(src.branch ? { branch: src.branch } : {}),
|
...(src.branch ? { branch: src.branch } : {}),
|
||||||
|
...(src.worktreePath ? { worktreePath: src.worktreePath } : {}),
|
||||||
sessions: src.sessions,
|
sessions: src.sessions,
|
||||||
profiles: src.profiles,
|
profiles: src.profiles,
|
||||||
claude,
|
claude,
|
||||||
@@ -76,6 +80,8 @@ export async function stageHandoffBundle(
|
|||||||
export interface HandoffInstallTargets extends HandoffRoots {
|
export interface HandoffInstallTargets extends HandoffRoots {
|
||||||
workspaceProjectsDir: string
|
workspaceProjectsDir: string
|
||||||
worktreeProjectsDir?: string
|
worktreeProjectsDir?: string
|
||||||
|
/** 发送方与接收方的 worktree 路径不同:安装前先把会话文件里的旧路径重写成新路径。 */
|
||||||
|
pathRewrite?: { from: string, to: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installHandoffBundle(
|
export async function installHandoffBundle(
|
||||||
@@ -84,6 +90,18 @@ export async function installHandoffBundle(
|
|||||||
targets: HandoffInstallTargets,
|
targets: HandoffInstallTargets,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const claudeDir = path.join(extractedDir, 'claude')
|
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) {
|
for (const { id, kind } of manifest.claude) {
|
||||||
const dstProjectsDir = kind === 'brainstorm'
|
const dstProjectsDir = kind === 'brainstorm'
|
||||||
? targets.workspaceProjectsDir
|
? targets.workspaceProjectsDir
|
||||||
|
|||||||
@@ -43,4 +43,11 @@ describe('parseHandoffManifest', () => {
|
|||||||
it('不是 JSON → 抛错', () => {
|
it('不是 JSON → 抛错', () => {
|
||||||
expect(() => parseHandoffManifest('nope', 42)).toThrow()
|
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
|
from: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
branch?: string
|
branch?: string
|
||||||
|
worktreePath?: string
|
||||||
sessions: HandoffSessions
|
sessions: HandoffSessions
|
||||||
profiles: HandoffProfiles
|
profiles: HandoffProfiles
|
||||||
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
||||||
@@ -96,6 +97,7 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
|
|||||||
from,
|
from,
|
||||||
createdAt,
|
createdAt,
|
||||||
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
||||||
|
...(optString(raw.worktreePath) ? { worktreePath: optString(raw.worktreePath) } : {}),
|
||||||
sessions,
|
sessions,
|
||||||
profiles,
|
profiles,
|
||||||
claude,
|
claude,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
installClaudeSession,
|
installClaudeSession,
|
||||||
installCodexSession,
|
installCodexSession,
|
||||||
listClaudeSessionCopies,
|
listClaudeSessionCopies,
|
||||||
|
rewritePathInFile,
|
||||||
|
rewritePathInTree,
|
||||||
} from './sessionBundle'
|
} from './sessionBundle'
|
||||||
|
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
@@ -123,3 +125,41 @@ describe('codex', () => {
|
|||||||
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
|
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.mkdir(path.dirname(dst), { recursive: true })
|
||||||
await fsp.copyFile(srcFile, dst)
|
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 column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
|
||||||
const assignees = (issue.assignees ?? []).map(a => a.login)
|
const assignees = (issue.assignees ?? []).map(a => a.login)
|
||||||
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) {
|
if (!canStartHandoff({ source: 'gitea', column, handoffAttachmentId: str(state.handoffAttachmentId), assignees }, me)) {
|
||||||
// 区分「不是我的工单」和「已在移交中/已完成」两种拒绝原因,措辞不同。
|
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`)
|
||||||
const notMine = assignees.length > 0 && !(me !== undefined && assignees.includes(me))
|
|
||||||
toast(panel, 'error', notMine
|
|
||||||
? `#${issueNumber} 不是你负责的工单,不能移交`
|
|
||||||
: `#${issueNumber} 已在移交中或已完成,不能再次移交`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +262,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
const outDir = await scratchDir('spx-handoff-out-')
|
const outDir = await scratchDir('spx-handoff-out-')
|
||||||
try {
|
try {
|
||||||
manifest = await stageHandoffBundle(
|
manifest = await stageHandoffBundle(
|
||||||
{ issueNumber, from: me, branch, sessions, profiles },
|
{ issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles },
|
||||||
staging,
|
staging,
|
||||||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
{ 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, {
|
await installHandoffBundle(extracted, manifest, {
|
||||||
projectsRoot: claudeProjectsRoot(),
|
projectsRoot: claudeProjectsRoot(),
|
||||||
codexRoot: defaultCodexSessionsDir(),
|
codexRoot: defaultCodexSessionsDir(),
|
||||||
workspaceProjectsDir: projectsDirFor(workspaceRoot),
|
workspaceProjectsDir: projectsDirFor(workspaceRoot),
|
||||||
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
|
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
|
||||||
|
...(pathRewrite ? { pathRewrite } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
||||||
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists);
|
// reviewSessionFileExists 是计算字段,board 只在启动时算一次(annotateReviewSessionFileExists);
|
||||||
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。
|
// 接管刚把 codex 会话文件装进本机,这里顺手算好一起下发,UI 不用等下次刷新才能点「审查会话」链接。
|
||||||
const reviewSessionFileExists = !!manifest.sessions.reviewSessionId
|
const reviewSessionId = manifest.sessions.reviewSessionId
|
||||||
&& manifest.codex.some(c => c.id.toLowerCase() === manifest.sessions.reviewSessionId!.toLowerCase())
|
const reviewSessionFileExists = !!reviewSessionId
|
||||||
|
&& manifest.codex.some(c => c.id.toLowerCase() === reviewSessionId.toLowerCase())
|
||||||
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
|
||||||
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
|
||||||
try {
|
try {
|
||||||
@@ -482,7 +483,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
|||||||
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }),
|
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs, reviewSessionFileExists }),
|
||||||
})
|
})
|
||||||
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
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) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user