✨ feat(vscode): 工单移交的会话包改走阿里云 OSS、换 xz 压缩,两边进度一步步显示

- 会话包传到 OSS 的 spx-handoff/<owner>/<repo>/issue-<N>-<时间>.tar.xz,上传时对象设成私有;不再挂 Gitea 工单附件
- 新命令「配置移交存储」:endpoint、bucket 存扩展设置,AccessKey 存密钥存储;没配时移交 / 接管直接提示,什么都不改
- 共享 state 字段 handoffAttachmentId 改名 handoffBundleKey(含 spx CLI 与 schema)
- 打包换成 xz 1 级(tar -cJf),去掉 100MB 上限检查和先试压一遍的重复压缩
- 发送方提示打包 / 上传了多少 MB;接管方同一条提示依次显示下载、解包、建 worktree、装会话,完成前不消失
- 会话交接(单会话)仍走 Gitea 附件,Gitea 附件白名单没有 .xz,单独保留 gzip 打包
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent a33d3d2251
commit 499cfad7d7
39 files changed
+1039 -526

No files matched your search

+42 -30
View File
@@ -8,14 +8,17 @@
- `claude --resume` 同事的头脑风暴 / 实施 / 测试会话,`codex resume` 审查会话 - `claude --resume` 同事的头脑风暴 / 实施 / 测试会话,`codex resume` 审查会话
- 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作 - 之后所有既有流程(拖到完成、冲突解决、测试会话、恢复实施会话)照常工作
通道选 **Gitea 工单附件**:会话文件打成 tgz 挂在工单上,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。 通道选 **阿里云 OSS**:会话文件打成 tar.xz 传到 OSS 存储桶,移交状态记在共享 state JSON 里。不引入 Tailscale / ssh 之类点对点传输。
不经 Gitea 传:Gitea 在内网,外网经阿里云 ECS 用 frp 转发,出口约 3Mbps,实测只有 0.35~0.4MB/s。OSS 北京区实测上传 32MB 4.6 秒、下载 1 秒。
## 事实前提 ## 事实前提
- Claude Code ≥ 2.1.223:`claude --resume <id>` 在任意目录可用,会搜本机全部 `~/.claude/projects/*/`;jsonl 内部旧 `cwd` 不存在也能续。**同一 sid 出现在两个 project 目录 → 报 "No conversation found"**。 - Claude Code ≥ 2.1.223:`claude --resume <id>` 在任意目录可用,会搜本机全部 `~/.claude/projects/*/`;jsonl 内部旧 `cwd` 不存在也能续。**同一 sid 出现在两个 project 目录 → 报 "No conversation found"**。
- 一个 claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选目录 `<projectsDir>/<sid>/`(`subagents/`、`tool-results/`)。两者必须一起搬。 - 一个 claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选目录 `<projectsDir>/<sid>/`(`subagents/`、`tool-results/`)。两者必须一起搬。
- codex 会话 = `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,全局目录,与 worktree 无关。 - codex 会话 = `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`,全局目录,与 worktree 无关。
- Gitea 1.27:附件 `max_size=100MB`,允许 `.tgz`;`POST/GET/DELETE /repos/{owner}/{repo}/issues/{index}/assets[/{id}]`。 - 会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB 以内的重复,压不动(实例:原始 148MB,gzip 86MB);xz 1 级 32MB、5.4 秒,解包 1.6 秒。macOS 自带 bsdtar 内建 xz:`tar --options xz:compression-level=1 -cJf` 可用,不用另装软件。
- 目标存储桶是公共读的(正式服在用),会话包里有代码和对话:每个对象上传时必须带 `x-oss-object-acl: private`。不改存储桶本身的任何配置。
- 共享 state JSON 由 `vscode/schemas/state-json.schema.json` 约束(`additionalProperties: false`),spx 写入前校验;本机字段走 workspaceState(`issues/localState.ts`)。 - 共享 state JSON 由 `vscode/schemas/state-json.schema.json` 约束(`additionalProperties: false`),spx 写入前校验;本机字段走 workspaceState(`issues/localState.ts`)。
- webhook `issues.assigned` 事件已会让受影响机器重载该工单。 - webhook `issues.assigned` 事件已会让受影响机器重载该工单。
- 仅 Gitea 工单支持移交;YouTrack 工单不显示相关按钮。 - 仅 Gitea 工单支持移交;YouTrack 工单不显示相关按钮。
@@ -24,7 +27,8 @@
- **发送方**:当前持有 worktree / 会话的机器(同事)。 - **发送方**:当前持有 worktree / 会话的机器(同事)。
- **接管方**:目标 assignee 的机器(我)。 - **接管方**:目标 assignee 的机器(我)。
- **会话包**:`spx-handoff-issue-<N>.tgz`。 - **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。
- **移交存储**:放会话包的 OSS 存储桶,见下文「移交存储配置」。
## 会话包格式 ## 会话包格式
@@ -62,53 +66,60 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
| `handoffAttachmentId` | string(minLength 1) | 会话包在 Gitea 的附件 id;存在即「待接管」 | | `handoffBundleKey` | string(minLength 1) | 会话包在 OSS 上的对象 key;存在即「待接管」 |
| `handoffFrom` | string(minLength 1) | 发送方 login | | `handoffFrom` | string(minLength 1) | 发送方 login |
两个字段均为共享字段(不进 `LOCAL_STATE_FIELDS`),清除沿用 `''` 墓碑约定。四处字段清单同步:`schemas/state-json.schema.json`、`gitea/stateJson.ts` 与 `youtrack/stateComment.ts` 的 `KNOWN_STATE_FIELDS`、`cli/cmd/spx/main.go` 的 `knownStateFields`;`cli` 走 `make install` 重装(schema 由 Makefile `sync-schema` 拷贝)。 两个字段均为共享字段(不进 `LOCAL_STATE_FIELDS`),清除沿用 `''` 墓碑约定。四处字段清单同步:`schemas/state-json.schema.json`、`gitea/stateJson.ts` 与 `youtrack/stateComment.ts` 的 `KNOWN_STATE_FIELDS`、`cli/cmd/spx/main.go` 的 `knownStateFields`;`cli` 走 `make install` 重装(schema 由 Makefile `sync-schema` 拷贝)。
`Issue` 类型(`gitea/types.ts` 与 `webview-ui/src/types.ts` 镜像)新增 `handoffAttachmentId?`、`handoffFrom?`、`assignees: string[]`。`issueLoader.ts` 解析并透出这三个字段。`issues/update` 消息新增 `me?: string`(当前 Gitea login,token 无效时缺省)。 `Issue` 类型(`gitea/types.ts` 与 `webview-ui/src/types.ts` 镜像)新增 `handoffBundleKey?`、`handoffFrom?`、`assignees: string[]`。`issueLoader.ts` 解析并透出这三个字段。`issues/update` 消息新增 `me?: string`(当前 Gitea login,token 无效时缺省)。
## 移交存储配置
命令「Superpowers: 配置移交存储」(`superpowers.configureHandoffStore`)依次弹四个输入框:endpoint(如 `oss-cn-beijing.aliyuncs.com`)、bucket、AccessKey ID、AccessKey Secret(密码框)。框里预填已有值;四项都填完才保存,中途 Esc 什么都不改。endpoint、bucket 存扩展设置(`settings/store.ts` 的 `handoffOssEndpoint` / `handoffOssBucket`),两个 AccessKey 存 VS Code SecretStorage(`auth/secrets.ts`),整台机器共用一份。
四项缺一项就算没配置:移交、接管、同意移交申请都直接 toast 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因)。
## 发送方:移交 ## 发送方:移交
入口:工单 tab 头部「移交」按钮。显示条件:`source !== 'youtrack'` 且 `column !== 'done'` 且无 `handoffAttachmentId`。 入口:工单 tab 头部「移交」按钮。显示条件:`source !== 'youtrack'` 且 `column !== 'done'` 且无 `handoffBundleKey`。
点击弹 webview 内 `HandoffModal`:请求 `handoff/users`,扩展用 `GET /repos/{owner}/{repo}/assignees` 返回候选(排除我),单选后提交 `handoff/start { issueNumber, to }`。无需再弹原生确认框。 点击弹 webview 内 `HandoffModal`:请求 `handoff/users`,扩展用 `GET /repos/{owner}/{repo}/assignees` 返回候选(排除我),单选后提交 `handoff/start { issueNumber, to }`。无需再弹原生确认框。
扩展端 `handleHandoffStart(panel, issueNumber, to)` 顺序: 扩展端 `handleHandoffStart(panel, issueNumber, to)` 顺序:
1. 前置:workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。 1. 前置:移交存储已配置;workspaceRoot / `detectRepo` / token / `loginForToken` 得 `me`。读 `readIssueState`,取本机字段(四个 sid、三个 profile、`worktreePath`)与共享 `branch`。
2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin <branch>` 后 `HEAD` 必须等于 `origin/<branch>`。否则 toast「有未提交/未推送改动」并终止,什么都不改。 2. **worktree 守卫**(有 `worktreePath` 且目录存在时):`git status --porcelain` 必须为空;`git fetch origin <branch>` 后 `HEAD` 必须等于 `origin/<branch>`。否则 toast「有未提交/未推送改动」并终止,什么都不改。
3. **会话大小**(关终端之前):统计将打进包的 jsonl、会话目录和 codex rollout。原始总字节不超过 Gitea 附件上限 100MB 则通过。超过则先压缩,压缩产出一旦超过 100MB 就停,toast 写明哪个会话多大,不关终端、不上传、不改指派。正式打包再卡一次同一上限;那次失败发生在关终端之后,按打包失败处理。 3. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。
4. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 4. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `<sid>.jsonl`(多份取最新 mtime),连同 `<sid>/` 一起拷入 `claude/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar --options xz:compression-level=1 -cJf`。
5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `<sid>.jsonl`(多份取最新 mtime),连同 `<sid>/` 一起拷入 `claude/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/<relPath>`。找不到的会话不进清单,只 log。写 `handoff.json`,`tar -czf`。 5. 用阿里云官方 SDK `ali-oss` 的分片上传传到 OSS,带 `x-oss-object-acl: private`;中途失败把已传的分片清掉(abort)。
6. 若工单已有同名旧附件(重复移交)先删。`POST …/assets?name=spx-handoff-issue-N.tgz` 上传。 6. `mergeIssueState(issueNumber, { handoffBundleKey, handoffFrom: me })`。
7. `mergeIssueState(issueNumber, { handoffAttachmentId, handoffFrom: me })`。 7. `updateIssueAssignees(… [to])`。
8. `updateIssueAssignees(… [to])`。 8. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。
9. 本机清理(失败只 warn,不回滚——远端移交已生效):`pre-remove` 钩子 → `killProcessesUsingWorktree` → `removeWorktreeDir`;`cleanupFeatureBranch` **不传 remote/token**(只删本地分支,远端分支必须保留给接管方);本机字段全部写 `''` 墓碑。发送方磁盘上的会话文件保留。 9. `issue/patch { handoffBundleKey, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。
10. `issue/patch { handoffAttachmentId, handoffFrom, worktreePath: null, worktreeExists: false, …sid: null }` + 成功 toast。
进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、检查会话大小、压缩检查、关闭终端、收集会话、打包、上传、写入状态、指派、清理本机)。移交结束前不自动消失。 进度 toast 从核对分支开始,同一条提示随步骤改文案(核对分支、关闭终端、收集会话、`打包 x MB`、`上传 x MB / y MB`、写入状态、指派、清理本机)。打包时按包文件已写出的大小报,上传时每传完一片报一次。移交结束前不自动消失。
审查列里,负责人不是自己、也没有待接管包的 Gitea 工单,工单详情标题栏可以「申请移交」。同一张工单同时只有一条申请。同事收到提示,打开工单详情可以同意或拒绝。同意走上面的移交,对象就是申请人;申请人仍自己点接管。拒绝后申请消失,申请人看到被拒绝。申请人可以在回应前取消。同意后若移交失败,申请留着,两边都看到原因,修好可以再点同意。工单离开审查列时清掉未处理的申请。共享字段 `handoffRequestFrom` / `handoffRequestError`(拒绝时 error 为 `rejected`)。 审查列里,负责人不是自己、也没有待接管包的 Gitea 工单,工单详情标题栏可以「申请移交」。同一张工单同时只有一条申请。同事收到提示,打开工单详情可以同意或拒绝。同意走上面的移交,对象就是申请人;申请人仍自己点接管。拒绝后申请消失,申请人看到被拒绝。申请人可以在回应前取消。同意后若移交失败,申请留着,两边都看到原因,修好可以再点同意。工单离开审查列时清掉未处理的申请。共享字段 `handoffRequestFrom` / `handoffRequestError`(拒绝时 error 为 `rejected`)。
回滚:大小检查失败 → 什么都不改;打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删附件;指派失败 → 清回两个共享字段、删附件。结束 `toast/dismiss`。 回滚:打包/上传失败 → toast 终止(终端已关,无其他改动);写状态失败 → 删 OSS 对象;指派失败 → 清回两个共享字段、删 OSS 对象。结束 `toast/dismiss`。
## 接管方:接管 ## 接管方:接管
入口:工单 tab 头部「接管」按钮 + 看板卡片「待接管」角标。显示条件:`handoffAttachmentId` 存在且 `assignees` 含 `me`。 入口:工单 tab 头部「接管」按钮 + 看板卡片「待接管」角标。显示条件:`handoffBundleKey` 存在且 `assignees` 含 `me`。
扩展端 `handleHandoffAccept(panel, issueNumber)` 顺序: 扩展端 `handleHandoffAccept(panel, issueNumber)` 顺序:
1. 前置同上。读共享 state 取 `handoffAttachmentId`、`branch`、`pr`。 1. 前置同上。读共享 state 取 `handoffBundleKey`、`branch`、`pr`。
2. `GET …/assets/{id}` 取 `browser_download_url`,带 `Authorization: token` 下载到 scratch 临时目录,`tar -xzf`,解析并校验 `handoff.json`(`version === 1`、`issue === issueNumber`)。 2. 从 OSS 边下边写到 scratch 临时目录(文件名固定,不拿 key 拼本机路径;连续 60 秒收不到数据就放弃),`tar -xJf`,解析并校验 `handoff.json`(`version === 1`、`issue === issueNumber`)。
3. **重建 worktree**(`manifest.branch ?? state.branch` 存在时):`ensureWorktreeFromRemote({ workspaceRoot, worktreePath, branch })`——`git fetch origin <branch>`;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B <branch> <path> origin/<branch>`。模板路径用设置 `worktreeDirectory`,slug = `branch` 去掉 `feature/` 前缀。之后跑 `post-create` 钩子。分支不存在于远端 → toast 终止(附件保留,可重试)。 3. **重建 worktree**(`manifest.branch ?? state.branch` 存在时):`ensureWorktreeFromRemote({ workspaceRoot, worktreePath, branch })`——`git fetch origin <branch>`;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B <branch> <path> origin/<branch>`。模板路径用设置 `worktreeDirectory`,slug = `branch` 去掉 `feature/` 前缀。之后跑 `post-create` 钩子。分支不存在于远端 → toast 终止(会话包保留,可重试)。
4. **安装会话**:`brainstorm` 会话装到 `projectsDirFor(workspaceRoot)`,`implement` / `test` 装到 `projectsDirFor(worktreeAbs)`(无 worktree 时也装到 workspaceRoot)。安装前先删本机所有 `~/.claude/projects/*/<sid>.jsonl` 与 `<sid>/`(避免重复 sid),再拷 jsonl + 目录。codex 文件按 `relPath` 拷到 `~/.codex/sessions/` 下。 4. **安装会话**:`brainstorm` 会话装到 `projectsDirFor(workspaceRoot)`,`implement` / `test` 装到 `projectsDirFor(worktreeAbs)`(无 worktree 时也装到 workspaceRoot)。安装前先删本机所有 `~/.claude/projects/*/<sid>.jsonl` 与 `<sid>/`(避免重复 sid),再拷 jsonl + 目录。codex 文件按 `relPath` 拷到 `~/.codex/sessions/` 下。
5. 写本机字段:清单里的四个 sid、三个 profile 路径、`worktreePath`。 5. 写本机字段:清单里的四个 sid、三个 profile 路径、`worktreePath`。
6. `mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' })`;`DELETE …/assets/{id}`(失败只 warn)。 6. `mergeIssueState(issueNumber, { handoffBundleKey: '', handoffFrom: '' })`;删 OSS 对象(失败只 warn)。
7. `issue/patch`(sid、worktreePath、worktreeExists、两个 handoff 字段置 null)+ 成功 toast。 7. `issue/patch`(sid、worktreePath、worktreeExists、两个 handoff 字段置 null)+ 成功 toast。
幂等:3、4 可重复执行;6 之前失败附件仍在,按钮仍显示,用户可重试。 进度 toast 和发送方一样,同一条提示依次显示 核对指派、`下载 x MB / y MB`、解包、建 worktree、装会话、写入状态,完成前不自动消失,结束时收掉。
幂等:3、4 可重复执行;6 之前失败会话包仍在,按钮仍显示,用户可重试。
## 附带修正 ## 附带修正
@@ -119,25 +130,26 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
| 模块 | 职责 | 测试 | | 模块 | 职责 | 测试 |
|---|---|---| |---|---|---|
| `cc/sessionBundle.ts` | 纯 fs:`findClaudeSessionFiles(sid, projectsRoot)`、`installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)`(先删本机所有副本再拷)、`codexRolloutPathFor(sid, codexRoot)`、`installCodexSession(relPath, srcFile, codexRoot)` | 临时目录 vitest | | `cc/sessionBundle.ts` | 纯 fs:`findClaudeSessionFiles(sid, projectsRoot)`、`installClaudeSession(sid, srcDir, dstProjectsDir, projectsRoot)`(先删本机所有副本再拷)、`codexRolloutPathFor(sid, codexRoot)`、`installCodexSession(relPath, srcFile, codexRoot)` | 临时目录 vitest |
| `cc/handoffManifest.ts` | 清单类型、`buildHandoffManifest(...)`、`parseHandoffManifest(json)` | 纯函数 vitest | | `cc/handoffManifest.ts` | 清单类型、`buildHandoffManifest(...)`、`parseHandoffManifest(json)`、OSS 对象 key `handoffBundleKey(...)` | 纯函数 vitest |
| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile)` / `unpackHandoff(file, dstDir)`,`execFile('tar')` | 真 tar 往返 vitest | | `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级;另有会话交接(单会话,仍走 Gitea 附件,白名单没有 .xz)用的 gzip 版 `packSessionHandoff` / `unpackSessionHandoff` | 真 tar 往返 vitest |
| `git/worktreePath.ts` | 从 `panel/handlers/sessions.ts` 抽出 `resolveWorktreeDir`、`deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest | | `git/worktreePath.ts` | 从 `panel/handlers/sessions.ts` 抽出 `resolveWorktreeDir`、`deriveSlug`,新增 `slugFromBranch` | 纯函数 vitest |
| `git/worktree.ts` | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process | | `git/worktree.ts` | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` | `test/git/` mock child_process |
| `gitea/api.ts` | `listRepoAssignees`、`listIssueAttachments`、`uploadIssueAttachment`(FormData + Blob)、`getIssueAttachment`、`downloadAttachment`(arrayBuffer,不吞错)、`deleteIssueAttachment` | — | | `gitea/api.ts` | `listRepoAssignees` | — |
| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定 | vitest | | `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle` | 真机往返脚本(不进仓库) |
| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令 | — |
| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定、`transferProgressText` 进度文案 | vitest |
| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers` | — | | `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers` | — |
| webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — | | webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — |
消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像): 消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像):
- webview → 扩展:`handoff/users { issueNumber }`、`handoff/start { issueNumber, to }`、`handoff/accept { issueNumber }` - webview → 扩展:`handoff/users { issueNumber }`、`handoff/start { issueNumber, to }`、`handoff/accept { issueNumber }`
- 扩展 → webview:`handoff/users-result { issueNumber, users: string[] }`;`issues/update` 增 `me`;`issue/patch` 增 `handoffAttachmentId`、`handoffFrom`、四个 sid(可 null) - 扩展 → webview:`handoff/users-result { issueNumber, users: string[] }`;`issues/update` 增 `me`;`issue/patch` 增 `handoffBundleKey`、`handoffFrom`、四个 sid(可 null)
## 验证 ## 验证
- `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。 - `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。
- 实施第一步先用 curl 验证 `Authorization: token` 能下载 `browser_download_url`(Gitea 私有仓库附件走 web 路由);不通则改走 `GET /attachments/{uuid}` - OSS 真机往返(临时脚本,不进仓库,对象放 `spx-handoff/_selftest/` 下):上传 → 匿名 `curl` 必须 403 → 下载 → 解包一致 → 删除 → 匿名 `curl` 404、带密钥下载报 NoSuchKey。分片上传(≥100KB)和单次上传(<100KB)两条路都测私有权限。
等价路径,写进 `downloadAttachment`。
- 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。 - 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。
## 不做 ## 不做
+2 -2
View File
@@ -23,7 +23,7 @@
4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。 4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。
5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。 5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。
6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。 6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。
7. **移交**:前置条件:两台机器的 spx 都已 `cd cli && make install` 到含 `handoffAttachmentId` 的版本,否则移交期间旧 spx 的 state 写入会被 schema 拒绝。工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 7. **移交**:前置条件:两台机器的 spx 都已 `cd cli && make install` 到含 `handoffBundleKey` 的版本,否则移交期间旧 spx 的 state 写入会被 schema 拒绝;两台机器都在命令面板跑过「Superpowers: 配置移交存储」(阿里云 OSS 的 endpoint、bucket、AccessKey ID / Secret,AccessKey 存在 VS Code 的密钥存储里),没配的话移交和接管都会直接提示去配。工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 xz 压缩的 `issue-N-<时间>.tar.xz`,以私有权限传到 OSS 的 `spx-handoff/<owner>/<repo>/` 下,写 `handoffBundleKey` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 OSS 下载会话包、从 origin 重建 worktree(`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删掉 OSS 上的会话包。两边的进度提示会一步步显示打包 / 上传 / 下载了多少 MB。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。
8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/<devBranch>` 切当天的 `<releaseBranchPrefix>YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交(按文件改动指纹比对,另认 `cherry-pick -x` 留下的「cherry picked from commit」尾注,所以解过冲突、内容已变的复制品也算已摘;手工 pick 请带 `-x`),多选后在发布 worktree(`worktreeDirectory` 模板,目录名为分支名把 `/` 换成 `-`)里按序 `cherry-pick -x` 并推远端,成功后删 worktree。中途冲突则保留冲突现场、远端不变,开 `<发布分支>-冲突解决` 会话让 claude 解冲突、`cherry-pick --continue`、按序摘完剩余提交再推远端;上次冲突还没处理完时再摘会被拒绝。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build <jenkinsJob> -p REF=<分支> -p REMOTE_HOST=<jenkinsProdHost>`。 8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/<devBranch>` 切当天的 `<releaseBranchPrefix>YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交(按文件改动指纹比对,另认 `cherry-pick -x` 留下的「cherry picked from commit」尾注,所以解过冲突、内容已变的复制品也算已摘;手工 pick 请带 `-x`),多选后在发布 worktree(`worktreeDirectory` 模板,目录名为分支名把 `/` 换成 `-`)里按序 `cherry-pick -x` 并推远端,成功后删 worktree。中途冲突则保留冲突现场、远端不变,开 `<发布分支>-冲突解决` 会话让 claude 解冲突、`cherry-pick --continue`、按序摘完剩余提交再推远端;上次冲突还没处理完时再摘会被拒绝。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build <jenkinsJob> -p REF=<分支> -p REMOTE_HOST=<jenkinsProdHost>`。
其他看板规则: 其他看板规则:
@@ -165,7 +165,7 @@ prompts 在 `prompts/*.md`,随 VSIX 一起安装,改完即生效,也可在
| `implementStatus` | `running` / `done` / `failed` | | `implementStatus` | `running` / `done` / `failed` |
| `color` | 终端 tab 颜色 | | `color` | 终端 tab 颜色 |
| `autoReview` | 工单级自动审查开关 | | `autoReview` | 工单级自动审查开关 |
| `handoffAttachmentId` / `handoffFrom` | 移交中:会话包附件 id 与发起人;接管后清空 | | `handoffBundleKey` / `handoffFrom` | 移交中:会话包在 OSS 上的对象 key 与发起人;接管后清空 |
| `sessionId` / `implementSessionId` / `reviewSessionId` / `testSessionId` | 各会话 id | | `sessionId` / `implementSessionId` / `reviewSessionId` / `testSessionId` | 各会话 id |
| `profilePath` / `brainstormProfilePath` / `testProfilePath` | 各会话 profile | | `profilePath` / `brainstormProfilePath` / `testProfilePath` | 各会话 profile |
| `worktreePath` / `prDiffFile` | 本机路径 | | `worktreePath` / `prDiffFile` | 本机路径 |
+1 -1
View File
@@ -426,7 +426,7 @@ var knownStateFields = map[string]struct{}{
"color": {}, "color": {},
"autoReview": {}, "autoReview": {},
"reviewPassed": {}, "reviewPassed": {},
"handoffAttachmentId": {}, "handoffBundleKey": {},
"handoffFrom": {}, "handoffFrom": {},
"handoffRequestFrom": {}, "handoffRequestFrom": {},
"handoffRequestError": {}, "handoffRequestError": {},
+2 -2
View File
@@ -122,9 +122,9 @@
"type": "boolean", "type": "boolean",
"description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。" "description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。"
}, },
"handoffAttachmentId": { "handoffBundleKey": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" "description": "移交会话包在阿里云 OSS 上的对象 key(spx-handoff/<owner>/<repo>/issue-<N>-<时间>.tar.xz);存在即「待接管」,接管完成后清空;清空时写空串。"
}, },
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
+2 -1
View File
@@ -14,7 +14,8 @@ const options = {
entryPoints: ['src/extension.ts'], entryPoints: ['src/extension.ts'],
bundle: true, bundle: true,
outfile: 'dist/index.cjs', outfile: 'dist/index.cjs',
external: ['vscode'], // proxy-agent:ali-oss 底下的 urllib 只在显式开代理时才去 require 它,我们不开,也没装
external: ['vscode', 'proxy-agent'],
format: 'cjs', format: 'cjs',
platform: 'node', platform: 'node',
target: 'node20', target: 'node20',
+8
View File
@@ -33,6 +33,7 @@
"onView:superpowers.kanban", "onView:superpowers.kanban",
"onCommand:superpowers.openKanban", "onCommand:superpowers.openKanban",
"onCommand:superpowers.setGiteaToken", "onCommand:superpowers.setGiteaToken",
"onCommand:superpowers.configureHandoffStore",
"onUri", "onUri",
"onView:superpowers.codeReader.files", "onView:superpowers.codeReader.files",
"onCustomEditor:superpowers.codeReader.preview", "onCustomEditor:superpowers.codeReader.preview",
@@ -107,6 +108,11 @@
"title": "Set Gitea Token", "title": "Set Gitea Token",
"category": "Superpowers" "category": "Superpowers"
}, },
{
"command": "superpowers.configureHandoffStore",
"title": "配置移交存储",
"category": "Superpowers"
},
{ {
"command": "superpowers.codeReader.pickReviewIssue", "command": "superpowers.codeReader.pickReviewIssue",
"title": "选工单", "title": "选工单",
@@ -515,6 +521,7 @@
}, },
"dependencies": { "dependencies": {
"@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/tree-sitter-wasm": "^0.3.1",
"ali-oss": "^6.23.0",
"diff": "^9.0.0", "diff": "^9.0.0",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"ignore": "^7.0.10", "ignore": "^7.0.10",
@@ -523,6 +530,7 @@
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^6.7.3", "@antfu/eslint-config": "^6.7.3",
"@types/ali-oss": "^6.23.3",
"@types/diff": "^8.0.0", "@types/diff": "^8.0.0",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/vscode": "^1.97.0", "@types/vscode": "^1.97.0",
+441 -7
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -122,9 +122,9 @@
"type": "boolean", "type": "boolean",
"description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。" "description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。"
}, },
"handoffAttachmentId": { "handoffBundleKey": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" "description": "移交会话包在阿里云 OSS 上的对象 key(spx-handoff/<owner>/<repo>/issue-<N>-<时间>.tar.xz);存在即「待接管」,接管完成后清空;清空时写空串。"
}, },
"handoffFrom": { "handoffFrom": {
"type": "string", "type": "string",
+16
View File
@@ -43,3 +43,19 @@ export async function setYouTrackToken(ctx: ExtensionContext, host: string, toke
export async function deleteYouTrackToken(ctx: ExtensionContext, host: string): Promise<void> { export async function deleteYouTrackToken(ctx: ExtensionContext, host: string): Promise<void> {
await ctx.secrets.delete(youtrackTokenKey(host)) await ctx.secrets.delete(youtrackTokenKey(host))
} }
/** 移交存储(阿里云 OSS)的 AccessKey,整台机器共用一份,不分仓库。 */
const HANDOFF_OSS_KEY_ID = 'handoff-oss:access-key-id'
const HANDOFF_OSS_KEY_SECRET = 'handoff-oss:access-key-secret'
export async function getHandoffOssKeys(ctx: ExtensionContext): Promise<{ accessKeyId?: string, accessKeySecret?: string }> {
return {
accessKeyId: await ctx.secrets.get(HANDOFF_OSS_KEY_ID),
accessKeySecret: await ctx.secrets.get(HANDOFF_OSS_KEY_SECRET),
}
}
export async function setHandoffOssKeys(ctx: ExtensionContext, keys: { accessKeyId: string, accessKeySecret: string }): Promise<void> {
await ctx.secrets.store(HANDOFF_OSS_KEY_ID, keys.accessKeyId)
await ctx.secrets.store(HANDOFF_OSS_KEY_SECRET, keys.accessKeySecret)
}
+87
View File
@@ -0,0 +1,87 @@
import { mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { formatByteSize, packHandoff, packSessionHandoff, unpackHandoff, unpackSessionHandoff } from './handoffArchive'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
function headBytes(file: string, n: number): number[] {
const fd = openSync(file, 'r')
const buf = new Uint8Array(n)
readSync(fd, buf, 0, n, 0)
return [...buf]
}
/** 一份像会话的暂存目录:清单 + 一条重复很多的 jsonl + 子目录里的文件。 */
function staging(): string {
const dir = tempDir('handoff-archive-staging-')
mkdirSync(path.join(dir, 'claude', 'sid', 'tool-results'), { recursive: true })
writeFileSync(path.join(dir, 'handoff.json'), '{"version":1}\n')
writeFileSync(path.join(dir, 'claude', 'sid.jsonl'), '{"type":"user","message":"重复的对话内容"}\n'.repeat(20_000))
writeFileSync(path.join(dir, 'claude', 'sid', 'tool-results', 'out.txt'), 'tool\n')
return dir
}
describe('formatByteSize', () => {
it('10MB 以上取整,1MB~10MB 留一位小数,再往下换 KB / B', () => {
expect(formatByteSize(100 * 1024 * 1024)).toBe('100MB')
expect(formatByteSize(1.5 * 1024 * 1024)).toBe('1.5MB')
expect(formatByteSize(512 * 1024)).toBe('512KB')
expect(formatByteSize(300)).toBe('300B')
})
})
describe('packHandoff / unpackHandoff', () => {
it('打出 xz 包,解开后内容一致', async () => {
const src = staging()
const archive = path.join(tempDir('handoff-archive-out-'), 'issue-1-20261008T000000Z.tar.xz')
await packHandoff(src, archive)
// xz 文件头:FD 37 7A 58 5A 00
expect(headBytes(archive, 6)).toEqual([0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00])
expect(statSync(archive).size).toBeLessThan(statSync(path.join(src, 'claude', 'sid.jsonl')).size / 20)
const out = tempDir('handoff-archive-unpacked-')
await unpackHandoff(archive, out)
for (const rel of ['handoff.json', 'claude/sid.jsonl', 'claude/sid/tool-results/out.txt'])
expect(readFileSync(path.join(out, rel), 'utf8')).toBe(readFileSync(path.join(src, rel), 'utf8'))
})
it('最后一次进度就是包的最终大小', async () => {
const archive = path.join(tempDir('handoff-archive-out-'), 'bundle.tar.xz')
const reported: number[] = []
await packHandoff(staging(), archive, { onProgress: bytes => reported.push(bytes) })
expect(reported.at(-1)).toBe(statSync(archive).size)
})
it('打包失败带上 tar 的报错', async () => {
const archive = path.join(tempDir('handoff-archive-out-'), 'bundle.tar.xz')
await expect(packHandoff(path.join(tempDir('handoff-archive-missing-'), 'nope'), archive)).rejects.toThrow(/tar 打包失败/)
})
})
describe('packSessionHandoff / unpackSessionHandoff', () => {
it('打出 gzip 包,解开后内容一致', async () => {
const src = staging()
const archive = path.join(tempDir('session-archive-out-'), 'bundle.tgz')
await packSessionHandoff(src, archive)
expect(headBytes(archive, 2)).toEqual([0x1F, 0x8B])
const out = tempDir('session-archive-unpacked-')
await unpackSessionHandoff(archive, out)
expect(readFileSync(path.join(out, 'claude', 'sid.jsonl'), 'utf8')).toBe(readFileSync(path.join(src, 'claude', 'sid.jsonl'), 'utf8'))
})
})
+44 -68
View File
@@ -2,9 +2,6 @@ import { execFile, spawn } from 'node:child_process'
import { promises as fsp, statSync } from 'node:fs' import { promises as fsp, statSync } from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
/** Gitea 工单附件上限。压缩后的 tgz 超过这个值,服务器会拒收。 */
export const HANDOFF_ATTACHMENT_MAX_BYTES = 100 * 1024 * 1024
export function formatByteSize(bytes: number): string { export function formatByteSize(bytes: number): string {
const mb = bytes / (1024 * 1024) const mb = bytes / (1024 * 1024)
if (mb >= 10) if (mb >= 10)
@@ -19,18 +16,6 @@ export function formatByteSize(bytes: number): string {
return `${bytes}B` 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> { function tarUnpack(args: string[]): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => { execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
@@ -43,74 +28,65 @@ function tarUnpack(args: string[]): Promise<void> {
}) })
} }
export async function packHandoff( /** 跑一次打包;给了 `onProgress` 就每 200ms 报一次包文件已写出的字节数,结束时再报一次最终大小。 */
stagingDir: string, async function tarPack(args: string[], outFile: string, onProgress?: (packedBytes: number) => void): Promise<void> {
outFile: string,
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
): Promise<void> {
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
await fsp.mkdir(path.dirname(outFile), { recursive: true }) await fsp.mkdir(path.dirname(outFile), { recursive: true })
const reportSize = (): void => {
try {
onProgress?.(statSync(outFile).size)
}
catch {
// 包还没写出来
}
}
await new Promise<void>((resolve, reject) => { 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() const decode = new TextDecoder()
let stderr = '' let stderr = ''
let settled = false
let poll: ReturnType<typeof setInterval> | undefined
child.stderr?.on('data', (chunk: Uint8Array) => { child.stderr?.on('data', (chunk: Uint8Array) => {
stderr += decode.decode(chunk, { stream: true }) stderr += decode.decode(chunk, { stream: true })
}) })
const finish = (err?: Error): void => { const poll = onProgress ? setInterval(reportSize, 200) : undefined
if (settled) child.on('error', (err) => {
return clearInterval(poll)
settled = true reject(err)
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))
child.on('close', (code) => { child.on('close', (code) => {
if (settled) clearInterval(poll)
return if (code !== 0) {
let size = 0 reject(new Error(`tar 打包失败: ${stderr.trim() || `exit ${code}`}`))
try {
size = statSync(outFile).size
}
catch {
// 包还没写出来
}
opts?.onProgress?.(size)
if (size > maxBytes) {
finish(new HandoffArchiveTooLargeError(size, maxBytes))
return return
} }
if (code === 0) reportSize()
finish() resolve()
else
finish(new Error(`tar -czf 失败: ${stderr.trim() || `exit ${code}`}`))
}) })
}) })
} }
/**
* 工单移交的会话包: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> { 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 fsp.mkdir(dstDir, { recursive: true })
await tarUnpack(['-xzf', archive, '-C', dstDir]) await tarUnpack(['-xzf', archive, '-C', dstDir])
} }
+1 -1
View File
@@ -76,7 +76,7 @@ describe('pack → unpack → install', () => {
sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW }, sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW },
profiles: {}, profiles: {},
}, staging, roots) }, 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) await packHandoff(staging, archive)
expect(existsSync(archive)).toBe(true) expect(existsSync(archive)).toBe(true)
+11 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest' import { handoffBundleKey, parseHandoffManifest } from './handoffManifest'
const UUID = '11111111-1111-4111-8111-111111111111' const UUID = '11111111-1111-4111-8111-111111111111'
@@ -15,9 +15,16 @@ const good = {
codex: [], codex: [],
} }
describe('handoffAttachmentName', () => { describe('handoffBundleKey', () => {
it('固定命名', () => { it('按仓库和工单分目录,带 UTC 时间,后缀 .tar.xz', () => {
expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz') const at = new Date('2026-10-08T01:02:03.456Z')
expect(handoffBundleKey({ owner: 'HKJX', repo: 'luffy-agent-platform', issueNumber: 479, at }))
.toBe('spx-handoff/HKJX/luffy-agent-platform/issue-479-20261008T010203Z.tar.xz')
})
it('同一工单不同时间移交,key 不同', () => {
const a = handoffBundleKey({ owner: 'o', repo: 'r', issueNumber: 1, at: new Date('2026-10-08T01:02:03Z') })
const b = handoffBundleKey({ owner: 'o', repo: 'r', issueNumber: 1, at: new Date('2026-10-08T01:02:04Z') })
expect(a).not.toBe(b)
}) })
}) })
+7 -2
View File
@@ -37,8 +37,13 @@ export interface HandoffManifest {
export const HANDOFF_MANIFEST_FILE = 'handoff.json' export 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']) const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test', 'prototype'])
-112
View File
@@ -1,112 +0,0 @@
import { Buffer } from 'node:buffer'
import { randomBytes } from 'node:crypto'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive'
import { ensureHandoffFits, HandoffSourcesTooLargeError, measureHandoffSources } from './handoffSize'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(prefix: string): string {
const dir = mkdtempSync(path.join(os.tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
function fixture(implBytes: Buffer): { projectsRoot: string, codexRoot: string } {
const projectsRoot = tempDir('handoff-size-projects-')
const codexRoot = tempDir('handoff-size-codex-')
const proj = path.join(projectsRoot, '-Users-chw-proj')
const wt = path.join(projectsRoot, '-Users-chw-wt-x')
mkdirSync(proj, { recursive: true })
mkdirSync(path.join(wt, IMPL, 'subagents'), { recursive: true })
writeFileSync(path.join(proj, `${BRAIN}.jsonl`), 'brain\n')
writeFileSync(path.join(wt, `${IMPL}.jsonl`), implBytes)
writeFileSync(path.join(wt, IMPL, 'subagents', 'agent-1.jsonl'), '{}\n')
const day = path.join(codexRoot, '2026', '08', '26')
mkdirSync(day, { recursive: true })
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`), 'review\n')
return { projectsRoot, codexRoot }
}
function source(roots: { projectsRoot: string, codexRoot: string }) {
return {
src: {
issueNumber: 448,
from: 'chw',
sessions: {
sessionId: BRAIN,
implementSessionId: IMPL,
testSessionId: 'missing-0000-4000-8000-000000000000',
reviewSessionId: REVIEW,
},
profiles: {},
},
roots,
}
}
describe('formatByteSize', () => {
it('100MB 上限按整数 MB 显示', () => {
expect(formatByteSize(HANDOFF_ATTACHMENT_MAX_BYTES)).toBe('100MB')
})
})
describe('measureHandoffSources', () => {
it('jsonl、会话目录、codex 加在一起,缺失的会话不出现', async () => {
const roots = fixture(Buffer.from('impl\n'))
const { src } = source(roots)
const items = await measureHandoffSources(src, roots)
expect(items).toEqual([
{ role: '头脑风暴', bytes: Buffer.byteLength('brain\n') },
{ role: '实施', bytes: Buffer.byteLength('impl\n') },
{ role: '审查', bytes: Buffer.byteLength('review\n') },
])
})
})
describe('ensureHandoffFits', () => {
it('原始大小不超过上限时直接通过', async () => {
const roots = fixture(Buffer.from('impl\n'))
const { src } = source(roots)
const items = await ensureHandoffFits(src, roots, { maxBytes: 1024 * 1024 })
expect(items.map(item => item.role)).toEqual(['头脑风暴', '实施', '审查'])
})
it('压缩后超过上限就停,并说明还没开始移交', async () => {
const roots = fixture(randomBytes(80 * 1024))
const { src } = source(roots)
await expect(ensureHandoffFits(src, roots, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffSourcesTooLargeError)
try {
await ensureHandoffFits(src, roots, { maxBytes: 1024 })
}
catch (err) {
expect(err).toBeInstanceOf(HandoffSourcesTooLargeError)
expect((err as Error).message).toContain('没有开始移交')
expect((err as Error).message).toContain('实施')
expect((err as Error).message).toContain('1.0KB')
}
})
})
describe('packHandoff', () => {
it('产出超过 maxBytes 就失败,不等到打完', async () => {
const staging = tempDir('handoff-pack-')
writeFileSync(path.join(staging, 'big.bin'), randomBytes(80 * 1024))
const out = path.join(tempDir('handoff-pack-out-'), 'bundle.tgz')
await expect(packHandoff(staging, out, { maxBytes: 1024 })).rejects.toBeInstanceOf(HandoffArchiveTooLargeError)
})
})
-114
View File
@@ -1,114 +0,0 @@
import type { HandoffRoots, HandoffSource } from './handoffBundle'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { formatByteSize, HANDOFF_ATTACHMENT_MAX_BYTES, HandoffArchiveTooLargeError, packHandoff } from './handoffArchive'
import { HANDOFF_SKIPPED_DIR, stageHandoffBundle } from './handoffBundle'
import { CLAUDE_SESSION_FIELDS } from './handoffManifest'
import { codexRolloutRelPathFor, findClaudeSessionFiles } from './sessionBundle'
const ROLE_LABEL = {
brainstorm: '头脑风暴',
implement: '实施',
test: '测试',
prototype: '原型',
review: '审查',
} as const
export interface HandoffSourceSize {
role: string
bytes: number
}
export class HandoffSourcesTooLargeError extends Error {
readonly items: HandoffSourceSize[]
readonly limitBytes: number
constructor(items: HandoffSourceSize[], limitBytes: number) {
super(formatHandoffTooLarge(items, limitBytes))
this.name = 'HandoffSourcesTooLargeError'
this.items = items
this.limitBytes = limitBytes
}
}
export function formatHandoffTooLarge(items: HandoffSourceSize[], limitBytes: number): string {
const parts = items.filter(item => item.bytes > 0).map(item => `${item.role} ${formatByteSize(item.bytes)}`)
const total = items.reduce((sum, item) => sum + item.bytes, 0)
return `会话太大,没有开始移交:${parts.join('、')},合计 ${formatByteSize(total)}。压缩后超过 Gitea 附件上限 ${formatByteSize(limitBytes)}`
}
async function treeBytes(abs: string): Promise<number> {
const st = await fsp.lstat(abs)
if (st.isSymbolicLink())
return 0
if (!st.isDirectory())
return st.size
let total = 0
for (const name of await fsp.readdir(abs)) {
if (name === HANDOFF_SKIPPED_DIR)
continue
total += await treeBytes(path.join(abs, name))
}
return total
}
/** 统计即将打进会话包的文件。缺失的会话不出现。 */
export async function measureHandoffSources(src: HandoffSource, roots: HandoffRoots): Promise<HandoffSourceSize[]> {
const items: HandoffSourceSize[] = []
for (const { field, kind } of CLAUDE_SESSION_FIELDS) {
const id = src.sessions[field]
if (!id)
continue
const files = await findClaudeSessionFiles(id, roots.projectsRoot)
if (!files)
continue
let bytes = await treeBytes(files.jsonl)
if (files.dir)
bytes += await treeBytes(files.dir)
items.push({ role: ROLE_LABEL[kind], bytes })
}
if (src.sessions.reviewSessionId) {
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
if (relPath) {
items.push({
role: ROLE_LABEL.review,
bytes: await treeBytes(path.join(roots.codexRoot, relPath)),
})
}
}
return items
}
/**
* 关终端、上传之前调用。
* 原始大小不超过上限时直接过(压缩只会更小,最多多出可忽略的包头)。
* 超过才真正压缩,产出一过上限就停。
*/
export async function ensureHandoffFits(
src: HandoffSource,
roots: HandoffRoots,
opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void },
): Promise<HandoffSourceSize[]> {
const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES
const items = await measureHandoffSources(src, roots)
const total = items.reduce((sum, item) => sum + item.bytes, 0)
if (total <= maxBytes)
return items
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-'))
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'spx-handoff-size-out-'))
try {
await stageHandoffBundle(src, staging, roots)
await packHandoff(staging, path.join(outDir, 'probe.tgz'), { maxBytes, onProgress: opts?.onProgress })
return items
}
catch (err) {
if (err instanceof HandoffArchiveTooLargeError)
throw new HandoffSourcesTooLargeError(items, maxBytes)
throw err
}
finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
}
}
+2
View File
@@ -10,6 +10,7 @@ import { initIdentity } from './auth/identity'
import { setProfilesDirOverride } from './cc/profiles' import { setProfilesDirOverride } from './cc/profiles'
import { registerCodeReader } from './codeReader' import { registerCodeReader } from './codeReader'
import { logger } from './logging/logger' import { logger } from './logging/logger'
import { CONFIGURE_HANDOFF_STORE_COMMAND, configureHandoffStore } from './oss/handoffStoreSetup'
import { KanbanWebviewPanel } from './panel/KanbanPanel' import { KanbanWebviewPanel } from './panel/KanbanPanel'
import { offerPromptUpdates } from './settings/promptUpdate' import { offerPromptUpdates } from './settings/promptUpdate'
import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store' import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store'
@@ -105,6 +106,7 @@ export function activate(context: ExtensionContext): SuperpowersApi {
KanbanWebviewPanel.createOrShow(context) KanbanWebviewPanel.createOrShow(context)
KanbanWebviewPanel.requestEditAuth() KanbanWebviewPanel.requestEditAuth()
}), }),
commands.registerCommand(CONFIGURE_HANDOFF_STORE_COMMAND, () => configureHandoffStore(context)),
) )
return { codeReader: registerCodeReader(context) } return { codeReader: registerCodeReader(context) }
-16
View File
@@ -777,22 +777,6 @@ export async function uploadIssueAttachment(opts: {
return (await res.json()) as GiteaAttachment 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 * `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML, * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
+1 -1
View File
@@ -14,7 +14,7 @@ const STATE_KEYS = new Set([
'reviewSessionId', 'reviewSessionId',
'testSessionId', 'testSessionId',
'prototypeSessionId', 'prototypeSessionId',
'handoffAttachmentId', 'handoffBundleKey',
'handoffFrom', 'handoffFrom',
'handoffRequestFrom', 'handoffRequestFrom',
'handoffRequestError', 'handoffRequestError',
+7 -7
View File
@@ -98,7 +98,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color?: string color?: string
autoReview?: boolean autoReview?: boolean
reviewPassed?: boolean reviewPassed?: boolean
handoffAttachmentId?: string handoffBundleKey?: string
handoffFrom?: string handoffFrom?: string
handoffRequestFrom?: string handoffRequestFrom?: string
handoffRequestError?: string handoffRequestError?: string
@@ -150,7 +150,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color?: unknown color?: unknown
autoReview?: unknown autoReview?: unknown
reviewPassed?: unknown reviewPassed?: unknown
handoffAttachmentId?: unknown handoffBundleKey?: unknown
handoffFrom?: unknown handoffFrom?: unknown
handoffRequestFrom?: unknown handoffRequestFrom?: unknown
handoffRequestError?: unknown handoffRequestError?: unknown
@@ -210,8 +210,8 @@ function parseColumnFromComments(comments: GiteaComment[]): {
: undefined : undefined
const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined
const reviewPassed = typeof obj.reviewPassed === 'boolean' ? obj.reviewPassed : undefined const reviewPassed = typeof obj.reviewPassed === 'boolean' ? obj.reviewPassed : undefined
const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0 const handoffBundleKey = typeof obj.handoffBundleKey === 'string' && obj.handoffBundleKey.length > 0
? obj.handoffAttachmentId ? obj.handoffBundleKey
: undefined : undefined
const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0 const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
? obj.handoffFrom ? obj.handoffFrom
@@ -246,7 +246,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
color, color,
autoReview, autoReview,
reviewPassed, reviewPassed,
handoffAttachmentId, handoffBundleKey,
handoffFrom, handoffFrom,
handoffRequestFrom, handoffRequestFrom,
handoffRequestError, handoffRequestError,
@@ -385,7 +385,7 @@ async function buildIssue(opts: {
color, color,
autoReview, autoReview,
reviewPassed, reviewPassed,
handoffAttachmentId, handoffBundleKey,
handoffFrom, handoffFrom,
handoffRequestFrom, handoffRequestFrom,
handoffRequestError, handoffRequestError,
@@ -446,7 +446,7 @@ async function buildIssue(opts: {
...(color ? { color } : {}), ...(color ? { color } : {}),
...(autoReview !== undefined ? { autoReview } : {}), ...(autoReview !== undefined ? { autoReview } : {}),
...(reviewPassed !== undefined ? { reviewPassed } : {}), ...(reviewPassed !== undefined ? { reviewPassed } : {}),
...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffBundleKey ? { handoffBundleKey } : {}),
...(handoffFrom ? { handoffFrom } : {}), ...(handoffFrom ? { handoffFrom } : {}),
...(handoffRequestFrom ? { handoffRequestFrom } : {}), ...(handoffRequestFrom ? { handoffRequestFrom } : {}),
...(handoffRequestError ? { handoffRequestError } : {}), ...(handoffRequestError ? { handoffRequestError } : {}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 * 扫描时会被跳过,避免它们插队后下一次 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。 * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+2 -2
View File
@@ -137,8 +137,8 @@ export interface Issue {
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */ /** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ /** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
handoffAttachmentId?: string handoffBundleKey?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */
handoffFrom?: string handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */ /** 谁在申请把这个审查工单移交给自己。 */
+112
View File
@@ -0,0 +1,112 @@
/**
* 工单移交的会话包放在阿里云 OSS 上:发送方上传,接管方下载后删掉。
*
* 这里只管传文件,不碰 VS Code;配置从哪来见 `src/oss/handoffStoreSetup.ts`。
*/
import type { Readable } from 'node:stream'
import { createWriteStream, promises as fsp } from 'node:fs'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import OSS from 'ali-oss'
/** 移交会话包所在的存储桶和访问它的 AccessKey,四项都有值才算配好。 */
export interface HandoffStore {
/** 如 `oss-cn-beijing.aliyuncs.com` */
endpoint: string
bucket: string
accessKeyId: string
accessKeySecret: string
}
/** 下载时连续这么久收不到数据就放弃。SDK 拿到响应头以后就不再计时,网断了会一直挂着。 */
const DOWNLOAD_IDLE_MS = 60_000
function client(store: HandoffStore): OSS {
return new OSS({
endpoint: store.endpoint,
bucket: store.bucket,
accessKeyId: store.accessKeyId,
accessKeySecret: store.accessKeySecret,
secure: true,
})
}
/**
* 分片上传 `file` 到 `key`,对象权限设成私有:存储桶本身是公共读的,会话包里有代码和
* 对话,不能让人不带密钥就下载到。中途失败会把已传的分片清掉。`onProgress` 每传完一片
* 报一次已传字节数和总字节数。
*/
export async function uploadHandoffBundle(
store: HandoffStore,
key: string,
file: string,
onProgress?: (sentBytes: number, totalBytes: number) => void,
): Promise<void> {
const total = (await fsp.stat(file)).size
const oss = client(store)
let uploadId: string | undefined
try {
await oss.multipartUpload(key, file, {
mime: 'application/x-xz',
headers: { 'x-oss-object-acl': 'private' },
// SDK 开始时报 0、每传完一片报一次、合并完报 1;小于 100KB 的文件不分片,只报一次 1。
progress: (fraction: number, checkpoint?: { uploadId: string, partSize: number, doneParts: unknown[] } | null) => {
if (checkpoint)
uploadId = checkpoint.uploadId
const sent = fraction >= 1
? total
: checkpoint ? Math.min(total, checkpoint.doneParts.length * checkpoint.partSize) : 0
onProgress?.(sent, total)
},
})
}
catch (err) {
if (uploadId)
await oss.abortMultipartUpload(key, uploadId).catch(() => {})
throw err
}
}
/**
* 把 `key` 边下边写进 `dstFile`。`onProgress` 每收到一块数据报一次已收字节数和总字节数
* (服务器没给长度时总数是 0)。
*
* @throws 对象不存在、连续 60 秒收不到数据、收到的字节数和长度对不上。
*/
export async function downloadHandoffBundle(
store: HandoffStore,
key: string,
dstFile: string,
onProgress?: (receivedBytes: number, totalBytes: number) => void,
): Promise<void> {
const { stream, res } = await client(store).getStream(key)
const total = Number((res.headers as Record<string, string | undefined>)['content-length'] ?? 0) || 0
let received = 0
let idle: ReturnType<typeof setTimeout> | undefined
const counter = new Transform({
transform(chunk: Uint8Array, _encoding, callback) {
received += chunk.length
armIdle()
onProgress?.(received, total)
callback(null, chunk)
},
})
function armIdle(): void {
clearTimeout(idle)
idle = setTimeout(() => counter.destroy(new Error(`下载 ${DOWNLOAD_IDLE_MS / 1000} 秒没有收到数据`)), DOWNLOAD_IDLE_MS)
}
armIdle()
try {
await pipeline(stream as Readable, counter, createWriteStream(dstFile))
}
finally {
clearTimeout(idle)
}
if (total > 0 && received !== total)
throw new Error(`下载不完整:收到 ${received} 字节,应为 ${total} 字节`)
}
export async function deleteHandoffBundle(store: HandoffStore, key: string): Promise<void> {
await client(store).delete(key)
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 移交存储的配置:endpoint 和 bucket 存在扩展设置里,两个 AccessKey 存在 SecretStorage 里。
*/
import type { ExtensionContext } from 'vscode'
import type { HandoffStore } from './handoffStore'
import { window } from 'vscode'
import { getHandoffOssKeys, setHandoffOssKeys } from '../auth/secrets'
import { getSettings, saveSettings } from '../settings/store'
export const CONFIGURE_HANDOFF_STORE_COMMAND = 'superpowers.configureHandoffStore'
export const HANDOFF_STORE_MISSING_MESSAGE = '还没配置移交存储:在命令面板运行「Superpowers: 配置移交存储」'
/** 读移交存储的配置,四项缺一项就当没配置,返回 undefined。 */
export async function readHandoffStore(ctx: ExtensionContext): Promise<HandoffStore | undefined> {
const { handoffOssEndpoint: endpoint, handoffOssBucket: bucket } = getSettings(ctx)
const { accessKeyId, accessKeySecret } = await getHandoffOssKeys(ctx)
if (!endpoint || !bucket || !accessKeyId || !accessKeySecret)
return undefined
return { endpoint, bucket, accessKeyId, accessKeySecret }
}
async function ask(opts: { title: string, prompt: string, value?: string, placeHolder?: string, password?: boolean }): Promise<string | undefined> {
const input = await window.showInputBox({
title: opts.title,
prompt: opts.prompt,
value: opts.value,
placeHolder: opts.placeHolder,
password: opts.password,
ignoreFocusOut: true,
validateInput: v => (v.trim() ? undefined : '不能为空'),
})
return input?.trim()
}
/** 「配置移交存储」命令:依次问四项,框里预填已有的值;四项都填完才保存,中途按 Esc 什么都不改。 */
export async function configureHandoffStore(ctx: ExtensionContext): Promise<void> {
const settings = getSettings(ctx)
const keys = await getHandoffOssKeys(ctx)
const endpoint = await ask({
title: '配置移交存储(1/4)',
prompt: '阿里云 OSS 的 endpoint',
value: settings.handoffOssEndpoint,
placeHolder: 'oss-cn-beijing.aliyuncs.com',
})
if (!endpoint)
return
const bucket = await ask({ title: '配置移交存储(2/4)', prompt: 'bucket 名', value: settings.handoffOssBucket })
if (!bucket)
return
const accessKeyId = await ask({ title: '配置移交存储(3/4)', prompt: 'AccessKey ID', value: keys.accessKeyId })
if (!accessKeyId)
return
const accessKeySecret = await ask({ title: '配置移交存储(4/4)', prompt: 'AccessKey Secret', value: keys.accessKeySecret, password: true })
if (!accessKeySecret)
return
await saveSettings(ctx, { handoffOssEndpoint: endpoint, handoffOssBucket: bucket })
await setHandoffOssKeys(ctx, { accessKeyId, accessKeySecret })
void window.showInformationMessage(`移交存储已保存:${bucket}(${endpoint})`)
}
+29 -17
View File
@@ -8,8 +8,11 @@ import {
handoffLocalTombstones, handoffLocalTombstones,
handoffStartedStateExtra, handoffStartedStateExtra,
handoffStartedUiPatch, handoffStartedUiPatch,
transferProgressText,
} from './handoff' } from './handoff'
const KEY = 'spx-handoff/o/r/issue-9-20261008T010203Z.tar.xz'
describe('canStartHandoff', () => { describe('canStartHandoff', () => {
it('gitea 工单、非 done、无待接管、无 assignee 或 assignee 含我 → true', () => { it('gitea 工单、非 done、无待接管、无 assignee 或 assignee 含我 → true', () => {
expect(canStartHandoff({ column: 'in-progress' }, 'me')).toBe(true) expect(canStartHandoff({ column: 'in-progress' }, 'me')).toBe(true)
@@ -20,21 +23,30 @@ describe('canStartHandoff', () => {
it('done / youtrack / 已在移交中 / 不是我负责 → false', () => { it('done / youtrack / 已在移交中 / 不是我负责 → false', () => {
expect(canStartHandoff({ column: 'done' }, 'me')).toBe(false) expect(canStartHandoff({ column: 'done' }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'review', source: 'youtrack' }, '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: ['x'] }, 'me')).toBe(false)
expect(canStartHandoff({ column: 'todo', assignees: ['me'] }, undefined)).toBe(false) expect(canStartHandoff({ column: 'todo', assignees: ['me'] }, undefined)).toBe(false)
}) })
}) })
describe('canAcceptHandoff', () => { describe('canAcceptHandoff', () => {
it('有附件且 assignee 含我 → true', () => { it('有待接管的会话包且 assignee 含我 → true', () => {
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(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({ assignees: ['me'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['x'] }, 'me')).toBe(false) expect(canAcceptHandoff({ handoffBundleKey: KEY, assignees: ['x'] }, 'me')).toBe(false)
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me'] }, undefined)).toBe(false) expect(canAcceptHandoff({ handoffBundleKey: KEY, assignees: ['me'] }, undefined)).toBe(false)
expect(canAcceptHandoff({ source: 'youtrack', handoffAttachmentId: '9', assignees: ['me'] }, 'me')).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', () => { describe('payloads', () => {
it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => { it('移交落盘:只写两个共享字段,本机字段留到本机清理跑完后再墓碑', () => {
expect(handoffStartedStateExtra(9, 'chw')).toEqual({ expect(handoffStartedStateExtra(KEY, 'chw')).toEqual({
handoffAttachmentId: '9', handoffBundleKey: KEY,
handoffFrom: 'chw', handoffFrom: 'chw',
}) })
}) })
@@ -95,8 +107,8 @@ describe('payloads', () => {
}) })
}) })
it('移交 UI patch 用 null 清字段并更新 assignees', () => { it('移交 UI patch 用 null 清字段并更新 assignees', () => {
expect(handoffStartedUiPatch(9, 'chw', 'me', { keepWorktreePath: false })).toEqual({ expect(handoffStartedUiPatch(KEY, 'chw', 'me', { keepWorktreePath: false })).toEqual({
handoffAttachmentId: '9', handoffBundleKey: KEY,
handoffFrom: 'chw', handoffFrom: 'chw',
assignees: ['me'], assignees: ['me'],
sessionId: null, sessionId: null,
@@ -114,11 +126,11 @@ describe('payloads', () => {
}) })
}) })
it('移交 UI patch:keepWorktreePath=true 时省略 worktreePath / worktreeExists', () => { 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('worktreePath')
expect(patch).not.toHaveProperty('worktreeExists') expect(patch).not.toHaveProperty('worktreeExists')
expect(patch).toEqual({ expect(patch).toEqual({
handoffAttachmentId: '9', handoffBundleKey: KEY,
handoffFrom: 'chw', handoffFrom: 'chw',
assignees: ['me'], assignees: ['me'],
sessionId: null, sessionId: null,
@@ -139,7 +151,7 @@ describe('payloads', () => {
profiles: { profilePath: '/p.json' }, profiles: { profilePath: '/p.json' },
worktreePath: '/wt', worktreePath: '/wt',
})).toEqual({ })).toEqual({
handoffAttachmentId: '', handoffBundleKey: '',
handoffFrom: '', handoffFrom: '',
implementSessionId: 'i', implementSessionId: 'i',
reviewSessionId: 'r', reviewSessionId: 'r',
@@ -155,7 +167,7 @@ describe('payloads', () => {
worktreeExists: true, worktreeExists: true,
missingSessionIds: [], missingSessionIds: [],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffBundleKey: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's', sessionId: 's',
worktreePath: '/wt', worktreePath: '/wt',
@@ -170,7 +182,7 @@ describe('payloads', () => {
worktreeExists: false, worktreeExists: false,
missingSessionIds: [], missingSessionIds: [],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffBundleKey: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's', sessionId: 's',
worktreePath: null, worktreePath: null,
@@ -185,7 +197,7 @@ describe('payloads', () => {
worktreeExists: false, worktreeExists: false,
missingSessionIds: ['r'], missingSessionIds: ['r'],
})).toEqual({ })).toEqual({
handoffAttachmentId: null, handoffBundleKey: null,
handoffFrom: null, handoffFrom: null,
sessionId: 's', sessionId: 's',
reviewSessionId: 'r', reviewSessionId: 'r',
+19 -11
View File
@@ -1,29 +1,37 @@
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest' import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types' import type { IssueColumn } from '../../gitea/types'
import { formatByteSize } from '../../cc/handoffArchive'
import { LOCAL_STATE_FIELDS } from '../../issues/localState' import { LOCAL_STATE_FIELDS } from '../../issues/localState'
export function canStartHandoff(issue: { export function canStartHandoff(issue: {
source?: 'gitea' | 'youtrack' source?: 'gitea' | 'youtrack'
column: IssueColumn column: IssueColumn
handoffAttachmentId?: string handoffBundleKey?: string
assignees?: string[] assignees?: string[]
}, me: string | undefined): boolean { }, me: string | undefined): boolean {
const assignees = issue.assignees ?? [] const assignees = issue.assignees ?? []
const mine = assignees.length === 0 || (me !== undefined && assignees.includes(me)) 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: { export function canAcceptHandoff(issue: {
source?: 'gitea' | 'youtrack' source?: 'gitea' | 'youtrack'
handoffAttachmentId?: string handoffBundleKey?: string
assignees?: string[] assignees?: string[]
}, me: string | undefined): boolean { }, me: string | undefined): boolean {
return issue.source !== 'youtrack' return issue.source !== 'youtrack'
&& !!issue.handoffAttachmentId && !!issue.handoffBundleKey
&& me !== undefined && me !== undefined
&& (issue.assignees ?? []).includes(me) && (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」,否则接管方从远端重建会丢工作。 */ /** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */
export function checkWorktreePushed(input: { export function checkWorktreePushed(input: {
statusPorcelain: string statusPorcelain: string
@@ -38,13 +46,13 @@ export function checkWorktreePushed(input: {
} }
/** /**
* 共享墓碑:只写 handoffAttachmentId / handoffFrom 两个共享字段。本机 * 共享字段:只写 handoffBundleKey / handoffFrom 两个。本机
* sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘, * sid/profile/worktreePath 墓碑要等本机清理跑完(成功指派后)才落盘,
* 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空, * 见 handoffLocalTombstones —— 否则④/⑤失败回滚时本机字段已经被清空,
* 用户会话恢复不了。 * 用户会话恢复不了。
*/ */
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> { export function handoffStartedStateExtra(bundleKey: string, from: string): Record<string, unknown> {
return { handoffAttachmentId: String(attachmentId), handoffFrom: from } return { handoffBundleKey: bundleKey, handoffFrom: from }
} }
/** /**
@@ -62,9 +70,9 @@ export function handoffLocalTombstones(opts: { keepWorktreePath: boolean }): Rec
return extra 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 { return {
handoffAttachmentId: String(attachmentId), handoffBundleKey: bundleKey,
handoffFrom: from, handoffFrom: from,
assignees: [to], assignees: [to],
sessionId: null, sessionId: null,
@@ -97,7 +105,7 @@ export function handoffAcceptedStateExtra(local: {
worktreePath?: string worktreePath?: string
}): Record<string, unknown> { }): Record<string, unknown> {
return { return {
handoffAttachmentId: '', handoffBundleKey: '',
handoffFrom: '', handoffFrom: '',
...definedEntries(local.sessions), ...definedEntries(local.sessions),
...definedEntries(local.profiles), ...definedEntries(local.profiles),
@@ -113,7 +121,7 @@ export function handoffAcceptedUiPatch(local: {
missingSessionIds: string[] missingSessionIds: string[]
}): Record<string, unknown> { }): Record<string, unknown> {
return { return {
handoffAttachmentId: null, handoffBundleKey: null,
handoffFrom: null, handoffFrom: null,
...definedEntries(local.sessions), ...definedEntries(local.sessions),
...definedEntries(local.profiles), ...definedEntries(local.profiles),
+103 -107
View File
@@ -1,5 +1,6 @@
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest' import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
import type { IssueColumn } from '../../gitea/types' import type { IssueColumn } from '../../gitea/types'
import type { HandoffStore } from '../../oss/handoffStore'
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import * as fs from 'node:fs' import * as fs from 'node:fs'
@@ -9,26 +10,18 @@ import * as path from 'node:path'
import { window, workspace } from 'vscode' import { window, workspace } from 'vscode'
import { loginForToken } from '../../auth/identity' import { loginForToken } from '../../auth/identity'
import { getToken } from '../../auth/secrets' 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 { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, parseHandoffManifest } from '../../cc/handoffManifest' import { HANDOFF_MANIFEST_FILE, handoffBundleKey, parseHandoffManifest } from '../../cc/handoffManifest'
import { ensureHandoffFits, HandoffSourcesTooLargeError } from '../../cc/handoffSize'
import { claudeProjectsRoot } from '../../cc/sessionBundle' import { claudeProjectsRoot } from '../../cc/sessionBundle'
import { projectsDirFor } from '../../cc/sessionWatcher' import { projectsDirFor } from '../../cc/sessionWatcher'
import { detectRepo } from '../../git/remote' import { detectRepo } from '../../git/remote'
import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { ensureWorktreeFromRemote, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath' import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir, slugFromBranch } from '../../git/worktreePath'
import { import { getIssue, listRepoAssignees, updateIssueAssignees } from '../../gitea/api'
deleteIssueAttachment,
downloadAttachment,
getIssue,
getIssueAttachment,
listIssueAttachments,
listRepoAssignees,
updateIssueAssignees,
uploadIssueAttachment,
} from '../../gitea/api'
import { logger } from '../../logging/logger' 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 { annotateMissingSessionIds, defaultCodexSessionsDir } from '../../sessions/sessionFiles'
import { getSettings } from '../../settings/store' import { getSettings } from '../../settings/store'
import { makeNonce } from '../../webview/html' import { makeNonce } from '../../webview/html'
@@ -42,6 +35,7 @@ import {
handoffLocalTombstones, handoffLocalTombstones,
handoffStartedStateExtra, handoffStartedStateExtra,
handoffStartedUiPatch, handoffStartedUiPatch,
transferProgressText,
} from './handoff' } from './handoff'
import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest } from './handoffRequest' 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)) 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> { export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) { if (!ctx) {
@@ -130,7 +153,7 @@ export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber:
* 发送方:服务端复查 canStartHandoff(webview 按钮的显示条件可能过期)→ * 发送方:服务端复查 canStartHandoff(webview 按钮的显示条件可能过期)→
* 校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝;没有 * 校验 worktree 已 push(worktree 存在但 state 缺 branch 直接拒绝;没有
* worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→ * worktree 时改查本地分支是否已 push,避免 ⑥ 误删未推送的分支)→
* 关终端 → 打包上传 → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。 * 关终端 → 打包上传到 OSS → 写共享字段 → 重新指派 → 本机清理 → 写本机墓碑。
* 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理 * 远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;本机清理
* 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住 * 与墓碑写入失败只告警,移交在远端已经生效。顶层 try/catch/finally 兜住
* 未预期异常并保证 `handoff/done` 一定发出去。 * 未预期异常并保证 `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> { export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<HandoffStartResult> {
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
// spinner 声明在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。 // progress 建在 try 外面:outer catch 兜住未预期异常时也要能把它收掉。
let spinner: string | undefined const progress = progressToast(panel, `正在移交 #${issueNumber} 给 ${to}`)
const say = progress.say
try { try {
const store = await requireHandoffStore(panel)
if (!store)
return { ok: false, message: HANDOFF_STORE_MISSING_MESSAGE }
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) if (!ctx)
return { ok: false, message: '无法开始移交' } return { ok: false, message: '无法开始移交' }
@@ -175,7 +202,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done'] const knownColumns: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo' const column = knownColumns.includes(state.column as IssueColumn) ? (state.column as IssueColumn) : 'todo'
const assignees = (issue.assignees ?? []).map(a => a.login) 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} 不是你负责的工单,或已在移交中 / 已完成,不能移交` const message = `#${issueNumber} 不是你负责的工单,或已在移交中 / 已完成,不能移交`
toast(panel, 'error', message) toast(panel, 'error', message)
return { ok: false, message } return { ok: false, message }
@@ -197,10 +224,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}), ...(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 => { const fail = (message: string): HandoffStartResult => {
if (spinner) progress.dismiss()
dismiss(panel, spinner)
toast(panel, 'error', message) toast(panel, 'error', message)
return { ok: false, message } return { ok: false, message }
} }
@@ -208,7 +233,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。 // 没法做 push 校验——不能放行,否则 ⑥ 清理会在没校验过的情况下把它删掉。
// 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它—— // 没有 worktree 但本地还留着同名分支时,⑥ 会直接 `git branch -D` 掉它——
// 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。 // 分支若领先远端(未 push)就会丢工作,必须在这里单独拦一次。
spinner = say('核对分支是否已推送') say('核对分支是否已推送')
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
if (worktreeAbs && fs.existsSync(worktreeAbs)) { if (worktreeAbs && fs.existsSync(worktreeAbs)) {
if (!branch) { 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 刷盘 // ② 关终端,等 jsonl 刷盘
say('关闭终端') say('关闭终端')
let disposedAny = false let disposedAny = false
@@ -290,35 +292,24 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
// ③ 打包 + 上传 // ③ 打包 + 上传
say('收集会话') 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 let manifest: HandoffManifest
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。 // 包必须放在 staging 之外,否则 tar 会把半成品包也打进去。
const staging = await scratchDir('spx-handoff-') const staging = await scratchDir('spx-handoff-')
const outDir = await scratchDir('spx-handoff-out-') const outDir = await scratchDir('spx-handoff-out-')
try { try {
manifest = await stageHandoffBundle(handoffSrc, staging, handoffRoots) manifest = await stageHandoffBundle(handoffSrc, staging, handoffRoots)
const name = handoffAttachmentName(issueNumber) const archive = path.join(outDir, path.posix.basename(bundleKey))
const archive = path.join(outDir, name) await packHandoff(staging, archive, { onProgress: bytes => say(`打包 ${formatByteSize(bytes)}`) })
packedMbShown = -1 await uploadHandoffBundle(store, bundleKey, archive, (sent, total) => say(transferProgressText('上传', sent, total)))
await packHandoff(staging, archive, { onProgress: reportPacked('打包', false) }) logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (${bundleKey}, ${(await fsp.stat(archive)).size} bytes)`, details: JSON.stringify(manifest) })
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) })
} }
catch (err) { catch (err) {
const message = err instanceof HandoffArchiveTooLargeError const message = err instanceof Error ? err.message : String(err)
? err.message
: (err instanceof Error ? err.message : String(err))
const prefix = err instanceof HandoffArchiveTooLargeError ? '打包' : '打包/上传'
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message }) logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:打包/上传失败`, details: message })
dismiss(panel, spinner) return fail(`移交失败(打包/上传):${message}`)
toast(panel, 'error', `移交失败(${prefix}):${message}`)
return { ok: false, message: `移交失败(${prefix}):${message}` }
} }
finally { finally {
// rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。 // rm 失败不该抛出掩盖上面 try/catch 已经判定好的成败——scratch 目录只是垃圾。
@@ -326,19 +317,17 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
} }
// ④ 写共享字段(只 handoffAttachmentId / handoffFrom,本机墓碑留到 ⑦); // ④ 写共享字段(只 handoffBundleKey / handoffFrom,本机墓碑留到 ⑦);
// ⑤ 重新指派。失败回滚远端改动。 // ⑤ 重新指派。失败回滚远端改动。
say('写入状态') say('写入状态')
try { try {
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me)) await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(bundleKey, me))
} }
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
// 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。 // 回滚失败不能遮蔽原始错误:吞掉,走下面统一的失败 toast。
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) await deleteHandoffBundle(store, bundleKey).catch(() => {})
dismiss(panel, spinner) return fail(`移交失败(写状态):${message}`)
toast(panel, 'error', `移交失败(写状态):${message}`)
return { ok: false, message: `移交失败(写状态):${message}` }
} }
say('指派') say('指派')
try { try {
@@ -347,11 +336,9 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
// 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。 // 回滚失败不能遮蔽原始错误:两次 catch 都吞掉,走下面统一的失败 toast。
await panel.mergeIssueState(issueNumber, { handoffAttachmentId: '', handoffFrom: '' }).catch(() => {}) await panel.mergeIssueState(issueNumber, { handoffBundleKey: '', handoffFrom: '' }).catch(() => {})
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {}) await deleteHandoffBundle(store, bundleKey).catch(() => {})
dismiss(panel, spinner) return fail(`移交失败(指派 ${to}):${message}`)
toast(panel, 'error', `移交失败(指派 ${to}):${message}`)
return { ok: false, message: `移交失败(指派 ${to}):${message}` }
} }
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警—— // ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)。失败只告警——
@@ -393,8 +380,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:写本机墓碑失败`, details: message }) logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:写本机墓碑失败`, details: message })
}) })
dismiss(panel, spinner) progress.dismiss()
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to, { keepWorktreePath: worktreeRemovalFailed }) }) 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 会话)`) toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {}) await panel.mergeIssueState(issueNumber, { handoffRequestFrom: '', handoffRequestError: '' }).catch(() => {})
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } }) panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestFrom: null, handoffRequestError: null } })
@@ -403,9 +390,8 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message }) logger.add({ level: 'error', source: 'panel', message: `移交 #${issueNumber}:未预期错误`, details: message })
progress.dismiss()
toast(panel, 'error', `移交失败:${message}`) toast(panel, 'error', `移交失败:${message}`)
if (spinner)
dismiss(panel, spinner)
return { ok: false, message: `移交失败:${message}` } return { ok: false, message: `移交失败:${message}` }
} }
finally { finally {
@@ -413,12 +399,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
} }
} }
/** async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Promise<{ me: string, facts: { source: 'gitea', column: IssueColumn, handoffBundleKey?: string, handoffRequestFrom?: string, assignees: string[] } } | undefined> {
* 接管方:服务端复查 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> {
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) if (!ctx)
return return
@@ -442,7 +423,7 @@ async function requestFacts(panel: KanbanWebviewPanel, issueNumber: number): Pro
facts: { facts: {
source: 'gitea', source: 'gitea',
column, column,
...(str(state.handoffAttachmentId) ? { handoffAttachmentId: str(state.handoffAttachmentId) } : {}), ...(str(state.handoffBundleKey) ? { handoffBundleKey: str(state.handoffBundleKey) } : {}),
...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}), ...(str(state.handoffRequestFrom) ? { handoffRequestFrom: str(state.handoffRequestFrom) } : {}),
assignees: (issue.assignees ?? []).map(a => a.login), assignees: (issue.assignees ?? []).map(a => a.login),
}, },
@@ -493,6 +474,9 @@ export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issu
toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`) toast(panel, 'error', `#${issueNumber} 没有待处理的移交申请`)
return return
} }
// 没配移交存储是本机的事,不记成申请失败的原因,申请原样留着。
if (!await requireHandoffStore(panel))
return
const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom) const result = await handleHandoffStart(panel, issueNumber, loaded.facts.handoffRequestFrom)
if (result.ok) if (result.ok)
return return
@@ -500,9 +484,19 @@ export async function handleHandoffRequestAccept(panel: KanbanWebviewPanel, issu
panel.postMessage({ type: 'issue/patch', issueNumber, patch: { handoffRequestError: result.message } }) 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> { export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
// finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。 // finish 要在 ctx 解析之前就能用:resolveRepoCtx 失败也得让弹窗的 spinner 收掉。
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber }) const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
const store = await requireHandoffStore(panel)
if (!store) {
finish()
return
}
const ctx = await resolveRepoCtx(panel) const ctx = await resolveRepoCtx(panel)
if (!ctx) { if (!ctx) {
finish() finish()
@@ -519,41 +513,41 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
finish() finish()
return return
} }
const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10) const bundleKey = str(state.handoffBundleKey)
if (!Number.isFinite(attachmentId)) { if (!bundleKey) {
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`) toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
finish() finish()
return return
} }
// spinner / extracted 声明在 try 外面:getIssue、mkdtemp 任一失败都要靠 // 进度 toast 和发送方一样一直留着、随步骤改文案,结束时才收掉。
// 「是否已创建」来决定 catch/finally 里能不能 dismiss/rm,不能假设已存在。 const progress = progressToast(panel, `正在接管 #${issueNumber}`)
let spinner: string | undefined const say = progress.say
// extracted 声明在 try 外面:mkdtemp 之前就失败时 finally 不能去 rm 它。
let extracted: string | undefined let extracted: string | undefined
try { try {
spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 }) say('核对指派')
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被 // 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
// 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。 // 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
const issue = await getIssue({ host, token, owner, repo, index: issueNumber }) const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue) { if (!issue) {
dismiss(panel, spinner) progress.dismiss()
toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`) toast(panel, 'error', `工单 #${issueNumber} 不存在或已删除`)
return return
} }
if (!canAcceptHandoff({ handoffAttachmentId: String(attachmentId), assignees: (issue.assignees ?? []).map(a => a.login) }, me)) { if (!canAcceptHandoff({ handoffBundleKey: bundleKey, assignees: (issue.assignees ?? []).map(a => a.login) }, me)) {
dismiss(panel, spinner) progress.dismiss()
toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`) toast(panel, 'error', `#${issueNumber} 未指派给你,不能接管`)
return return
} }
extracted = await scratchDir('spx-takeover-') extracted = await scratchDir('spx-takeover-')
// ① 下载 + 解包 + 校验清单 // ① 下载 + 解包 + 校验清单。包名固定,不拿共享 state 里的 key 拼本机路径。
const meta = await getIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) const archive = path.join(extracted, 'handoff-bundle.tar.xz')
const data = await downloadAttachment({ token, url: meta.browser_download_url }) await downloadHandoffBundle(store, bundleKey, archive, (got, total) => say(transferProgressText('下载', got, total)))
const archive = path.join(extracted, meta.name) say('解包')
await fsp.writeFile(archive, data)
await unpackHandoff(archive, extracted) await unpackHandoff(archive, extracted)
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber) 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) const branch = manifest.branch ?? str(state.branch)
let worktreeAbs: string | undefined let worktreeAbs: string | undefined
if (branch) { if (branch) {
say('建 worktree')
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch)) const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch }) worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
@@ -578,6 +573,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
// ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的 // ③ 装会话文件;发送方与接收方的 worktree 路径不同就顺带把会话里的
// cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着 // cwd 类路径重写成接收方路径,否则 `codex resume` / MCP 起进程会照着
// 发送方那条本机不存在的路径找目录,直接 ENOENT。 // 发送方那条本机不存在的路径找目录,直接 ENOENT。
say('装会话')
const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined const pathRewrite = manifest.worktreePath && worktreeAbs ? { from: manifest.worktreePath, to: worktreeAbs } : undefined
await installHandoffBundle(extracted, manifest, { await installHandoffBundle(extracted, manifest, {
projectsRoot: claudeProjectsRoot(), projectsRoot: claudeProjectsRoot(),
@@ -587,20 +583,21 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
...(pathRewrite ? { pathRewrite } : {}), ...(pathRewrite ? { pathRewrite } : {}),
}) })
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn) // ④ 本机字段 + 清共享字段;⑤ 删 OSS 上的会话包(失败只 warn)
// missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机, // missingSessionIds 是计算字段,board 只在加载时算;会话文件刚装进本机,
// 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。 // 这里按真实文件重算一起下发,UI 不用等下次刷新就能双击 resume。
say('写入状态')
const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions]) const [{ missingSessionIds = [] }] = await annotateMissingSessionIds([manifest.sessions])
const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs } const local = { sessions: manifest.sessions, profiles: manifest.profiles, worktreePath: worktreeAbs }
await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local)) await panel.mergeIssueState(issueNumber, handoffAcceptedStateExtra(local))
try { try {
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }) await deleteHandoffBundle(store, bundleKey)
} }
catch (err) { 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({ panel.postMessage({
type: 'issue/patch', type: 'issue/patch',
issueNumber, issueNumber,
@@ -612,8 +609,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message }) logger.add({ level: 'error', source: 'panel', message: `接管 #${issueNumber} 失败`, details: message })
if (spinner) progress.dismiss()
dismiss(panel, spinner)
toast(panel, 'error', `接管失败:${message}`) toast(panel, 'error', `接管失败:${message}`)
} }
finally { finally {
@@ -17,7 +17,7 @@ describe('移交申请', () => {
expect(canAskHandoff({ ...review, assignees: [] }, 'cruldra')).toBe(false) expect(canAskHandoff({ ...review, assignees: [] }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, column: 'in-progress' }, 'cruldra')).toBe(false) expect(canAskHandoff({ ...review, column: 'in-progress' }, 'cruldra')).toBe(false)
expect(canAskHandoff({ ...review, source: 'youtrack' }, '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) expect(canAskHandoff(review, undefined)).toBe(false)
}) })
+2 -2
View File
@@ -1,14 +1,14 @@
export interface HandoffRequestFacts { export interface HandoffRequestFacts {
source?: 'gitea' | 'youtrack' source?: 'gitea' | 'youtrack'
column: 'todo' | 'in-progress' | 'review' | 'done' column: 'todo' | 'in-progress' | 'review' | 'done'
handoffAttachmentId?: string handoffBundleKey?: string
handoffRequestFrom?: string handoffRequestFrom?: string
handoffRequestError?: string handoffRequestError?: string
assignees?: string[] assignees?: string[]
} }
function openReview(issue: HandoffRequestFacts): boolean { function openReview(issue: HandoffRequestFacts): boolean {
return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffAttachmentId return issue.source !== 'youtrack' && issue.column === 'review' && !issue.handoffBundleKey
} }
/** 审查列里同事负责的工单,且当前没有别的申请。 */ /** 审查列里同事负责的工单,且当前没有别的申请。 */
+3 -3
View File
@@ -1,7 +1,7 @@
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { promises as fsp } from 'node:fs' import { promises as fsp } from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive' import { packSessionHandoff, unpackSessionHandoff } from '../../cc/handoffArchive'
import { stageClaudeSessionFiles } from '../../cc/handoffBundle' import { stageClaudeSessionFiles } from '../../cc/handoffBundle'
import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest' import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
import { resolveProfilePath } from '../../cc/profiles' 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`) await fsp.writeFile(path.join(staging, HANDOFF_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
mailboxIndex = await ensureMailboxIssue(ctx) mailboxIndex = await ensureMailboxIssue(ctx)
const archive = path.join(outDir, attachmentName) const archive = path.join(outDir, attachmentName)
await packHandoff(staging, archive) await packSessionHandoff(staging, archive)
for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) { for (const old of await listIssueAttachments({ ...ctx, index: mailboxIndex })) {
const parsed = parseSessionHandoffAttachmentName(old.name) const parsed = parseSessionHandoffAttachmentName(old.name)
if (parsed && parsed.sid === session.id && parsed.to === to) 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 buf = await downloadAttachment({ token: ctx.token, url: asset.browser_download_url })
const archive = path.join(archiveDir, asset.name) const archive = path.join(archiveDir, asset.name)
await fsp.writeFile(archive, buf) 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')) const manifest = parseSessionHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'))
if (manifest.to !== ctx.me) if (manifest.to !== ctx.me)
throw new Error(`清单接收人是 ${manifest.to},不是你`) throw new Error(`清单接收人是 ${manifest.to},不是你`)
+1 -1
View File
@@ -54,7 +54,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
+18
View File
@@ -240,6 +240,13 @@ export interface Settings {
* 支持前导 `~`。保存后 listClaudeProfiles / getDefaultProfilePath 都从这里读。 * 支持前导 `~`。保存后 listClaudeProfiles / getDefaultProfilePath 都从这里读。
*/ */
profilesDirectory: string profilesDirectory: string
/**
* 移交存储(阿里云 OSS)的 endpoint,如 `oss-cn-beijing.aliyuncs.com`。空串 = 没配置,
* 移交和接管都会停下来提示去跑「配置移交存储」。AccessKey 在 SecretStorage 里。
*/
handoffOssEndpoint: string
/** 移交存储的 bucket 名。空串 = 没配置。 */
handoffOssBucket: string
} }
export const SETTINGS_KEY = 'superpowers.settings' export const SETTINGS_KEY = 'superpowers.settings'
@@ -313,6 +320,8 @@ function defaults(ctx: ExtensionContext): Settings {
codexModel: '', codexModel: '',
codexReasoningEffort: '', codexReasoningEffort: '',
profilesDirectory: '', profilesDirectory: '',
handoffOssEndpoint: '',
handoffOssBucket: '',
} }
} }
@@ -444,6 +453,13 @@ export function getSettings(ctx: ExtensionContext): Settings {
const profilesDirectory = typeof stored.profilesDirectory === 'string' const profilesDirectory = typeof stored.profilesDirectory === 'string'
? stored.profilesDirectory ? stored.profilesDirectory
: base.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 { return {
webhookPort, webhookPort,
@@ -479,6 +495,8 @@ export function getSettings(ctx: ExtensionContext): Settings {
codexModel, codexModel,
codexReasoningEffort, codexReasoningEffort,
profilesDirectory, profilesDirectory,
handoffOssEndpoint,
handoffOssBucket,
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->' export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */ /** 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 * Parse a comment body into a state object, or null if it isn't a state
+1 -1
View File
@@ -539,7 +539,7 @@ export function App() {
<HandoffModal <HandoffModal
open={handoffIssue !== null} open={handoffIssue !== null}
title={handoffIssue !== null ? `移交 #${handoffIssue}` : '移交'} title={handoffIssue !== null ? `移交 #${handoffIssue}` : '移交'}
description="会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。" description="会把本机的 worktree 删除、会话记录打包传到移交存储(阿里云 OSS),并把工单指派给对方。移交前分支必须已全部 push。"
users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null} users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null}
onCancel={() => setHandoffIssue(null)} onCancel={() => setHandoffIssue(null)}
onSubmit={(to) => { onSubmit={(to) => {
@@ -129,7 +129,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
: null} : null}
{readOnly && ownerLogin ? <OwnerBadge login={ownerLogin} avatar={ownerAvatar} /> : null} {readOnly && ownerLogin ? <OwnerBadge login={ownerLogin} avatar={ownerAvatar} /> : null}
</div> </div>
{issue.handoffAttachmentId {issue.handoffBundleKey
? ( ? (
<span <span
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400" className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
@@ -918,7 +918,7 @@ export function IssueDetailPanel({
avoid acting on a gitea issue of the same number. */} avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && !readOnly && ( {issue.source !== 'youtrack' && !readOnly && (
<> <>
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( {issue.handoffBundleKey && me !== undefined && (issue.assignees ?? []).includes(me) && (
<button <button
type="button" type="button"
data-tooltip={`接管 ${issue.handoffFrom ?? '?'} 的移交`} data-tooltip={`接管 ${issue.handoffFrom ?? '?'} 的移交`}
@@ -930,7 +930,7 @@ export function IssueDetailPanel({
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />} {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
</button> </button>
)} )}
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && ( {!issue.handoffBundleKey && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
<button <button
type="button" type="button"
data-tooltip="移交给同事" data-tooltip="移交给同事"
@@ -942,7 +942,7 @@ export function IssueDetailPanel({
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />} {isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button> </button>
)} )}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( {issue.handoffBundleKey && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
<span <span
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs opacity-70" className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs opacity-70"
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`} data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,等 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}
+2 -2
View File
@@ -320,9 +320,9 @@ export interface UseIssuesResult {
handoffUsers: { issueNumber: number, users: string[] } | null handoffUsers: { issueNumber: number, users: string[] } | null
/** 拉取某工单可移交的候选用户列表,结果通过 `handoffUsers` 返回。 */ /** 拉取某工单可移交的候选用户列表,结果通过 `handoffUsers` 返回。 */
requestHandoffUsers: (issueNumber: number) => void requestHandoffUsers: (issueNumber: number) => void
/** 发起移交:删本机 worktree、打包会话记录挂到附件、把工单指派给对方。 */ /** 发起移交:删本机 worktree、打包会话记录传到 OSS、把工单指派给对方。 */
startHandoff: (issueNumber: number, to: string) => void startHandoff: (issueNumber: number, to: string) => void
/** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */ /** 接管一个待接管工单:从 OSS 下载会话包、恢复会话与 worktree。 */
acceptHandoff: (issueNumber: number) => void acceptHandoff: (issueNumber: number) => void
/** 审查列里向同事申请把工单移交给自己。 */ /** 审查列里向同事申请把工单移交给自己。 */
askHandoff: (issueNumber: number) => void askHandoff: (issueNumber: number) => void
+1 -1
View File
@@ -93,7 +93,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: 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。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
+2 -2
View File
@@ -101,8 +101,8 @@ export interface Issue {
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */ /** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */ /** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
handoffAttachmentId?: string handoffBundleKey?: string
/** 发起移交的 Gitea login。 */ /** 发起移交的 Gitea login。 */
handoffFrom?: string handoffFrom?: string
/** 谁在申请把这个审查工单移交给自己。 */ /** 谁在申请把这个审查工单移交给自己。 */