@@ -0,0 +1,387 @@
import type { HandoffManifest , HandoffProfiles , HandoffSessions } from '../../cc/handoffManifest'
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 ,
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 {
checkWorktreePushed ,
handoffAcceptedStateExtra ,
handoffAcceptedUiPatch ,
handoffStartedStateExtra ,
handoffStartedUiPatch ,
} from './handoff'
interface RepoCtx {
workspaceRoot : string
host : string
owner : string
repo : string
token : string
me : string
}
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
}
function dismiss ( panel : KanbanWebviewPanel , id : string ) : void {
panel . postMessage ( { type : 'toast/dismiss' , id } )
}
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
}
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 )
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 : [ ] } )
}
}
/**
* 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 →
* 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;
* 本机清理失败只告警,移交在远端已经生效。
*/
export async function handleHandoffStart ( panel : KanbanWebviewPanel , issueNumber : number , to : string ) : Promise < void > {
const ctx = await resolveRepoCtx ( panel )
if ( ! ctx )
return
const { workspaceRoot , host , owner , repo , token , me } = ctx
const finish = ( ) : void = > panel . postMessage ( { type : 'handoff/done' , issueNumber } )
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 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 守卫:干净 + 与远端一致
const worktreeAbs = worktreePath ? resolveWorktreePath ( worktreePath , workspaceRoot ) : undefined
if ( worktreeAbs && fs . existsSync ( worktreeAbs ) && branch ) {
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 ( ) } ` )
finish ( )
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 } ` )
finish ( )
return
}
}
const 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 , 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 } ` )
finish ( )
return
}
finally {
await fsp . rm ( staging , { recursive : true , force : true } )
await fsp . rm ( outDir , { recursive : true , force : true } )
}
// ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。
try {
await panel . mergeIssueState ( issueNumber , handoffStartedStateExtra ( attachmentId , me ) )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
await deleteIssueAttachment ( { host , token , owner , repo , index : issueNumber , attachmentId } ) . catch ( ( ) = > { } )
dismiss ( panel , spinner )
toast ( panel , 'error' , ` 移交失败(写状态): ${ message } ` )
finish ( )
return
}
try {
await updateIssueAssignees ( { host , token , owner , repo , index : issueNumber , assignees : [ to ] } )
}
catch ( err ) {
const message = err instanceof Error ? err.message : String ( err )
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 } ` )
finish ( )
return
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)
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 ) {
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 } )
}
dismiss ( panel , spinner )
panel . postMessage ( { type : 'issue/patch' , issueNumber , patch : handoffStartedUiPatch ( attachmentId , me , to ) } )
toast ( panel , 'success' , ` # ${ issueNumber } 已移交给 ${ to } ( ${ manifest . claude . length } 个 claude 会话, ${ manifest . codex . length } 个 codex 会话) ` )
finish ( )
}
/**
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept ( panel : KanbanWebviewPanel , issueNumber : number ) : Promise < void > {
const ctx = await resolveRepoCtx ( panel )
if ( ! ctx )
return
const { workspaceRoot , host , owner , repo , token } = ctx
const finish = ( ) : void = > panel . postMessage ( { type : 'handoff/done' , issueNumber } )
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
}
const spinner = toast ( panel , 'info' , ` 正在接管 # ${ issueNumber } … ` , { spinner : true , dismissOnTimer : 120_000 } )
const extracted = await scratchDir ( 'spx-takeover-' )
try {
// ① 下载 + 解包 + 校验清单
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 ,
} )
}
// ③ 装会话文件
await installHandoffBundle ( extracted , manifest , {
projectsRoot : claudeProjectsRoot ( ) ,
codexRoot : defaultCodexSessionsDir ( ) ,
workspaceProjectsDir : projectsDirFor ( workspaceRoot ) ,
. . . ( worktreeAbs ? { worktreeProjectsDir : projectsDirFor ( worktreeAbs ) } : { } ) ,
} )
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
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 } ) ,
} )
toast ( panel , 'success' , ` 已接管 # ${ issueNumber } (来自 ${ manifest . from } ) ${ worktreeAbs ? ` , worktree: ${ worktreeAbs } ` : '' } ` )
logger . add ( { level : 'info' , source : 'panel' , message : ` 接管 # ${ issueNumber } 完成 ` , details : JSON.stringify ( { manifest , worktreeAbs } ) } )
}
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 } ` )
}
finally {
await fsp . rm ( extracted , { recursive : true , force : true } )
finish ( )
}
}