✨ 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
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user