✨ 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

+112
View File
@@ -0,0 +1,112 @@
/**
* 工单移交的会话包放在阿里云 OSS 上:发送方上传,接管方下载后删掉。
*
* 这里只管传文件,不碰 VS Code;配置从哪来见 `src/oss/handoffStoreSetup.ts`。
*/
import type { Readable } from 'node:stream'
import { createWriteStream, promises as fsp } from 'node:fs'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import OSS from 'ali-oss'
/** 移交会话包所在的存储桶和访问它的 AccessKey,四项都有值才算配好。 */
export interface HandoffStore {
/** 如 `oss-cn-beijing.aliyuncs.com` */
endpoint: string
bucket: string
accessKeyId: string
accessKeySecret: string
}
/** 下载时连续这么久收不到数据就放弃。SDK 拿到响应头以后就不再计时,网断了会一直挂着。 */
const DOWNLOAD_IDLE_MS = 60_000
function client(store: HandoffStore): OSS {
return new OSS({
endpoint: store.endpoint,
bucket: store.bucket,
accessKeyId: store.accessKeyId,
accessKeySecret: store.accessKeySecret,
secure: true,
})
}
/**
* 分片上传 `file` 到 `key`,对象权限设成私有:存储桶本身是公共读的,会话包里有代码和
* 对话,不能让人不带密钥就下载到。中途失败会把已传的分片清掉。`onProgress` 每传完一片
* 报一次已传字节数和总字节数。
*/
export async function uploadHandoffBundle(
store: HandoffStore,
key: string,
file: string,
onProgress?: (sentBytes: number, totalBytes: number) => void,
): Promise<void> {
const total = (await fsp.stat(file)).size
const oss = client(store)
let uploadId: string | undefined
try {
await oss.multipartUpload(key, file, {
mime: 'application/x-xz',
headers: { 'x-oss-object-acl': 'private' },
// SDK 开始时报 0、每传完一片报一次、合并完报 1;小于 100KB 的文件不分片,只报一次 1。
progress: (fraction: number, checkpoint?: { uploadId: string, partSize: number, doneParts: unknown[] } | null) => {
if (checkpoint)
uploadId = checkpoint.uploadId
const sent = fraction >= 1
? total
: checkpoint ? Math.min(total, checkpoint.doneParts.length * checkpoint.partSize) : 0
onProgress?.(sent, total)
},
})
}
catch (err) {
if (uploadId)
await oss.abortMultipartUpload(key, uploadId).catch(() => {})
throw err
}
}
/**
* 把 `key` 边下边写进 `dstFile`。`onProgress` 每收到一块数据报一次已收字节数和总字节数
* (服务器没给长度时总数是 0)。
*
* @throws 对象不存在、连续 60 秒收不到数据、收到的字节数和长度对不上。
*/
export async function downloadHandoffBundle(
store: HandoffStore,
key: string,
dstFile: string,
onProgress?: (receivedBytes: number, totalBytes: number) => void,
): Promise<void> {
const { stream, res } = await client(store).getStream(key)
const total = Number((res.headers as Record<string, string | undefined>)['content-length'] ?? 0) || 0
let received = 0
let idle: ReturnType<typeof setTimeout> | undefined
const counter = new Transform({
transform(chunk: Uint8Array, _encoding, callback) {
received += chunk.length
armIdle()
onProgress?.(received, total)
callback(null, chunk)
},
})
function armIdle(): void {
clearTimeout(idle)
idle = setTimeout(() => counter.destroy(new Error(`下载 ${DOWNLOAD_IDLE_MS / 1000} 秒没有收到数据`)), DOWNLOAD_IDLE_MS)
}
armIdle()
try {
await pipeline(stream as Readable, counter, createWriteStream(dstFile))
}
finally {
clearTimeout(idle)
}
if (total > 0 && received !== total)
throw new Error(`下载不完整:收到 ${received} 字节,应为 ${total} 字节`)
}
export async function deleteHandoffBundle(store: HandoffStore, key: string): Promise<void> {
await client(store).delete(key)
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 移交存储的配置:endpoint 和 bucket 存在扩展设置里,两个 AccessKey 存在 SecretStorage 里。
*/
import type { ExtensionContext } from 'vscode'
import type { HandoffStore } from './handoffStore'
import { window } from 'vscode'
import { getHandoffOssKeys, setHandoffOssKeys } from '../auth/secrets'
import { getSettings, saveSettings } from '../settings/store'
export const CONFIGURE_HANDOFF_STORE_COMMAND = 'superpowers.configureHandoffStore'
export const HANDOFF_STORE_MISSING_MESSAGE = '还没配置移交存储:在命令面板运行「Superpowers: 配置移交存储」'
/** 读移交存储的配置,四项缺一项就当没配置,返回 undefined。 */
export async function readHandoffStore(ctx: ExtensionContext): Promise<HandoffStore | undefined> {
const { handoffOssEndpoint: endpoint, handoffOssBucket: bucket } = getSettings(ctx)
const { accessKeyId, accessKeySecret } = await getHandoffOssKeys(ctx)
if (!endpoint || !bucket || !accessKeyId || !accessKeySecret)
return undefined
return { endpoint, bucket, accessKeyId, accessKeySecret }
}
async function ask(opts: { title: string, prompt: string, value?: string, placeHolder?: string, password?: boolean }): Promise<string | undefined> {
const input = await window.showInputBox({
title: opts.title,
prompt: opts.prompt,
value: opts.value,
placeHolder: opts.placeHolder,
password: opts.password,
ignoreFocusOut: true,
validateInput: v => (v.trim() ? undefined : '不能为空'),
})
return input?.trim()
}
/** 「配置移交存储」命令:依次问四项,框里预填已有的值;四项都填完才保存,中途按 Esc 什么都不改。 */
export async function configureHandoffStore(ctx: ExtensionContext): Promise<void> {
const settings = getSettings(ctx)
const keys = await getHandoffOssKeys(ctx)
const endpoint = await ask({
title: '配置移交存储(1/4)',
prompt: '阿里云 OSS 的 endpoint',
value: settings.handoffOssEndpoint,
placeHolder: 'oss-cn-beijing.aliyuncs.com',
})
if (!endpoint)
return
const bucket = await ask({ title: '配置移交存储(2/4)', prompt: 'bucket 名', value: settings.handoffOssBucket })
if (!bucket)
return
const accessKeyId = await ask({ title: '配置移交存储(3/4)', prompt: 'AccessKey ID', value: keys.accessKeyId })
if (!accessKeyId)
return
const accessKeySecret = await ask({ title: '配置移交存储(4/4)', prompt: 'AccessKey Secret', value: keys.accessKeySecret, password: true })
if (!accessKeySecret)
return
await saveSettings(ctx, { handoffOssEndpoint: endpoint, handoffOssBucket: bucket })
await setHandoffOssKeys(ctx, { accessKeyId, accessKeySecret })
void window.showInformationMessage(`移交存储已保存:${bucket}(${endpoint})`)
}