From 605f69f6a518c8eac75fcda2e280edb1d39c0240 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 26 Aug 2026 16:17:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=A7=BB=E4=BA=A4/?= =?UTF-8?q?=E6=8E=A5=E7=AE=A1=E6=89=A9=E5=B1=95=E7=AB=AF=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=B8=8E=E6=B6=88=E6=81=AF=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph --- vscode/src/panel/KanbanPanel.ts | 13 + vscode/src/panel/handlers/handoffFlow.ts | 387 +++++++++++++++++++++++ vscode/src/panel/messages.ts | 5 + vscode/webview-ui/src/lib/messages.ts | 5 + 4 files changed, 410 insertions(+) create mode 100644 vscode/src/panel/handlers/handoffFlow.ts diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 83b2652..3c960cd 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -27,6 +27,7 @@ import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { getEffectiveCommitProfilePath, getSettings } from '../settings/store' import { webhookCoordinator } from '../webhook/coordinator' import { loadYouTrackIssues } from '../youtrack/issueLoader' +import * as handoffFlow from './handlers/handoffFlow' import * as issues from './handlers/issues' import * as managedSessions from './handlers/managedSessions' import * as prFiles from './handlers/prFiles' @@ -427,6 +428,18 @@ export class KanbanWebviewPanel { void issues.handleColumnChange(this, msg.issueNumber, msg.toColumn) return } + if (msg.type === 'handoff/users') { + void handoffFlow.handleHandoffUsers(this, msg.issueNumber) + return + } + if (msg.type === 'handoff/start') { + void handoffFlow.handleHandoffStart(this, msg.issueNumber, msg.to) + return + } + if (msg.type === 'handoff/accept') { + void handoffFlow.handleHandoffAccept(this, msg.issueNumber) + return + } if (msg.type === 'youtrack/list-projects') { void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token) return diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts new file mode 100644 index 0000000..5a2f6da --- /dev/null +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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 { + 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 { + return fsp.mkdtemp(path.join(os.tmpdir(), prefix)) +} + +export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise { + 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 { + 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 + 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(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 { + 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 + 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() + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 5b259fd..5b72809 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -44,6 +44,8 @@ export type ExtensionToWebview | { type: 'issues/error', message: string } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } + | { type: 'handoff/users-result', issueNumber: number, users: string[] } + | { type: 'handoff/done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } | { @@ -168,6 +170,9 @@ export type WebviewToExtension | { type: 'worktree/delete', issueNumber: number, path: string } | { type: 'git/merge-preview', issueNumber: number, branch: string } | { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string } + | { type: 'handoff/users', issueNumber: number } + | { type: 'handoff/start', issueNumber: number, to: string } + | { type: 'handoff/accept', issueNumber: number } | { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number } | { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number } | { type: 'issue/update-auto-review', issueNumber: number, value: boolean } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 9b06eaa..6b6590e 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -59,6 +59,8 @@ export type ExtensionToWebview | { type: 'issues/error', message: string } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } + | { type: 'handoff/users-result', issueNumber: number, users: string[] } + | { type: 'handoff/done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } | { @@ -183,6 +185,9 @@ export type WebviewToExtension | { type: 'worktree/delete', issueNumber: number, path: string } | { type: 'git/merge-preview', issueNumber: number, branch: string } | { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string } + | { type: 'handoff/users', issueNumber: number } + | { type: 'handoff/start', issueNumber: number, to: string } + | { type: 'handoff/accept', issueNumber: number } | { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number } | { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number } | { type: 'issue/update-auto-review', issueNumber: number, value: boolean }