✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI

- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写
- 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新
- 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版
- 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口
- session-handoff/accept 与待接收项改用 OSS 对象 key 标识
- 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表
- 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话
- 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 81fe14f6dc
commit fcee4143d4
26 files changed
+542 -515

No files matched your search

+1 -1
View File
@@ -571,7 +571,7 @@ export class KanbanWebviewPanel {
return
}
if (msg.type === 'session-handoff/accept') {
void sessionHandoff.handleSessionHandoffAccept(this, msg.attachmentId)
void sessionHandoff.handleSessionHandoffAccept(this, msg.key)
return
}
if (msg.type === 'session-handoff/refresh') {
+2 -2
View File
@@ -99,7 +99,7 @@ export function scratchDir(prefix: string): Promise<string> {
}
/** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */
async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
export async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
const store = await readHandoffStore(panel.context)
if (!store)
toast(panel, 'error', HANDOFF_STORE_MISSING_MESSAGE)
@@ -110,7 +110,7 @@ async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffSt
* 同一条进度 toast 随步骤改文案,直到流程结束才收掉。文案没变就不重发,
* 上传 / 下载的进度回调很密。
*/
function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
export function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
let id: string | undefined
let shown = ''
return {
+155 -150
View File
@@ -1,7 +1,18 @@
/**
* 会话交接:把会话 tab 里的一个 claude 会话交给同事。
*
* 会话包放在移交存储(阿里云 OSS)上,每个人在每个仓库有一个收件箱目录,
* key 的格式见 `src/cc/sessionHandoff.ts`。发送方打包上传进对方的收件箱,
* 接收方列自己的收件箱得到待接收列表,接收后把包删掉。
*/
import type { HandoffStore } from '../../oss/handoffStore'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import type { SessionHandoffPendingItem } from '../messages'
import type { RepoCtx } from './handoffFlow'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
import { packSessionHandoff, unpackSessionHandoff } from '../../cc/handoffArchive'
import { formatByteSize, packHandoff, unpackHandoff } from '../../cc/handoffArchive'
import { stageClaudeSessionFiles } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
import { resolveProfilePath } from '../../cc/profiles'
@@ -14,32 +25,26 @@ import {
} from '../../cc/sessionBundle'
import {
buildSessionHandoffManifest,
parseSessionHandoffAttachmentName,
parseSessionHandoffKey,
parseSessionHandoffManifest,
sessionHandoffAttachmentName,
sessionHandoffKey,
sessionInboxPrefix,
} from '../../cc/sessionHandoff'
import { projectsDirFor } from '../../cc/sessionWatcher'
import {
deleteIssueAttachment,
downloadAttachment,
listIssueAttachments,
listRepoAssignees,
uploadIssueAttachment,
} from '../../gitea/api'
import { ensureMailboxIssue, findMailboxIssue } from '../../gitea/mailboxIssue'
import { listRepoAssignees } from '../../gitea/api'
import { logger } from '../../logging/logger'
import {
deleteHandoffBundle,
downloadHandoffBundle,
isHandoffBundleMissing,
listHandoffBundles,
uploadHandoffBundle,
} from '../../oss/handoffStore'
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
import { dismiss, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
import { transferProgressText } from './handoff'
import { progressToast, requireHandoffStore, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
import { pushManagedSessions } from './managedSessions'
export interface SessionHandoffPendingItem {
attachmentId: number
sid: string
from: string
to: string
name: string
}
export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
@@ -60,27 +65,40 @@ export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Prom
}
}
/** 列我的收件箱发给 webview。拿不到列表(没配移交存储、没仓库、网络错)也回一个空列表,webview 不用一直等。 */
export async function handleSessionHandoffRefresh(panel: KanbanWebviewPanel): Promise<void> {
const post = (items: SessionHandoffPendingItem[]): void => panel.postMessage({ type: 'session-handoff/pending', items })
const store = await requireHandoffStore(panel)
if (!store) {
post([])
return
}
const ctx = await resolveRepoCtx(panel)
if (!ctx) {
panel.postMessage({ type: 'session-handoff/pending', items: [] })
post([])
return
}
try {
const items = await listPendingForMe(ctx)
panel.postMessage({ type: 'session-handoff/pending', items })
post(await listPendingForMe(store, ctx))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: '拉取待接收会话失败', details: message })
panel.postMessage({ type: 'session-handoff/pending', items: [] })
post([])
}
}
/**
* 发送方:关掉会话终端 → 收集会话文件、写清单、xz 打包 → 删掉对方收件箱里这个会话的旧包
* → 上传 → 本机记下交给了谁。同一条进度提示随步骤改文案,结束才收掉。
*/
export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessionId: string, to: string): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId })
let spinner: string | undefined
let progress: ReturnType<typeof progressToast> | undefined
try {
const store = await requireHandoffStore(panel)
if (!store)
return
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
@@ -95,62 +113,47 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi
toast(panel, 'error', `找不到会话文件 ${sessionId},无法交接`)
return
}
const key = sessionHandoffKey({ owner: ctx.owner, repo: ctx.repo, to, sid: session.id, from: ctx.me, name: session.name })
progress = progressToast(panel, `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}`)
const say = progress.say
const existing = panel.managedTerminals.get(sessionId)
if (existing) {
say('关闭终端')
try {
existing.dispose()
}
// 终端已经死了也无所谓,目的只是让 jsonl 不再被写
catch {}
await new Promise<void>(r => setTimeout(r, 600))
}
spinner = toast(panel, 'info', `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
say('收集会话')
const manifest = buildSessionHandoffManifest({
from: ctx.me,
to,
workspacePath: ctx.workspaceRoot,
session: { id: session.id, name: session.name, profilePath: session.profilePath },
})
const attachmentName = sessionHandoffAttachmentName({
sid: session.id,
from: ctx.me,
to,
name: session.name,
})
// 包必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir('spx-session-handoff-')
const outDir = await scratchDir('spx-session-handoff-out-')
let mailboxIndex: number
try {
const claudeDir = path.join(staging, 'claude')
await stageClaudeSessionFiles(files, claudeDir)
await stageClaudeSessionFiles(files, path.join(staging, 'claude'))
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 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)
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId: old.id })
const archive = path.join(outDir, 'session-handoff.tar.xz')
await packHandoff(staging, archive, { onProgress: bytes => say(`打包 ${formatByteSize(bytes)}`) })
// 同一个会话再交给同一人:先删掉对方收件箱里这个会话的旧包,对方只看到最新的一份。
for (const old of await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to }))) {
if (parseSessionHandoffKey(old.key)?.sid === session.id)
await deleteHandoffBundle(store, old.key)
}
await uploadIssueAttachment({
...ctx,
index: mailboxIndex,
name: attachmentName,
data: await fsp.readFile(archive),
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `会话交接打包/上传失败 ${sessionId}`, details: message })
if (spinner)
dismiss(panel, spinner)
toast(panel, 'error', `交接失败:${message}`)
return
await uploadHandoffBundle(store, key, archive, (sent, total) => say(transferProgressText('上传', sent, total)))
logger.add({ level: 'info', source: 'panel', message: `会话交接:会话包已上传 (${key}, ${(await fsp.stat(archive)).size} bytes)` })
}
finally {
// rm 失败不该抛出掩盖上面已经判定好的成败——scratch 目录只是垃圾。
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
@@ -160,137 +163,139 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi
await writeManagedSessions(ctx.workspaceRoot, data)
await pushAll(panel)
if (spinner)
dismiss(panel, spinner)
progress.dismiss()
toast(panel, 'success', `已交给 ${to}(对方在会话 tab 待接收)`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `会话交接未预期错误 ${sessionId}`, details: message })
logger.add({ level: 'error', source: 'panel', message: `会话交接失败 ${sessionId}`, details: message })
progress?.dismiss()
toast(panel, 'error', `交接失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
finish()
}
}
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, attachmentId: number): Promise<void> {
/**
* 接收方:只认我在这个仓库收件箱里的 key → 下载 → 解包、校验清单 → 发送方和我的
* 工作区路径不同就改写会话里的路径 → 装会话文件 → 记进会话 tab → 删掉 OSS 上的包。
* 删包之前失败,包还在列表里,可以重试。
*/
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, key: string): Promise<void> {
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' })
let spinner: string | undefined
let progress: ReturnType<typeof progressToast> | undefined
// extracted 声明在 try 外面:mkdtemp 之前就失败时 finally 不能去 rm 它。
let extracted: string | undefined
try {
const store = await requireHandoffStore(panel)
if (!store)
return
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const mailboxIndex = await findMailboxIssue(ctx)
if (mailboxIndex === undefined) {
toast(panel, 'error', '还没有会话交接邮箱')
return
}
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
const asset = assets.find(a => a.id === attachmentId)
if (!asset) {
toast(panel, 'error', '找不到这份待接收附件')
return
}
const named = parseSessionHandoffAttachmentName(asset.name)
if (!named || named.to !== ctx.me) {
// key 是 webview 发来的,只认我在当前仓库收件箱里的包。
const ref = parseSessionHandoffKey(key)
if (!ref || !key.startsWith(sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me }))) {
toast(panel, 'error', '这份交接不是给你的')
return
}
const label = ref.name || ref.sid.slice(0, 8)
spinner = toast(panel, 'info', `正在接收「${named.name || named.sid.slice(0, 8)}」…`, { spinner: true, dismissOnTimer: 120_000 })
const archiveDir = await scratchDir('spx-session-handoff-in-')
const extracted = await scratchDir('spx-session-handoff-ex-')
progress = progressToast(panel, `正在接收「${label}」`)
const say = progress.say
extracted = await scratchDir('spx-session-handoff-in-')
// 包名固定,不拿 key 拼本机路径。
const archive = path.join(extracted, 'session-handoff.tar.xz')
try {
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 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},不是你`)
const claudeDir = path.join(extracted, 'claude')
if (manifest.workspacePath !== ctx.workspaceRoot) {
for (const { id } of manifest.claude) {
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot)
await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot)
}
}
const dstProjectsDir = projectsDirFor(ctx.workspaceRoot)
await fsp.mkdir(dstProjectsDir, { recursive: true })
for (const { id } of manifest.claude) {
await installClaudeSession({
sid: id,
srcDir: claudeDir,
dstProjectsDir,
projectsRoot: claudeProjectsRoot(),
})
}
await downloadHandoffBundle(store, key, archive, (got, total) => say(transferProgressText('下载', got, total)))
}
catch (err) {
if (!isHandoffBundleMissing(err))
throw err
progress.dismiss()
toast(panel, 'error', `「${label}」已经不在收件箱里了,可能在别的机器上接收过`)
await handleSessionHandoffRefresh(panel)
return
}
const data = await readManagedSessions(ctx.workspaceRoot)
const existing = data.sessions.find(s => s.id === manifest.session.id)
const profilePath = resolveProfilePath(manifest.session.profilePath)
if (existing) {
if (manifest.session.name)
existing.name = manifest.session.name
existing.profilePath = profilePath
say('解包')
await unpackHandoff(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},不是你`)
if (manifest.session.id !== ref.sid)
throw new Error(`清单里的会话 ${manifest.session.id} 和收件箱里的 ${ref.sid} 对不上`)
say('装会话')
const claudeDir = path.join(extracted, 'claude')
if (manifest.workspacePath !== ctx.workspaceRoot) {
for (const { id } of manifest.claude) {
await rewritePathInFile(path.join(claudeDir, `${id}.jsonl`), manifest.workspacePath, ctx.workspaceRoot)
await rewritePathInTree(path.join(claudeDir, id), manifest.workspacePath, ctx.workspaceRoot)
}
else {
data.sessions.push({
id: manifest.session.id,
name: manifest.session.name || named.name || manifest.session.id.slice(0, 8),
profilePath,
createdAt: Date.now(),
})
}
await writeManagedSessions(ctx.workspaceRoot, data)
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId }).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'warn', source: 'panel', message: '接收后删附件失败', details: message })
}
const dstProjectsDir = projectsDirFor(ctx.workspaceRoot)
await fsp.mkdir(dstProjectsDir, { recursive: true })
for (const { id } of manifest.claude) {
await installClaudeSession({
sid: id,
srcDir: claudeDir,
dstProjectsDir,
projectsRoot: claudeProjectsRoot(),
})
}
finally {
await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {})
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
say('写入会话列表')
const data = await readManagedSessions(ctx.workspaceRoot)
const existing = data.sessions.find(s => s.id === manifest.session.id)
const profilePath = resolveProfilePath(manifest.session.profilePath)
if (existing) {
if (manifest.session.name)
existing.name = manifest.session.name
existing.profilePath = profilePath
}
else {
data.sessions.push({
id: manifest.session.id,
name: manifest.session.name || label,
profilePath,
createdAt: Date.now(),
})
}
await writeManagedSessions(ctx.workspaceRoot, data)
try {
await deleteHandoffBundle(store, key)
}
catch (err) {
logger.add({ level: 'warn', source: 'panel', message: `接收会话后删除 OSS 上的会话包失败 (${key})`, details: err instanceof Error ? err.message : String(err) })
}
await pushAll(panel)
if (spinner)
dismiss(panel, spinner)
toast(panel, 'success', `已接收「${named.name || named.sid.slice(0, 8)}」,来自 ${named.from}`)
progress.dismiss()
toast(panel, 'success', `已接收「${label}」,来自 ${manifest.from}`)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 attachment=${attachmentId}`, details: message })
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 ${key}`, details: message })
progress?.dismiss()
toast(panel, 'error', `接收失败:${message}`)
if (spinner)
dismiss(panel, spinner)
}
finally {
// finish 先发:下面的 rm 失败不该让 webview 一直等。
finish()
if (extracted)
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
}
}
async function listPendingForMe(ctx: { host: string, token: string, owner: string, repo: string, me: string }): Promise<SessionHandoffPendingItem[]> {
const mailboxIndex = await findMailboxIssue(ctx)
if (mailboxIndex === undefined)
return []
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
async function listPendingForMe(store: HandoffStore, ctx: RepoCtx): Promise<SessionHandoffPendingItem[]> {
const objects = await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me }))
const items: SessionHandoffPendingItem[] = []
for (const a of assets) {
const parsed = parseSessionHandoffAttachmentName(a.name)
if (!parsed || parsed.to !== ctx.me)
continue
items.push({
attachmentId: a.id,
sid: parsed.sid,
from: parsed.from,
to: parsed.to,
name: parsed.name,
})
for (const o of objects) {
const ref = parseSessionHandoffKey(o.key)
if (ref)
items.push({ key: o.key, sid: ref.sid, from: ref.from, name: ref.name })
}
return items
}
+3 -3
View File
@@ -18,11 +18,11 @@ export interface ManagedSessionShowItem extends ManagedSession {
tabOpen?: boolean
}
/** 我收件箱里一份待接收的会话包,`key` 是它在移交存储上的对象 key。 */
export interface SessionHandoffPendingItem {
attachmentId: number
key: string
sid: string
from: string
to: string
name: string
}
@@ -223,7 +223,7 @@ export type WebviewToExtension
| { type: 'handoff/request-reject', issueNumber: number }
| { type: 'session-handoff/users' }
| { type: 'session-handoff/start', sessionId: string, to: string }
| { type: 'session-handoff/accept', attachmentId: number }
| { type: 'session-handoff/accept', key: string }
| { type: 'session-handoff/refresh' }
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }