✨ 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

+16
View File
@@ -43,3 +43,19 @@ export async function setYouTrackToken(ctx: ExtensionContext, host: string, toke
export async function deleteYouTrackToken(ctx: ExtensionContext, host: string): Promise<void> {
await ctx.secrets.delete(youtrackTokenKey(host))
}
/** 移交存储(阿里云 OSS)的 AccessKey,整台机器共用一份,不分仓库。 */
const HANDOFF_OSS_KEY_ID = 'handoff-oss:access-key-id'
const HANDOFF_OSS_KEY_SECRET = 'handoff-oss:access-key-secret'
export async function getHandoffOssKeys(ctx: ExtensionContext): Promise<{ accessKeyId?: string, accessKeySecret?: string }> {
return {
accessKeyId: await ctx.secrets.get(HANDOFF_OSS_KEY_ID),
accessKeySecret: await ctx.secrets.get(HANDOFF_OSS_KEY_SECRET),
}
}
export async function setHandoffOssKeys(ctx: ExtensionContext, keys: { accessKeyId: string, accessKeySecret: string }): Promise<void> {
await ctx.secrets.store(HANDOFF_OSS_KEY_ID, keys.accessKeyId)
await ctx.secrets.store(HANDOFF_OSS_KEY_SECRET, keys.accessKeySecret)
}
+87
View File
@@ -0,0 +1,87 @@
import { mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { formatByteSize, packHandoff, packSessionHandoff, unpackHandoff, unpackSessionHandoff } from './handoffArchive'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
function headBytes(file: string, n: number): number[] {
const fd = openSync(file, 'r')
const buf = new Uint8Array(n)
readSync(fd, buf, 0, n, 0)
return [...buf]
}
/** 一份像会话的暂存目录:清单 + 一条重复很多的 jsonl + 子目录里的文件。 */
function staging(): string {
const dir = tempDir('handoff-archive-staging-')
mkdirSync(path.join(dir, 'claude', 'sid', 'tool-results'), { recursive: true })
writeFileSync(path.join(dir, 'handoff.json'), '{"version":1}\n')
writeFileSync(path.join(dir, 'claude', 'sid.jsonl'), '{"type":"user","message":"重复的对话内容"}\n'.repeat(20_000))
writeFileSync(path.join(dir, 'claude', 'sid', 'tool-results', 'out.txt'), 'tool\n')
return dir
}
describe('formatByteSize', () => {
it('10MB 以上取整,1MB~10MB 留一位小数,再往下换 KB / B', () => {
expect(formatByteSize(100 * 1024 * 1024)).toBe('100MB')
expect(formatByteSize(1.5 * 1024 * 1024)).toBe('1.5MB')
expect(formatByteSize(512 * 1024)).toBe('512KB')
expect(formatByteSize(300)).toBe('300B')
})
})
describe('packHandoff / unpackHandoff', () => {
it('打出 xz 包,解开后内容一致', async () => {
const src = staging()
const archive = path.join(tempDir('handoff-archive-out-'), 'issue-1-20261008T000000Z.tar.xz')
await packHandoff(src, archive)
// xz 文件头:FD 37 7A 58 5A 00
expect(headBytes(archive, 6)).toEqual([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])
expect(statSync(archive).size).toBeLessThan(statSync(path.join(src, 'claude', 'sid.jsonl')).size / 20)
const out = tempDir('handoff-archive-unpacked-')
await unpackHandoff(archive, out)
for (const rel of ['handoff.json', 'claude/sid.jsonl', 'claude/sid/tool-results/out.txt'])
expect(readFileSync(path.join(out, rel), 'utf8')).toBe(readFileSync(path.join(src, rel), 'utf8'))
})
it('最后一次进度就是包的最终大小', async () => {
const archive = path.join(tempDir('handoff-archive-out-'), 'bundle.tar.xz')
const reported: number[] = []
await packHandoff(staging(), archive, { onProgress: bytes => reported.push(bytes) })
expect(reported.at(-1)).toBe(statSync(archive).size)
})
it('打包失败带上 tar 的报错', async () => {
const archive = path.join(tempDir('handoff-archive-out-'), 'bundle.tar.xz')
await expect(packHandoff(path.join(tempDir('handoff-archive-missing-'), 'nope'), archive)).rejects.toThrow(/tar 打包失败/)
})
})
describe('packSessionHandoff / unpackSessionHandoff', () => {
it('打出 gzip 包,解开后内容一致', async () => {
const src = staging()
const archive = path.join(tempDir('session-archive-out-'), 'bundle.tgz')
await packSessionHandoff(src, archive)
expect(headBytes(archive, 2)).toEqual([0x1F, 0x8B])
const out = tempDir('session-archive-unpacked-')
await unpackSessionHandoff(archive, out)
expect(readFileSync(path.join(out, 'claude', 'sid.jsonl'), 'utf8')).toBe(readFileSync(path.join(src, 'claude', 'sid.jsonl'), 'utf8'))
})
})
+44 -68
View File
@@ -2,9 +2,6 @@ import { execFile, spawn } from 'node:child_process'
import { promises as fsp, statSync } from 'node:fs'
import * as path from 'node:path'
/** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */
export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024
export function formatByteSize(bytes: number): string {
const mb = bytes / (1024 * 1024)
if (mb >= 10)
@@ -19,18 +16,6 @@ export function formatByteSize(bytes: number): string {
return `${bytes}B`
}
export class HandoffArchiveTooLargeError extends Error {
readonly packedBytes: number
readonly limitBytes: number
constructor(packedBytes: number, limitBytes: number) {
super(`压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`)
this.name = 'HandoffArchiveTooLargeError'
this.packedBytes = packedBytes
this.limitBytes = limitBytes
}
}
function tarUnpack(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
@@ -43,74 +28,65 @@ function tarUnpack(args: string[]): Promise<void> {
})
}
export async function packHandoff(
stagingDir: string,
outFile: string,
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
): Promise<void> {
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
/** 跑一次打包;给了 `onProgress` 就每 200ms 报一次包文件已写出的字节数,结束时再报一次最终大小。 */
async function tarPack(args: string[], outFile: string, onProgress?: (packedBytes: number) => void): Promise<void> {
await fsp.mkdir(path.dirname(outFile), { recursive: true })
const reportSize = (): void => {
try {
onProgress?.(statSync(outFile).size)
}
catch {
// 包还没写出来
}
}
await new Promise<void>((resolve, reject) => {
const child = spawn('tar', ['-czf', outFile, '-C', stagingDir, '.'], { stdio: ['ignore', 'ignore', 'pipe'] })
const child = spawn('tar', args, { stdio: ['ignore', 'ignore', 'pipe'] })
const decode = new TextDecoder()
let stderr = ''
let settled = false
let poll: ReturnType<typeof setInterval> | undefined
child.stderr?.on('data', (chunk: Uint8Array) => {
stderr += decode.decode(chunk, { stream: true })
})
const finish = (err?: Error): void => {
if (settled)
return
settled = true
if (poll)
clearInterval(poll)
if (err)
reject(err)
else
resolve()
}
const checkSize = (): void => {
let size = 0
try {
size = statSync(outFile).size
}
catch {
return
}
opts?.onProgress?.(size)
// 产出一超过上限就杀掉。等 tar 自己打完再失败,大会话会卡很久。
if (size > maxBytes) {
child.kill('SIGKILL')
finish(new HandoffArchiveTooLargeError(size, maxBytes))
}
}
poll = setInterval(checkSize, 200)
child.on('error', err => finish(err))
const poll = onProgress ? setInterval(reportSize, 200) : undefined
child.on('error', (err) => {
clearInterval(poll)
reject(err)
})
child.on('close', (code) => {
if (settled)
return
let size = 0
try {
size = statSync(outFile).size
}
catch {
// 包还没写出来
}
opts?.onProgress?.(size)
if (size > maxBytes) {
finish(new HandoffArchiveTooLargeError(size, maxBytes))
clearInterval(poll)
if (code !== 0) {
reject(new Error(`tar 打包失败: ${stderr.trim() || `exit ${code}`}`))
return
}
if (code === 0)
finish()
else
finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`))
reportSize()
resolve()
})
})
}
/**
* 工单移交的会话包:tar.xz,xz 1 级。会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB
* 以内的重复,压不动;xz 1 级能压到 gzip 的三分之一左右,速度也够。用的是 macOS 自带
* bsdtar 内建的 xz,不用另装软件。
*/
export async function packHandoff(
stagingDir: string,
outFile: string,
opts?: { onProgress?: (packedBytes: number) => void },
): Promise<void> {
await tarPack(['--options', 'xz:compression-level=1', '-cJf', outFile, '-C', stagingDir, '.'], outFile, opts?.onProgress)
}
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await tarUnpack(['-xJf', archive, '-C', dstDir])
}
/** 会话交接(单个会话)的包:tgz。它挂在 Gitea 工单附件上,Gitea 的附件白名单里没有 .xz。 */
export async function packSessionHandoff(stagingDir: string, outFile: string): Promise<void> {
await tarPack(['-czf', outFile, '-C', stagingDir, '.'], outFile)
}
export async function unpackSessionHandoff(archive: string, dstDir: string): Promise<void> {
await fsp.mkdir(dstDir, { recursive: true })
await tarUnpack(['-xzf', archive, '-C', dstDir])
}
+1 -1
View File
@@ -76,7 +76,7 @@ describe('pack → unpack → install', () => {
sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW },
profiles: {},
}, staging, roots)
const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz')
const archive = path.join(tempDir('archive-'), 'issue-7-20261008T010203Z.tar.xz')
await packHandoff(staging, archive)
expect(existsSync(archive)).toBe(true)
+11 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest'
import { handoffBundleKey, parseHandoffManifest } from './handoffManifest'
const UUID = '11111111-1111-4111-8111-111111111111'
@@ -15,9 +15,16 @@ const good = {
codex: [],
}
describe('handoffAttachmentName', () => {
it('固定命名', () => {
expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz')
describe('handoffBundleKey', () => {
it('按仓库和工单分目录,带 UTC 时间,后缀 .tar.xz', () => {
const at = new Date('2026-10-08T01:02:03.456Z')
expect(handoffBundleKey({ owner: 'HKJX', repo: 'luffy-agent-platform', issueNumber: 479, at }))
.toBe('spx-handoff/HKJX/luffy-agent-platform/issue-479-20261008T010203Z.tar.xz')
})
it('同一工单不同时间移交,key 不同', () => {
const a = handoffBundleKey({ owner: 'o', repo: 'r', issueNumber: 1, at: new Date('2026-10-08T01:02:03Z') })
const b = handoffBundleKey({ owner: 'o', repo: 'r', issueNumber: 1, at: new Date('2026-10-08T01:02:04Z') })
expect(a).not.toBe(b)
})
})
+7 -2
View File
@@ -37,8 +37,13 @@ export interface HandoffManifest {
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
export function handoffAttachmentName(issueNumber: number): string {
return `spx-handoff-issue-${issueNumber}.tgz`
/**
* 会话包在 OSS 上的对象 key:`spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`。
* 带上时间,同一张工单移交几次也不会撞名。
*/
export function handoffBundleKey(opts: { owner: string, repo: string, issueNumber: number, at: Date }): string {
const stamp = opts.at.toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, '')
return `spx-handoff/${opts.owner}/${opts.repo}/issue-${opts.issueNumber}-${stamp}.tar.xz`
}
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test', 'prototype'])
-112
View File
@@ -1,112 +0,0 @@
import { Buffer } from 'node:buffer'
import { randomBytes } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive'
import { ensureHandoffFits, HandoffSourcesTooLargeError, measureHandoffSources } from './handoffSize'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
function fixture(implBytes: Buffer): { projectsRoot: string, codexRoot: string } {
const projectsRoot = tempDir('handoff-size-projects-')
const codexRoot = tempDir('handoff-size-codex-')
const proj = path.join(projectsRoot, '-Users-chw-proj')
const wt = path.join(projectsRoot, '-Users-chw-wt-x')
mkdirSync(proj, { recursive: true })
mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true })
writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n')
writeFileSync(path.join(wt, `${IMPL}.jsonl`), implBytes)
writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n')
const day = path.join(codexRoot, '2026', '08', '26')
mkdirSync(day, { recursive: true })
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n')
return { projectsRoot, codexRoot }
}
function source(roots: { projectsRoot: string, codexRoot: string }) {
return {
src: {
issueNumber: 448,
from: 'chw',
sessions: {
sessionId: BRAIN,
implementSessionId: IMPL,
testSessionId: 'missing-0000-4000-8000-000000000000',
reviewSessionId: REVIEW,
},
profiles: {},
},
roots,
}
}
describe('formatByteSize', () => {
it('100MB 上限按整数 MB 显示', () => {
expect(formatByteSize(HANDOFF_ATTACHMENT_MAX_BYTES)).toBe('100MB')
})
})
describe('measureHandoffSources', () => {
it('jsonl、会话目录、codex 加在一起,缺失的会话不出现', async () => {
const roots = fixture(Buffer.from('impl\n'))
const { src } = source(roots)
const items = await measureHandoffSources(src, roots)
expect(items).toEqual([
{ role: '头脑风暴', bytes: Buffer.byteLength('brain\n') },
{ role: '实施', bytes: Buffer.byteLength('impl\n') },
{ role: '审查', bytes: Buffer.byteLength('review\n') },
])
})
})
describe('ensureHandoffFits', () => {
it('原始大小不超过上限时直接通过', async () => {
const roots = fixture(Buffer.from('impl\n'))
const { src } = source(roots)
const items = await ensureHandoffFits(src, roots, { maxBytes: 1024 * 1024 })
expect(items.map(item => item.role)).toEqual(['头脑风暴', '实施', '审查'])
})
it('压缩后超过上限就停,并说明还没开始移交', async () => {
const roots = fixture(randomBytes(80 * 1024))
const { src } = source(roots)
await expect(ensureHandoffFits(src, roots, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffSourcesTooLargeError)
try {
await ensureHandoffFits(src, roots, { maxBytes: 1024 })
}
catch (err) {
expect(err).toBeInstanceOf(HandoffSourcesTooLargeError)
expect((err as Error).message).toContain('没有开始移交')
expect((err as Error).message).toContain('实施')
expect((err as Error).message).toContain('1.0KB')
}
})
})
describe('packHandoff', () => {
it('产出超过 maxBytes 就失败,不等到打完', async () => {
const staging = tempDir('handoff-pack-')
writeFileSync(path.join(staging, 'big.bin'), randomBytes(80 * 1024))
const out = path.join(tempDir('handoff-pack-out-'), 'bundle.tgz')
await expect(packHandoff(staging, out, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffArchiveTooLargeError)
})
})
-114
View File
@@ -1,114 +0,0 @@
import type { HandoffRoots, HandoffSource } from './handoffBundle'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive'
import { HANDOFF_SKIPPED_DIR, stageHandoffBundle } from './handoffBundle'
import { CLAUDE_SESSION_FIELDS } from './handoffManifest'
import { codexRolloutRelPathFor, findClaudeSessionFiles } from './sessionBundle'
const ROLE_LABEL = {
brainstorm: '头脑风暴',
implement: '实施',
test: '测试',
prototype: '原型',
review: '审查',
} as const
export interface HandoffSourceSize {
role: string
bytes: number
}
export class HandoffSourcesTooLargeError extends Error {
readonly items: HandoffSourceSize[]
readonly limitBytes: number
constructor(items: HandoffSourceSize[], limitBytes: number) {
super(formatHandoffTooLarge(items, limitBytes))
this.name = 'HandoffSourcesTooLargeError'
this.items = items
this.limitBytes = limitBytes
}
}
export function formatHandoffTooLarge(items: HandoffSourceSize[], limitBytes: number): string {
const parts = items.filter(item => item.bytes > 0).map(item => `${item.role} ${formatByteSize(item.bytes)}`)
const total = items.reduce((sum, item) => sum + item.bytes, 0)
return `会话太大,没有开始移交:${parts.join('、')},合计 ${formatByteSize(total)}。压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`
}
async function treeBytes(abs: string): Promise<number> {
const st = await fsp.lstat(abs)
if (st.isSymbolicLink())
return 0
if (!st.isDirectory())
return st.size
let total = 0
for (const name of await fsp.readdir(abs)) {
if (name === HANDOFF_SKIPPED_DIR)
continue
total += await treeBytes(path.join(abs, name))
}
return total
}
/** 统计即将打进会话包的文件。缺失的会话不出现。 */
export async function measureHandoffSources(src: HandoffSource, roots: HandoffRoots): Promise<HandoffSourceSize[]> {
const items: HandoffSourceSize[] = []
for (const { field, kind } of CLAUDE_SESSION_FIELDS) {
const id = src.sessions[field]
if (!id)
continue
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
if (!files)
continue
let bytes = await treeBytes(files.jsonl)
if (files.dir)
bytes += await treeBytes(files.dir)
items.push({ role: ROLE_LABEL[kind], bytes })
}
if (src.sessions.reviewSessionId) {
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
if (relPath) {
items.push({
role: ROLE_LABEL.review,
bytes: await treeBytes(path.join(roots.codexRoot, relPath)),
})
}
}
return items
}
/**
* 关终端、上传之前调用。
* 原始大小不超过上限时直接过(压缩只会更小,最多多出可忽略的包头)。
* 超过才真正压缩,产出一过上限就停。
*/
export async function ensureHandoffFits(
src: HandoffSource,
roots: HandoffRoots,
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
): Promise<HandoffSourceSize[]> {
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
const items = await measureHandoffSources(src, roots)
const total = items.reduce((sum, item) => sum + item.bytes, 0)
if (total <= maxBytes)
return items
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-'))
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-out-'))
try {
await stageHandoffBundle(src, staging, roots)
await packHandoff(staging, path.join(outDir, 'probe.tgz'), { maxBytes, onProgress: opts?.onProgress })
return items
}
catch (err) {
if (err instanceof HandoffArchiveTooLargeError)
throw new HandoffSourcesTooLargeError(items, maxBytes)
throw err
}
finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
}
+2
View File
@@ -10,6 +10,7 @@ import { initIdentity } from './auth/identity'
import { setProfilesDirOverride } from './cc/profiles'
import { registerCodeReader } from './codeReader'
import { logger } from './logging/logger'
import { CONFIGURE_HANDOFF_STORE_COMMAND, configureHandoffStore } from './oss/handoffStoreSetup'
import { KanbanWebviewPanel } from './panel/KanbanPanel'
import { offerPromptUpdates } from './settings/promptUpdate'
import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store'
@@ -105,6 +106,7 @@ export function activate(context: ExtensionContext): SuperpowersApi {
KanbanWebviewPanel.createOrShow(context)
KanbanWebviewPanel.requestEditAuth()
}),
commands.registerCommand(CONFIGURE_HANDOFF_STORE_COMMAND, () => configureHandoffStore(context)),
)
return { codeReader: registerCodeReader(context) }
-16
View File
@@ -777,22 +777,6 @@ export async function uploadIssueAttachment(opts: {
return (await res.json()) as GiteaAttachment
}
export async function getIssueAttachment(opts: {
host: string
token: string
owner: string
repo: string
index: number
attachmentId: number
}): Promise<GiteaAttachment> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
return (await res.json()) as GiteaAttachment
}
/**
* `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
+1 -1
View File
@@ -14,7 +14,7 @@ const STATE_KEYS = new Set([
'reviewSessionId',
'testSessionId',
'prototypeSessionId',
'handoffAttachmentId',
'handoffBundleKey',
'handoffFrom',
'handoffRequestFrom',
'handoffRequestError',
+7 -7
View File
@@ -98,7 +98,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color?: string
autoReview?: boolean
reviewPassed?: boolean
handoffAttachmentId?: string
handoffBundleKey?: string
handoffFrom?: string
handoffRequestFrom?: string
handoffRequestError?: string
@@ -150,7 +150,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color?: unknown
autoReview?: unknown
reviewPassed?: unknown
handoffAttachmentId?: unknown
handoffBundleKey?: unknown
handoffFrom?: unknown
handoffRequestFrom?: unknown
handoffRequestError?: unknown
@@ -210,8 +210,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
: undefined
const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined
const reviewPassed = typeof obj.reviewPassed === 'boolean' ? obj.reviewPassed : undefined
const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0
? obj.handoffAttachmentId
const handoffBundleKey = typeof obj.handoffBundleKey === 'string' && obj.handoffBundleKey.length > 0
? obj.handoffBundleKey
: undefined
const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
? obj.handoffFrom
@@ -246,7 +246,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color,
autoReview,
reviewPassed,
handoffAttachmentId,
handoffBundleKey,
handoffFrom,
handoffRequestFrom,
handoffRequestError,
@@ -385,7 +385,7 @@ async function buildIssue(opts: {
color,
autoReview,
reviewPassed,
handoffAttachmentId,
handoffBundleKey,
handoffFrom,
handoffRequestFrom,
handoffRequestError,
@@ -446,7 +446,7 @@ async function buildIssue(opts: {
...(color ? { color } : {}),
...(autoReview !== undefined ? { autoReview } : {}),
...(reviewPassed !== undefined ? { reviewPassed } : {}),
...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffBundleKey ? { handoffBundleKey } : {}),
...(handoffFrom ? { handoffFrom } : {}),
...(handoffRequestFrom ? { handoffRequestFrom } : {}),
...(handoffRequestError ? { handoffRequestError } : {}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
*/
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffBundleKey', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/**
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+2 -2
View File
@@ -137,8 +137,8 @@ export interface Issue {
assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
handoffAttachmentId?: string
/** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
handoffBundleKey?: string
/** 发起移交的 Gitea login。 */
handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */
+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})`)
}
+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 }
+18
View File
@@ -240,6 +240,13 @@ export interface Settings {
* 支持前导 `~`。保存后 listClaudeProfiles / getDefaultProfilePath 都从这里读。
*/
profilesDirectory: string
/**
* 移交存储(阿里云 OSS)的 endpoint,如 `oss-cn-beijing.aliyuncs.com`。空串 = 没配置,
* 移交和接管都会停下来提示去跑「配置移交存储」。AccessKey 在 SecretStorage 里。
*/
handoffOssEndpoint: string
/** 移交存储的 bucket 名。空串 = 没配置。 */
handoffOssBucket: string
}
export const SETTINGS_KEY = 'superpowers.settings'
@@ -313,6 +320,8 @@ function defaults(ctx: ExtensionContext): Settings {
codexModel: '',
codexReasoningEffort: '',
profilesDirectory: '',
handoffOssEndpoint: '',
handoffOssBucket: '',
}
}
@@ -444,6 +453,13 @@ export function getSettings(ctx: ExtensionContext): Settings {
const profilesDirectory = typeof stored.profilesDirectory === 'string'
? stored.profilesDirectory
: base.profilesDirectory
// 移交存储:'' 有意义(= 没配置),只认字符串类型。
const handoffOssEndpoint = typeof stored.handoffOssEndpoint === 'string'
? stored.handoffOssEndpoint.trim()
: base.handoffOssEndpoint
const handoffOssBucket = typeof stored.handoffOssBucket === 'string'
? stored.handoffOssBucket.trim()
: base.handoffOssBucket
return {
webhookPort,
@@ -479,6 +495,8 @@ export function getSettings(ctx: ExtensionContext): Settings {
codexModel,
codexReasoningEffort,
profilesDirectory,
handoffOssEndpoint,
handoffOssBucket,
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffBundleKey', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/**
* Parse a comment body into a state object, or null if it isn't a state