✨ 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
@@ -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)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ const STATE_KEYS = new Set([
|
||||
'reviewSessionId',
|
||||
'testSessionId',
|
||||
'prototypeSessionId',
|
||||
'handoffAttachmentId',
|
||||
'handoffBundleKey',
|
||||
'handoffFrom',
|
||||
'handoffRequestFrom',
|
||||
'handoffRequestError',
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
/** 谁在申请把这个审查工单移交给自己。 */
|
||||
|
||||
@@ -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})`)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 审查列里同事负责的工单,且当前没有别的申请。 */
|
||||
|
||||
@@ -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},不是你`)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user