✨ 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:
1 parent
a33d3d2251
commit
499cfad7d7
39 files changed
+1039
-526
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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})`)
|
||||
}
|
||||
Reference in new issue
Block a user