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