Files
superwork/vscode/src/panel/handlers/handoffFlow.ts
T

503 lines
23 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.
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets'
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
import { claudeProjectsRoot } from '../../cc/sessionBundle'
import { projectsDirFor } from '../../cc/sessionWatcher'
import { detectRepo } from '../../git/remote'
import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath'
import {
deleteIssueAttachment,
downloadAttachment,
getIssue,
getIssueAttachment,
listIssueAttachments,
listRepoAssignees,
updateIssueAssignees,
uploadIssueAttachment,
} from '../../gitea/api'
import { logger } from '../../logging/logger'
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import {
canAcceptHandoff,
canStartHandoff,
checkWorktreePushed,
handoffAcceptedStateExtra,
handoffAcceptedUiPatch,
handoffLocalTombstones,
handoffStartedStateExtra,
handoffStartedUiPatch,
} from './handoff'
export interface RepoCtx {
workspaceRoot: string
host: string
owner: string
repo: string
token: string
me: string
}
export function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
const id = extra?.id ?? makeNonce()
panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 })
return id
}
export function dismiss(panel: KanbanWebviewPanel, id: string): void {
panel.postMessage({ type: 'toast/dismiss', id })
}
export async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
toast(panel, 'error', '请先打开一个工作区文件夹')
return undefined
}
const remote = await detectRepo(workspaceRoot)
if (!remote) {
toast(panel, 'error', '当前工作区没有 Gitea 远程仓库')
return undefined
}
const token = await getToken(panel.context, remote.host)
if (!token) {
toast(panel, 'error', '请先完成 Gitea 配置')
return undefined
}
const me = await loginForToken(remote.host, token)
if (!me) {
toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?')
return undefined
}
return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me }
}
function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> {
return new Promise((resolve) => {
execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => {
resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() })
})
})
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined
}
export function scratchDir(prefix: string): Promise<string> {
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
}
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
// ctx 解析失败已经 toast 过原因;这里必须补一条空结果,否则弹窗的 spinner 转不停。
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
return
}
try {
const users = await listRepoAssignees(ctx)
panel.postMessage({
type: 'handoff/users-result',
issueNumber,
users: users.map(u => u.login).filter(l => l !== ctx.me).sort(),
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast(panel, 'error', `读取可指派用户失败:${message}`)
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
}
}
/**
* 发送方:服务端复查 canStartHandoffwebview 按钮的显示条件可能过期)→
* 校验 worktree 已 pushworktree 存在但 state 缺 branch 直接拒绝;没有
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
*/
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined
try {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
return
}
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
// assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) {
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
}
if (issue.state === 'closed') {
toast(panel, 'error', `#${issueNumber} 已关闭,不能移交`)
return
}
// 前置复查:webview 按钮的显示条件可能已经过期(并发移交、工单已被拖到
// 完成、指派已变更),用刚读到的最新 state + issue 再判一次 canStartHandoff
// 避免重复移交或抢别人的工单。
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
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)) {
toast(panel, 'error', `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`)
return
}
const branch = str(state.branch)
const worktreePath = str(state.worktreePath)
const sessions: HandoffSessions = {
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
}
const profiles: HandoffProfiles = {
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) {
toast(panel, 'error', `无法移交 #${issueNumber}state 缺少 branch,无法校验是否已 push`)
return
}
const status = await git(worktreeAbs, ['status', '--porcelain'])
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
if (!status.ok || !fetched.ok) {
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
return
}
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
if (!check.ok) {
toast(panel, 'error', `无法移交 #${issueNumber}${check.reason}`)
return
}
}
else if (branch) {
const localRef = await git(workspaceRoot, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
// 本地没有这个分支就没什么可丢的,直接放行。
if (localRef.ok) {
const fetched = await git(workspaceRoot, ['fetch', 'origin', branch])
const localHead = await git(workspaceRoot, ['rev-parse', `refs/heads/${branch}`])
const remoteHead = await git(workspaceRoot, ['rev-parse', `refs/remotes/origin/${branch}`])
if (!fetched.ok || localHead.stdout.trim() !== remoteHead.stdout.trim()) {
toast(panel, 'error', `无法移交 #${issueNumber}:本地分支 ${branch} 与远端不一致,请先 push`)
return
}
}
}
spinner = toast(panel, 'info', `正在移交 #${issueNumber}${to}…`, { spinner: true, dismissOnTimer: 120_000 })
// ② 关终端,等 jsonl 刷盘
let disposedAny = false
for (const [terminal, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber) {
try {
terminal.dispose()
disposedAny = true
}
// 终端已经死了也无所谓,目的只是确保不再有活跃会话
catch {}
}
}
for (const t of window.terminals) {
if (t.name === `issue-${issueNumber}-冲突解决`) {
try {
t.dispose()
disposedAny = true
}
// 同上:冲突解决终端不进 terminalOrigin,按名字关,关不掉不影响移交
catch {}
}
}
if (disposedAny)
await new Promise<void>(r => setTimeout(r, 600))
// ③ 打包 + 上传
let attachmentId: number
let manifest: HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir('spx-handoff-')
const outDir = await scratchDir('spx-handoff-out-')
try {
manifest = await stageHandoffBundle(
{ issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles },
staging,
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
)
const name = handoffAttachmentName(issueNumber)
const archive = path.join(outDir, name)
await packHandoff(staging, archive)
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
if (old.name === name)
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
}
const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) })
attachmentId = uploaded.id
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(打包/上传):${message}`)
return
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
// ⑤ 重新指派。失败回滚远端改动。
try {
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(写状态):${message}`)
return
}
try {
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。
await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {})
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
dismiss(panel, spinner)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
return
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
// 远端移交已经生效,这里不回滚。
let worktreeRemovalFailed = false
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
const settings = getSettings(panel.context)
try {
await panel.dispatchWorktreeHook('pre-remove', {
workspaceRoot,
worktreePath: worktreeAbs,
branch: branch ?? '',
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.worktreePreRemoveScript,
})
killProcessesUsingWorktree(worktreeAbs)
await removeWorktreeDir(workspaceRoot, worktreeAbs)
}
catch (err) {
worktreeRemovalFailed = true
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message })
toast(panel, 'error', `已移交,但本机 worktree 清理失败:${message}`)
}
}
if (branch) {
const settings = getSettings(panel.context)
await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch, autoBuildBranch: settings.autoBuildBranch })
}
// ⑦ 本机墓碑:sid/profile/worktreePath 归零。worktree 没删掉时保留
// worktreePath,别把用户还留在磁盘上的 worktree 从 state 里抹掉。写墓碑
// 失败只告警——远端移交已经生效,不回滚。
await panel.mergeIssueState(issueNumber, handoffLocalTombstones({ keepWorktreePath: worktreeRemovalFailed })).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:写本机墓碑失败`, details: message })
})
dismiss(panel, spinner)
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message })
toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
finish()
}
}
/**
* 接管方:服务端复查 canAcceptHandoffwebview 按钮的显示条件可能过期)→
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
finish()
return
}
const { workspaceRoot, host, owner, repo, token, me } = ctx
let state: Record<string, unknown>
try {
state = await panel.readIssueState(issueNumber)
}
catch (err) {
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
finish()
return
}
const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10)
if (!Number.isFinite(attachmentId)) {
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
finish()
return
}
// spinner / extracted 声明在 try 外面:getIssue、mkdtemp 任一失败都要靠
// 「是否已创建」来决定 catch/finally 里能不能 dismiss/rm,不能假设已存在。
let spinner: string | undefined
let extracted: string | undefined
try {
spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
// 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) {
dismiss(panel, spinner)
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
}
if (!canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
dismiss(panel, spinner)
toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`)
return
}
extracted = await scratchDir('spx-takeover-')
// ① 下载 + 解包 + 校验清单
const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
const data = await downloadAttachment({ token, url: meta.browser_download_url })
const archive = path.join(extracted, meta.name)
await fsp.writeFile(archive, data)
await unpackHandoff(archive, extracted)
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber)
// ② 重建 worktree
const branch = manifest.branch ?? str(state.branch)
let worktreeAbs: string | undefined
if (branch) {
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
const settings = getSettings(panel.context)
await panel.dispatchWorktreeHook('post-create', {
workspaceRoot,
worktreePath: worktreeAbs,
branch,
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.worktreePostCreateScript,
})
}
// ③ 装会话文件;发送方与接收方的 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 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 {
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId })
}
catch (err) {
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) })
}
dismiss(panel, spinner)
panel.postMessage({
type: 'issue/patch',
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, pathRewrite }) })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message })
if (spinner)
dismiss(panel, spinner)
toast(panel, 'error', `接管失败:${message}`)
}
finally {
// finish 先发:下面的 rm 失败不该让弹窗的 spinner 卡住不收。
finish()
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
if (extracted)
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
}
}