✨ feat(vscode): 工单移交的会话包改走阿里云 OSS、换 xz 压缩,两边进度一步步显示

- 会话包传到 OSS 的 spx-handoff/<owner>/<repo>/issue-<N>-<时间>.tar.xz,上传时对象设成私有;不再挂 Gitea 工单附件
- 新命令「配置移交存储」:endpoint、bucket 存扩展设置,AccessKey 存密钥存储;没配时移交 / 接管直接提示,什么都不改
- 共享 state 字段 handoffAttachmentId 改名 handoffBundleKey(含 spx CLI 与 schema)
- 打包换成 xz 1 级(tar -cJf),去掉 100MB 上限检查和先试压一遍的重复压缩
- 发送方提示打包 / 上传了多少 MB;接管方同一条提示依次显示下载、解包、建 worktree、装会话,完成前不消失
- 会话交接(单会话)仍走 Gitea 附件,Gitea 附件白名单没有 .xz,单独保留 gzip 打包
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent a33d3d2251
commit 499cfad7d7
39 files changed
+1039 -526

No files matched your search

+29 -17
View File
@@ -8,8 +8,11 @@ import {
handoffLocalTombstones,
handoffStartedStateExtra,
handoffStartedUiPatch,
transferProgressText,
} from './handoff'
const KEY = 'spx-handoff/o/r/issue-9-20261008T010203Z.tar.xz'
describe('canStartHandoff', () => {
it('gitea 工单、非 done、无待接管、无 assignee 或 assignee 含我 → true', () => {
expect(canStartHandoff({ column: 'in-progress' }, 'me')).toBe(true)
@@ -20,21 +23,30 @@ describe('canStartHandoff', () => {
it('done / youtrack / 已在移交中 / 不是我负责 → false', () => {
expect(canStartHandoff({ column: 'done' }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'review', source: 'youtrack' }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'review', handoffBundleKey: KEY }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'todo', assignees: ['x'] }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'todo', assignees: ['me'] }, undefined)).toBe(false)
})
})
describe('canAcceptHandoff', () => {
it('有附件且 assignee 含我 → true', () => {
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(true)
it('有待接管的会话包且 assignee 含我 → true', () => {
expect(canAcceptHandoff({ handoffBundleKey: KEY, assignees: ['me', 'x'] }, 'me')).toBe(true)
})
it('无附件 / 不是我的 / 身份未知 / youtrack → false', () => {
it('没有会话包 / 不是我的 / 身份未知 / youtrack → false', () => {
expect(canAcceptHandoff({ assignees: ['me'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['x'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me'] }, undefined)).toBe(false)
expect(canAcceptHandoff({ source: 'youtrack', handoffAttachmentId: '9', assignees: ['me'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffBundleKey: KEY, assignees: ['x'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffBundleKey: KEY, assignees: ['me'] }, undefined)).toBe(false)
expect(canAcceptHandoff({ source: 'youtrack', handoffBundleKey: KEY, assignees: ['me'] }, 'me')).toBe(false)
})
})
describe('transferProgressText', () => {
it('知道总大小时报「已传 / 总共」', () => {
expect(transferProgressText('上传', 3.2 * 1024 * 1024, 32 * 1024 * 1024)).toBe('上传 3.2MB / 32MB')
})
it('总大小未知时只报已传多少', () => {
expect(transferProgressText('下载', 512 * 1024, 0)).toBe('下载 512KB')
})
})
@@ -58,8 +70,8 @@ describe('checkWorktreePushed', () => {
describe('payloads', () => {
it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => {
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
handoffAttachmentId: '9',
expect(handoffStartedStateExtra(KEY, 'chw')).toEqual({
handoffBundleKey: KEY,
handoffFrom: 'chw',
})
})
@@ -95,8 +107,8 @@ describe('payloads', () => {
})
})
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: false })).toEqual({
handoffAttachmentId: '9',
expect(handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: false })).toEqual({
handoffBundleKey: KEY,
handoffFrom: 'chw',
assignees: ['me'],
sessionId: null,
@@ -114,11 +126,11 @@ describe('payloads', () => {
})
})
it('移交 UI patch:keepWorktreePath=true 时省略 worktreePath / worktreeExists', () => {
const patch = handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: true })
const patch = handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: true })
expect(patch).not.toHaveProperty('worktreePath')
expect(patch).not.toHaveProperty('worktreeExists')
expect(patch).toEqual({
handoffAttachmentId: '9',
handoffBundleKey: KEY,
handoffFrom: 'chw',
assignees: ['me'],
sessionId: null,
@@ -139,7 +151,7 @@ describe('payloads', () => {
profiles: { profilePath: '/p.json' },
worktreePath: '/wt',
})).toEqual({
handoffAttachmentId: '',
handoffBundleKey: '',
handoffFrom: '',
implementSessionId: 'i',
reviewSessionId: 'r',
@@ -155,7 +167,7 @@ describe('payloads', () => {
worktreeExists: true,
missingSessionIds: [],
})).toEqual({
handoffAttachmentId: null,
handoffBundleKey: null,
handoffFrom: null,
sessionId: 's',
worktreePath: '/wt',
@@ -170,7 +182,7 @@ describe('payloads', () => {
worktreeExists: false,
missingSessionIds: [],
})).toEqual({
handoffAttachmentId: null,
handoffBundleKey: null,
handoffFrom: null,
sessionId: 's',
worktreePath: null,
@@ -185,7 +197,7 @@ describe('payloads', () => {
worktreeExists: false,
missingSessionIds: ['r'],
})).toEqual({
handoffAttachmentId: null,
handoffBundleKey: null,
handoffFrom: null,
sessionId: 's',
reviewSessionId: 'r',
+19 -11
View File
@@ -1,29 +1,37 @@
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import { formatByteSize } from '../../cc/handoffArchive'
import { LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack'
column: IssueColumn
handoffAttachmentId?: string
handoffBundleKey?: string
assignees?: string[]
}, me: string | undefined): boolean {
const assignees = issue.assignees ?? []
const mine = assignees.length === 0 || (me !== undefined && assignees.includes(me))
return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId && mine
return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffBundleKey && mine
}
export function canAcceptHandoff(issue: {
source?: 'gitea' | 'youtrack'
handoffAttachmentId?: string
handoffBundleKey?: string
assignees?: string[]
}, me: string | undefined): boolean {
return issue.source !== 'youtrack'
&& !!issue.handoffAttachmentId
&& !!issue.handoffBundleKey
&& me !== undefined
&& (issue.assignees ?? []).includes(me)
}
/** 传输进度文案,如 `上传 3.2MB / 32MB`;不知道总大小时只报已传多少。 */
export function transferProgressText(verb: string, doneBytes: number, totalBytes: number): string {
return totalBytes > 0
? `${verb} ${formatByteSize(doneBytes)} / ${formatByteSize(totalBytes)}`
: `${verb} ${formatByteSize(doneBytes)}`
}
/** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */
export function checkWorktreePushed(input: {
statusPorcelain: string
@@ -38,13 +46,13 @@ export function checkWorktreePushed(input: {
}
/**
* 共享墓碑:只写 handoffAttachmentId / handoffFrom 两个共享字段。本机
* 共享字段:只写 handoffBundleKey / handoffFrom 两个。本机
* sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘,
* 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空,
* 用户会话恢复不了。
*/
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
return { handoffAttachmentId: String(attachmentId), handoffFrom: from }
export function handoffStartedStateExtra(bundleKey: string, from: string): Record<string, unknown> {
return { handoffBundleKey: bundleKey, handoffFrom: from }
}
/**
@@ -62,9 +70,9 @@ export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Rec
return extra
}
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string, opts: { keepWorktreePath: boolean }): Record<string, unknown> {
export function handoffStartedUiPatch(bundleKey: string, from: string, to: string, opts: { keepWorktreePath: boolean }): Record<string, unknown> {
return {
handoffAttachmentId: String(attachmentId),
handoffBundleKey: bundleKey,
handoffFrom: from,
assignees: [to],
sessionId: null,
@@ -97,7 +105,7 @@ export function handoffAcceptedStateExtra(local: {
worktreePath?: string
}): Record<string, unknown> {
return {
handoffAttachmentId: '',
handoffBundleKey: '',
handoffFrom: '',
...definedEntries(local.sessions),
...definedEntries(local.profiles),
@@ -113,7 +121,7 @@ export function handoffAcceptedUiPatch(local: {
missingSessionIds: string[]
}): Record<string, unknown> {
return {
handoffAttachmentId: null,
handoffBundleKey: null,
handoffFrom: null,
...definedEntries(local.sessions),
...definedEntries(local.profiles),
+103 -107
View File
@@ -1,5 +1,6 @@
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types'
import type { HandoffStore } from '../../oss/handoffStore'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
@@ -9,26 +10,18 @@ import * as path from 'node:path'
import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets'
import { formatByteSize, HandoffArchiveTooLargeError, packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { formatByteSize, packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest'
import { ensureHandoffFits, HandoffSourcesTooLargeError } from '../../cc/handoffSize'
import { HANDOFF_MANIFEST_FILE, handoffBundleKey, 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 { getIssue, listRepoAssignees, updateIssueAssignees } from '../../gitea/api'
import { logger } from '../../logging/logger'
import { deleteHandoffBundle, downloadHandoffBundle, uploadHandoffBundle } from '../../oss/handoffStore'
import { HANDOFF_STORE_MISSING_MESSAGE, readHandoffStore } from '../../oss/handoffStoreSetup'
import { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../../webview/html'
@@ -42,6 +35,7 @@ import {
handoffLocalTombstones,
handoffStartedStateExtra,
handoffStartedUiPatch,
transferProgressText,
} from './handoff'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest'
@@ -104,6 +98,35 @@ export function scratchDir(prefix: string): Promise<string> {
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
}
/** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */
async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
const store = await readHandoffStore(panel.context)
if (!store)
toast(panel, 'error', HANDOFF_STORE_MISSING_MESSAGE)
return store
}
/**
* 同一条进度 toast 随步骤改文案,直到流程结束才收掉。文案没变就不重发,
* 上传 / 下载的进度回调很密。
*/
function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
let id: string | undefined
let shown = ''
return {
say: (step) => {
if (id && step === shown)
return
shown = step
id = toast(panel, 'info', `${prefix}:${step}`, { id, spinner: true, dismissOnTimer: 0 })
},
dismiss: () => {
if (id)
dismiss(panel, id)
},
}
}
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
@@ -130,7 +153,7 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
* 发送方:服务端复查 canStartHandoff(webview 按钮的显示条件可能过期)→
* 校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝;没有
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 关终端 → 打包上传到 OSS → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。
@@ -139,9 +162,13 @@ export type HandoffStartResult = { ok: true } | { ok: false, message: string }
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<HandoffStartResult> {
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined
// progress 建在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
const progress = progressToast(panel, `正在移交 #${issueNumber} 给 ${to}`)
const say = progress.say
try {
const store = await requireHandoffStore(panel)
if (!store)
return { ok: false, message: HANDOFF_STORE_MISSING_MESSAGE }
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return { ok: false, message: '无法开始移交' }
@@ -175,7 +202,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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)) {
if (!canStartHandoff({ source: 'gitea', column, handoffBundleKey: str(state.handoffBundleKey), assignees }, me)) {
const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
toast(panel, 'error', message)
return { ok: false, message }
@@ -197,10 +224,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}),
}
const say = (step: string): string => toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}:${step}`, { id: spinner, spinner: true, dismissOnTimer: 0 })
const fail = (message: string): HandoffStartResult => {
if (spinner)
dismiss(panel, spinner)
progress.dismiss()
toast(panel, 'error', message)
return { ok: false, message }
}
@@ -208,7 +233,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
spinner = say('核对分支是否已推送')
say('核对分支是否已推送')
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) {
@@ -239,29 +264,6 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
}
// 关终端之前查大小。太大就停,避免白关终端再打包很久。
const handoffSrc = { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles }
const handoffRoots = { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }
let packedMbShown = -1
const reportPacked = (label: string, withLimit: boolean) => (packedBytes: number): void => {
const mb = Math.floor(packedBytes / (1024 * 1024))
if (mb === packedMbShown)
return
packedMbShown = mb
say(withLimit ? `${label} ${mb}MB / 100MB` : `${label} ${mb}MB`)
}
say('检查会话大小')
try {
await ensureHandoffFits(handoffSrc, handoffRoots, { onProgress: reportPacked('压缩检查', true) })
}
catch (err) {
if (err instanceof HandoffSourcesTooLargeError) {
return fail(`#${issueNumber} ${err.message}`)
}
const message = err instanceof Error ? err.message : String(err)
return fail(`移交失败(检查会话大小):${message}`)
}
// ② 关终端,等 jsonl 刷盘
say('关闭终端')
let disposedAny = false
@@ -290,35 +292,24 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// ③ 打包 + 上传
say('收集会话')
let attachmentId: number
const handoffSrc = { issueNumber, from: me, branch, ...(worktreeAbs && fs.existsSync(worktreeAbs) ? { worktreePath: worktreeAbs } : {}), sessions, profiles }
const handoffRoots = { projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() }
const bundleKey = handoffBundleKey({ owner, repo, issueNumber, at: new Date() })
let manifest: HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
// 包必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir('spx-handoff-')
const outDir = await scratchDir('spx-handoff-out-')
try {
manifest = await stageHandoffBundle(handoffSrc, staging, handoffRoots)
const name = handoffAttachmentName(issueNumber)
const archive = path.join(outDir, name)
packedMbShown = -1
await packHandoff(staging, archive, { onProgress: reportPacked('打包', false) })
say(`上传 ${formatByteSize((await fsp.stat(archive)).size)}`)
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) })
const archive = path.join(outDir, path.posix.basename(bundleKey))
await packHandoff(staging, archive, { onProgress: bytes => say(`打包 ${formatByteSize(bytes)}`) })
await uploadHandoffBundle(store, bundleKey, archive, (sent, total) => say(transferProgressText('上传', sent, total)))
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (${bundleKey}, ${(await fsp.stat(archive)).size} bytes)`, details: JSON.stringify(manifest) })
}
catch (err) {
const message = err instanceof HandoffArchiveTooLargeError
? err.message
: (err instanceof Error ? err.message : String(err))
const prefix = err instanceof HandoffArchiveTooLargeError ? '打包' : '打包/上传'
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', `移交失败(${prefix}):${message}`)
return { ok: false, message: `移交失败(${prefix}):${message}` }
return fail(`移交失败(打包/上传):${message}`)
}
finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
@@ -326,19 +317,17 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦);
// ④ 写共享字段(只 handoffBundleKey / handoffFrom,本机墓碑留到 ⑦);
// ⑤ 重新指派。失败回滚远端改动。
say('写入状态')
try {
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(bundleKey, 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 { ok: false, message: `移交失败(写状态):${message}` }
await deleteHandoffBundle(store, bundleKey).catch(() => {})
return fail(`移交失败(写状态):${message}`)
}
say('指派')
try {
@@ -347,11 +336,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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 { ok: false, message: `移交失败(指派 ${to}):${message}` }
await panel.mergeIssueState(issueNumber, { handoffBundleKey: '', handoffFrom: '' }).catch(() => {})
await deleteHandoffBundle(store, bundleKey).catch(() => {})
return fail(`移交失败(指派 ${to}):${message}`)
}
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
@@ -393,8 +380,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
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 }) })
progress.dismiss()
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(bundleKey, me, to, { keepWorktreePath: worktreeRemovalFailed }) })
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
@@ -403,9 +390,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message })
progress.dismiss()
toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
return { ok: false, message: `移交失败:${message}` }
}
finally {
@@ -413,12 +399,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
}
}
/**
* 接管方:服务端复查 canAcceptHandoff(webview 按钮的显示条件可能过期)→
* 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
*/
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffAttachmentId?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffBundleKey?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
@@ -442,7 +423,7 @@ async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Pro
facts: {
source: 'gitea',
column,
...(str(state.handoffAttachmentId) ? { handoffAttachmentId: str(state.handoffAttachmentId) } : {}),
...(str(state.handoffBundleKey) ? { handoffBundleKey: str(state.handoffBundleKey) } : {}),
...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
},
@@ -493,6 +474,9 @@ export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issu
toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`)
return
}
// 没配移交存储是本机的事,不记成申请失败的原因,申请原样留着。
if (!await requireHandoffStore(panel))
return
const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom)
if (result.ok)
return
@@ -500,9 +484,19 @@ export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issu
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestError: result.message } })
}
/**
* 接管方:服务端复查 canAcceptHandoff(webview 按钮的显示条件可能过期)→
* 从 OSS 下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
* 清共享字段 + 删 OSS 上的会话包。前几步失败时会话包还在,按钮仍显示,可重试。
*/
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
const store = await requireHandoffStore(panel)
if (!store) {
finish()
return
}
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
finish()
@@ -519,41 +513,41 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
finish()
return
}
const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10)
if (!Number.isFinite(attachmentId)) {
const bundleKey = str(state.handoffBundleKey)
if (!bundleKey) {
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
finish()
return
}
// spinner / extracted 声明在 try 外面:getIssue、mkdtemp 任一失败都要靠
// 「是否已创建」来决定 catch/finally 里能不能 dismiss/rm,不能假设已存在。
let spinner: string | undefined
// 进度 toast 和发送方一样一直留着、随步骤改文案,结束时才收掉。
const progress = progressToast(panel, `正在接管 #${issueNumber}`)
const say = progress.say
// extracted 声明在 try 外面:mkdtemp 之前就失败时 finally 不能去 rm 它。
let extracted: string | undefined
try {
spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
say('核对指派')
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
// 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) {
dismiss(panel, spinner)
progress.dismiss()
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return
}
if (!canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
dismiss(panel, spinner)
if (!canAcceptHandoff({ handoffBundleKey: bundleKey, assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
progress.dismiss()
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)
// ① 下载 + 解包 + 校验清单。包名固定,不拿共享 state 里的 key 拼本机路径。
const archive = path.join(extracted, 'handoff-bundle.tar.xz')
await downloadHandoffBundle(store, bundleKey, archive, (got, total) => say(transferProgressText('下载', got, total)))
say('解包')
await unpackHandoff(archive, extracted)
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber)
@@ -561,6 +555,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
const branch = manifest.branch ?? str(state.branch)
let worktreeAbs: string | undefined
if (branch) {
say('建 worktree')
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
@@ -578,6 +573,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
// ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的
// cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着
// 发送方那条本机不存在的路径找目录,直接 ENOENT。
say('装会话')
const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined
await installHandoffBundle(extracted, manifest, {
projectsRoot: claudeProjectsRoot(),
@@ -587,20 +583,21 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
...(pathRewrite ? { pathRewrite } : {}),
})
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
// ④ 本机字段 + 清共享字段;⑤ 删 OSS 上的会话包(失败只 warn)
// missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
// 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
say('写入状态')
const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
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 })
await deleteHandoffBundle(store, bundleKey)
}
catch (err) {
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) })
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除 OSS 上的会话包失败 (${bundleKey})`, details: err instanceof Error ? err.message : String(err) })
}
dismiss(panel, spinner)
progress.dismiss()
panel.postMessage({
type: 'issue/patch',
issueNumber,
@@ -612,8 +609,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
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)
progress.dismiss()
toast(panel, 'error', `接管失败:${message}`)
}
finally {
@@ -17,7 +17,7 @@ describe('移交申请', () => {
expect(canAskHandoff({ ...review, assignees: [] }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, column: 'in-progress' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, source: 'youtrack' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, handoffAttachmentId: '9' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, handoffBundleKey: 'spx-handoff/o/r/issue-9-20261008T010203Z.tar.xz' }, 'cruldra')).toBe(false)
expect(canAskHandoff(review, undefined)).toBe(false)
})
+2 -2
View File
@@ -1,14 +1,14 @@
export interface HandoffRequestFacts {
source?: 'gitea' | 'youtrack'
column: 'todo' | 'in-progress' | 'review' | 'done'
handoffAttachmentId?: string
handoffBundleKey?: string
handoffRequestFrom?: string
handoffRequestError?: string
assignees?: string[]
}
function openReview(issue: HandoffRequestFacts): boolean {
return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffAttachmentId
return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffBundleKey
}
/** 审查列里同事负责的工单,且当前没有别的申请。 */
+3 -3
View File
@@ -1,7 +1,7 @@
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { packSessionHandoff, unpackSessionHandoff } from '../../cc/handoffArchive'
import { stageClaudeSessionFiles } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
import { resolveProfilePath } from '../../cc/profiles'
@@ -129,7 +129,7 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi
await fsp.writeFile(path.join(staging, HANDOFF_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
mailboxIndex = await ensureMailboxIssue(ctx)
const archive = path.join(outDir, attachmentName)
await packHandoff(staging, archive)
await packSessionHandoff(staging, archive)
for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) {
const parsed = parseSessionHandoffAttachmentName(old.name)
if (parsed && parsed.sid === session.id && parsed.to === to)
@@ -208,7 +208,7 @@ export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, atta
const buf = await downloadAttachment({ token: ctx.token, url: asset.browser_download_url })
const archive = path.join(archiveDir, asset.name)
await fsp.writeFile(archive, buf)
await unpackHandoff(archive, extracted)
await unpackSessionHandoff(archive, extracted)
const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'))
if (manifest.to !== ctx.me)
throw new Error(`清单接收人是 ${manifest.to},不是你`)
+1 -1
View File
@@ -54,7 +54,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, reviewPassed?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, reviewPassed?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffBundleKey?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }