From 499cfad7d7a6072ade72b53c787ae35be67ac487 Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 8 Oct 2026 06:48:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E7=A7=BB=E4=BA=A4=E7=9A=84=E4=BC=9A=E8=AF=9D=E5=8C=85=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=E9=98=BF=E9=87=8C=E4=BA=91=20OSS=E3=80=81=E6=8D=A2=20?= =?UTF-8?q?xz=20=E5=8E=8B=E7=BC=A9=EF=BC=8C=E4=B8=A4=E8=BE=B9=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E4=B8=80=E6=AD=A5=E6=AD=A5=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话包传到 OSS 的 spx-handoff///issue--<时间>.tar.xz,上传时对象设成私有;不再挂 Gitea 工单附件 - 新命令「配置移交存储」:endpoint、bucket 存扩展设置,AccessKey 存密钥存储;没配时移交 / 接管直接提示,什么都不改 - 共享 state 字段 handoffAttachmentId 改名 handoffBundleKey(含 spx CLI 与 schema) - 打包换成 xz 1 级(tar -cJf),去掉 100MB 上限检查和先试压一遍的重复压缩 - 发送方提示打包 / 上传了多少 MB;接管方同一条提示依次显示下载、解包、建 worktree、装会话,完成前不消失 - 会话交接(单会话)仍走 Gitea 附件,Gitea 附件白名单没有 .xz,单独保留 gzip 打包 --- docs/superpowers/specs/issue-handoff/spec.md | 72 +-- vscode/README.md | 4 +- vscode/cli/cmd/spx/main.go | 2 +- vscode/cli/internal/state/schema.json | 4 +- vscode/esbuild.mjs | 3 +- vscode/package.json | 8 + vscode/pnpm-lock.yaml | 448 +++++++++++++++++- vscode/schemas/state-json.schema.json | 4 +- vscode/src/auth/secrets.ts | 16 + vscode/src/cc/handoffArchive.test.ts | 87 ++++ vscode/src/cc/handoffArchive.ts | 112 ++--- vscode/src/cc/handoffBundle.test.ts | 2 +- vscode/src/cc/handoffManifest.test.ts | 15 +- vscode/src/cc/handoffManifest.ts | 9 +- vscode/src/cc/handoffSize.test.ts | 112 ----- vscode/src/cc/handoffSize.ts | 114 ----- vscode/src/extension.ts | 2 + vscode/src/gitea/api.ts | 16 - vscode/src/gitea/issueComment.ts | 2 +- vscode/src/gitea/issueLoader.ts | 14 +- vscode/src/gitea/stateJson.ts | 2 +- vscode/src/gitea/types.ts | 4 +- vscode/src/oss/handoffStore.ts | 112 +++++ vscode/src/oss/handoffStoreSetup.ts | 61 +++ vscode/src/panel/handlers/handoff.test.ts | 46 +- vscode/src/panel/handlers/handoff.ts | 30 +- vscode/src/panel/handlers/handoffFlow.ts | 210 ++++---- .../src/panel/handlers/handoffRequest.test.ts | 2 +- vscode/src/panel/handlers/handoffRequest.ts | 4 +- vscode/src/panel/handlers/sessionHandoff.ts | 6 +- vscode/src/panel/messages.ts | 2 +- vscode/src/settings/store.ts | 18 + vscode/src/youtrack/stateComment.ts | 2 +- vscode/webview-ui/src/App.tsx | 2 +- .../webview-ui/src/components/IssueCard.tsx | 2 +- .../src/components/IssueDetailPanel.tsx | 6 +- vscode/webview-ui/src/hooks/useIssues.ts | 4 +- vscode/webview-ui/src/lib/messages.ts | 2 +- vscode/webview-ui/src/types.ts | 4 +- 39 files changed, 1039 insertions(+), 526 deletions(-) create mode 100644 vscode/src/cc/handoffArchive.test.ts delete mode 100644 vscode/src/cc/handoffSize.test.ts delete mode 100644 vscode/src/cc/handoffSize.ts create mode 100644 vscode/src/oss/handoffStore.ts create mode 100644 vscode/src/oss/handoffStoreSetup.ts diff --git a/docs/superpowers/specs/issue-handoff/spec.md b/docs/superpowers/specs/issue-handoff/spec.md index e5ea33f..692730a 100644 --- a/docs/superpowers/specs/issue-handoff/spec.md +++ b/docs/superpowers/specs/issue-handoff/spec.md @@ -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 ` 在任意目录可用,会搜本机全部 `~/.claude/projects/*/`;jsonl 内部旧 `cwd` 不存在也能续。**同一 sid 出现在两个 project 目录 → 报 "No conversation found"**。 - 一个 claude 会话 = `/.jsonl` + 可选目录 `//`(`subagents/`、`tool-results/`)。两者必须一起搬。 - codex 会话 = `~/.codex/sessions/YYYY/MM/DD/rollout--.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-.tgz`。 +- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff///issue--.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 ` 后 `HEAD` 必须等于 `origin/`。否则 toast「有未提交/未推送改动」并终止,什么都不改。 -3. **会话大小**(关终端之前):统计将打进包的 jsonl、会话目录和 codex rollout。原始总字节不超过 Gitea 附件上限 100MB 则通过。超过则先压缩,压缩产出一旦超过 100MB 就停,toast 写明哪个会话多大,不关终端、不上传、不改指派。正式打包再卡一次同一上限;那次失败发生在关终端之后,按打包失败处理。 -4. 关闭该工单全部终端(`terminalOrigin` 同 issueNumber + 名称匹配 `issue-N-冲突解决`),等 600ms 让 jsonl 刷盘。 -5. 在 scratch 临时目录收集会话包:同一卷上用硬链接收文件,跨卷才复制。每个 sid 在 `~/.claude/projects/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 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/*/` 里找 `.jsonl`(多份取最新 mtime),连同 `/` 一起拷入 `claude/`(跳过 `subagents/`,子代理对话不移交);`reviewSessionId` 在 `~/.codex/sessions` 里按文件名尾部 uuid 找到 rollout 拷入 `codex/`。找不到的会话不进清单,只 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 `;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B origin/`。模板路径用设置 `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 `;该分支已有 live worktree → 直接用;模板路径被垃圾目录占着 → kill + 删;`git worktree add -B origin/`。模板路径用设置 `worktreeDirectory`,slug = `branch` 去掉 `feature/` 前缀。之后跑 `post-create` 钩子。分支不存在于远端 → toast 终止(会话包保留,可重试)。 4. **安装会话**:`brainstorm` 会话装到 `projectsDirFor(workspaceRoot)`,`implement` / `test` 装到 `projectsDirFor(worktreeAbs)`(无 worktree 时也装到 workspaceRoot)。安装前先删本机所有 `~/.claude/projects/*/.jsonl` 与 `/`(避免重复 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)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。 ## 不做 diff --git a/vscode/README.md b/vscode/README.md index dd568a0..b67178d 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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 … origin/`)、把会话文件装到本机 `~/.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///` 下,写 `handoffBundleKey` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 OSS 下载会话包、从 origin 重建 worktree(`git worktree add -B … origin/`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删掉 OSS 上的会话包。两边的进度提示会一步步显示打包 / 上传 / 下载了多少 MB。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/` 切当天的 `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 -p REF=<分支> -p REMOTE_HOST=`。 其他看板规则: @@ -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` | 本机路径 | diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index e9ddafd..75e39d8 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -426,7 +426,7 @@ var knownStateFields = map[string]struct{}{ "color": {}, "autoReview": {}, "reviewPassed": {}, - "handoffAttachmentId": {}, + "handoffBundleKey": {}, "handoffFrom": {}, "handoffRequestFrom": {}, "handoffRequestError": {}, diff --git a/vscode/cli/internal/state/schema.json b/vscode/cli/internal/state/schema.json index 029748b..7df85b3 100644 --- a/vscode/cli/internal/state/schema.json +++ b/vscode/cli/internal/state/schema.json @@ -122,9 +122,9 @@ "type": "boolean", "description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。" }, - "handoffAttachmentId": { + "handoffBundleKey": { "type": "string", - "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" + "description": "移交会话包在阿里云 OSS 上的对象 key(spx-handoff///issue--<时间>.tar.xz);存在即「待接管」,接管完成后清空;清空时写空串。" }, "handoffFrom": { "type": "string", diff --git a/vscode/esbuild.mjs b/vscode/esbuild.mjs index bed9774..9118c55 100644 --- a/vscode/esbuild.mjs +++ b/vscode/esbuild.mjs @@ -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', diff --git a/vscode/package.json b/vscode/package.json index e80d6de..ef16c78 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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", diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index a73f558..7fd6fd5 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@vscode/tree-sitter-wasm': specifier: ^0.3.1 version: 0.3.1 + ali-oss: + specifier: ^6.23.0 + version: 6.23.0 diff: specifier: ^9.0.0 version: 9.0.0 @@ -35,6 +38,9 @@ importers: '@antfu/eslint-config': specifier: ^6.7.3 version: 6.7.3(@vue/compiler-sfc@3.4.27)(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.16(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.19.2)(yaml@2.8.2)) + '@types/ali-oss': + specifier: ^6.23.3 + version: 6.23.3 '@types/diff': specifier: ^8.0.0 version: 8.0.0 @@ -1229,6 +1235,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/ali-oss@6.23.3': + resolution: {integrity: sha512-huSf6njkqhSeR37OMJTGMCyUg2d9Zp2AioefhYuUMeh0vNM+WybctrBor7bO/RcCbLCQI6sHn6NtPBIWALYVJg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1486,16 +1495,28 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + agent-base@7.1.0: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} + agentkeepalive@3.5.3: + resolution: {integrity: sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==} + engines: {node: '>= 4.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ali-oss@6.23.0: + resolution: {integrity: sha512-FipRmyd16Pr/tEey/YaaQ/24Pc3HEpLM9S1DRakEuXlSLXNIJnu1oJtHM53eVYpvW3dXapSjrip3xylZUTIZVQ==} + engines: {node: '>=8'} + ansi-escapes@7.2.0: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} @@ -1520,6 +1541,9 @@ packages: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + are-docs-informative@0.0.2: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} engines: {node: '>=14'} @@ -1574,6 +1598,9 @@ packages: boundary@2.0.0: resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} + bowser@1.9.4: + resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -1606,6 +1633,9 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + bumpp@10.3.2: resolution: {integrity: sha512-yUUkVx5zpTywLNX97MlrqtpanI7eMMwFwLntWR2EBVDw3/Pm3aRIzCoDEGHATLIiHK9PuJC7xWI4XNWqXItSPg==} engines: {node: '>=18'} @@ -1738,12 +1768,22 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-to@2.0.1: + resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1763,6 +1803,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dateformat@2.2.0: + resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1786,6 +1829,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-user-agent@1.0.0: + resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==} + engines: {node: '>= 0.10.0'} + define-data-property@1.1.1: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} @@ -1812,6 +1859,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1831,6 +1882,10 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + digest-header@1.1.0: + resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==} + engines: {node: '>= 8.0.0'} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1858,6 +1913,9 @@ packages: resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} engines: {ecmascript: '>= es5', node: '>=4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1874,6 +1932,10 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + end-or-error@1.0.1: + resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} + engines: {node: '>= 0.11.14'} + enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -1911,6 +1973,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -2139,6 +2204,10 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2217,6 +2286,9 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formstream@1.5.2: + resolution: {integrity: sha512-NASf0lgxC1AyKNXQIrXTEYkiX99LhCEXTkiGObXAkpBui86a4u8FjH1o2bGb3PpqI3kafC+yw4zWeK6l6VHTgg==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2243,6 +2315,9 @@ packages: get-intrinsic@1.2.2: resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==} + get-ready@1.0.0: + resolution: {integrity: sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -2352,6 +2427,13 @@ packages: resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} engines: {node: '>= 14'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2393,11 +2475,18 @@ packages: resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} hasBin: true + is-class-hotfix@0.0.6: + resolution: {integrity: sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2418,13 +2507,22 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-type-of@1.4.0: + resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istextorbinary@9.5.0: resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} engines: {node: '>=4'} @@ -2437,6 +2535,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-base64@2.6.4: + resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2492,6 +2593,9 @@ packages: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} + jstoxml@2.2.9: + resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==} + jwa@1.4.1: resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} @@ -2717,6 +2821,9 @@ packages: mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2825,6 +2932,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -2850,6 +2962,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} @@ -2859,6 +2975,9 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2887,6 +3006,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-hex@1.0.1: + resolution: {integrity: sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==} + engines: {node: '>=8.0.0'} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2906,6 +3029,10 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} @@ -2933,6 +3060,16 @@ packages: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} + os-name@1.0.3: + resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==} + engines: {node: '>=0.10.0'} + hasBin: true + + osx-release@1.1.0: + resolution: {integrity: sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==} + engines: {node: '>=0.10.0'} + hasBin: true + ovsx@0.10.7: resolution: {integrity: sha512-UjBQlB5xSDD+biAylCZ8Q/k3An9K3y9FYa+hT/HTbJkzOQP+gaNHX20CaOo4lrYT1iJXdiePH9zS2uvCXdDNDA==} engines: {node: '>= 20'} @@ -3002,6 +3139,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -3025,6 +3165,9 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + pluralize@2.0.0: resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} @@ -3053,6 +3196,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} @@ -3110,6 +3256,9 @@ packages: resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} engines: {node: '>=0.8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3168,9 +3317,15 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.3.0: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} @@ -3181,6 +3336,9 @@ packages: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} + sdk-base@2.0.1: + resolution: {integrity: sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==} + secretlint@10.2.2: resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} engines: {node: '>=20.0.0'} @@ -3272,6 +3430,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3279,6 +3441,13 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + stream-http@2.8.2: + resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==} + + stream-wormhole@1.1.0: + resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==} + engines: {node: '>=4.0.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3287,6 +3456,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3365,6 +3537,16 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3384,6 +3566,9 @@ packages: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} + to-arraybuffer@1.0.1: + resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3469,6 +3654,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unescape@1.0.1: + resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} + engines: {node: '>=0.10.0'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -3505,6 +3694,15 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + urllib@2.44.1: + resolution: {integrity: sha512-vreOVvFizoiIz5NK9IYMgUknkriHHBVccn2VFfJhgKz6O2qwm0SgjFk4OpXFRDXpdrTx8EzM1DB0/pejrqXwPA==} + engines: {node: '>= 0.10.0'} + peerDependencies: + proxy-agent: ^5.0.0 + peerDependenciesMeta: + proxy-agent: + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3513,6 +3711,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utility@1.18.0: + resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==} + engines: {node: '>= 0.12.0'} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -3615,6 +3817,10 @@ packages: engines: {node: '>=8'} hasBin: true + win-release@1.1.1: + resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3637,10 +3843,18 @@ packages: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4667,6 +4881,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/ali-oss@6.23.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -5011,12 +5227,18 @@ snapshots: acorn@8.15.0: {} + address@1.2.2: {} + agent-base@7.1.0: dependencies: debug: 4.4.3 transitivePeerDependencies: - supports-color + agentkeepalive@3.5.3: + dependencies: + humanize-ms: 1.2.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5031,6 +5253,37 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ali-oss@6.23.0: + dependencies: + address: 1.2.2 + agentkeepalive: 3.5.3 + bowser: 1.9.4 + copy-to: 2.0.1 + dateformat: 2.2.0 + debug: 4.4.3 + destroy: 1.2.0 + end-or-error: 1.0.1 + get-ready: 1.0.0 + humanize-ms: 1.2.1 + is-type-of: 1.4.0 + js-base64: 2.6.4 + jstoxml: 2.2.9 + lodash: 4.17.21 + merge-descriptors: 1.0.3 + mime: 2.6.0 + platform: 1.3.6 + pump: 3.0.0 + qs: 6.11.2 + sdk-base: 2.0.1 + stream-http: 2.8.2 + stream-wormhole: 1.1.0 + urllib: 2.44.1 + utility: 1.18.0 + xml2js: 0.6.2 + transitivePeerDependencies: + - proxy-agent + - supports-color + ansi-escapes@7.2.0: dependencies: environment: 1.1.0 @@ -5047,6 +5300,8 @@ snapshots: ansis@4.2.0: {} + any-promise@1.3.0: {} + are-docs-informative@0.0.2: {} argparse@2.0.1: {} @@ -5090,6 +5345,8 @@ snapshots: boundary@2.0.0: {} + bowser@1.9.4: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -5127,6 +5384,8 @@ snapshots: builtin-modules@5.0.0: {} + builtin-status-codes@3.0.0: {} + bumpp@10.3.2: dependencies: ansis: 4.2.0 @@ -5270,12 +5529,18 @@ snapshots: consola@3.4.2: {} + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + copy-to@2.0.1: {} + core-js-compat@3.47.0: dependencies: browserslist: 4.28.1 + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5296,6 +5561,8 @@ snapshots: csstype@3.2.3: {} + dateformat@2.2.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5314,6 +5581,10 @@ snapshots: deep-is@0.1.4: {} + default-user-agent@1.0.0: + dependencies: + os-name: 1.0.3 + define-data-property@1.1.1: dependencies: get-intrinsic: 1.2.2 @@ -5336,6 +5607,8 @@ snapshots: destr@2.0.5: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} devlop@1.1.0: @@ -5348,6 +5621,8 @@ snapshots: diff@9.0.0: {} + digest-header@1.1.0: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5378,6 +5653,8 @@ snapshots: dependencies: version-range: 4.15.0 + ee-first@1.1.1: {} + electron-to-chromium@1.5.267: {} emoji-regex@8.0.0: {} @@ -5389,7 +5666,8 @@ snapshots: end-of-stream@1.4.4: dependencies: once: 1.4.0 - optional: true + + end-or-error@1.0.1: {} enhanced-resolve@5.18.1: dependencies: @@ -5468,6 +5746,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -5767,6 +6047,10 @@ snapshots: exsolve@1.0.8: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -5834,6 +6118,13 @@ snapshots: format@0.2.2: {} + formstream@1.5.2: + dependencies: + destroy: 1.2.0 + mime: 2.6.0 + node-hex: 1.0.1 + pause-stream: 0.0.11 + fs-constants@1.0.0: optional: true @@ -5859,6 +6150,8 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.0 + get-ready@1.0.0: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -5980,6 +6273,14 @@ snapshots: transitivePeerDependencies: - supports-color + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: optional: true @@ -5998,8 +6299,7 @@ snapshots: index-to-position@1.2.0: {} - inherits@2.0.4: - optional: true + inherits@2.0.4: {} ini@1.3.8: optional: true @@ -6012,8 +6312,12 @@ snapshots: dependencies: ci-info: 2.0.0 + is-class-hotfix@0.0.6: {} + is-docker@2.2.1: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6028,12 +6332,22 @@ snapshots: is-number@7.0.0: {} + is-type-of@1.4.0: + dependencies: + core-util-is: 1.0.3 + is-class-hotfix: 0.0.6 + isstream: 0.1.2 + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isexe@2.0.0: {} + isstream@0.1.2: {} + istextorbinary@9.5.0: dependencies: binaryextensions: 6.11.0 @@ -6048,6 +6362,8 @@ snapshots: jiti@2.6.1: {} + js-base64@2.6.4: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -6100,6 +6416,8 @@ snapshots: ms: 2.1.3 semver: 7.7.3 + jstoxml@2.2.9: {} + jwa@1.4.1: dependencies: buffer-equal-constant-time: 1.0.1(patch_hash=beceab8aa83d254a64c495a1832ac06b1829a43e89426e612563a08fed744b51) @@ -6391,6 +6709,8 @@ snapshots: mdurl@2.1.0: {} + merge-descriptors@1.0.3: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.2: @@ -6604,6 +6924,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mimic-response@3.1.0: optional: true @@ -6626,6 +6948,10 @@ snapshots: mkdirp-classic@0.5.3: optional: true + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mlly@1.7.4: dependencies: acorn: 8.15.0 @@ -6637,6 +6963,12 @@ snapshots: mute-stream@0.0.8: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} napi-build-utils@1.0.2: @@ -6658,6 +6990,8 @@ snapshots: node-fetch-native@1.6.7: {} + node-hex@1.0.1: {} + node-releases@2.0.27: {} node-sarif-builder@3.4.0: @@ -6683,6 +7017,8 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.2 + object-assign@4.1.1: {} + object-deep-merge@2.0.0: {} object-inspect@1.13.1: {} @@ -6696,7 +7032,6 @@ snapshots: once@1.4.0: dependencies: wrappy: 1.0.2 - optional: true open@8.4.2: dependencies: @@ -6713,6 +7048,15 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + os-name@1.0.3: + dependencies: + osx-release: 1.1.0 + win-release: 1.1.1 + + osx-release@1.1.0: + dependencies: + minimist: 1.2.8 + ovsx@0.10.7: dependencies: '@vscode/vsce': 3.9.1 @@ -6785,6 +7129,10 @@ snapshots: pathe@2.0.3: {} + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + pend@1.2.0: {} perfect-debounce@2.0.0: {} @@ -6807,6 +7155,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + platform@1.3.6: {} + pluralize@2.0.0: {} pluralize@8.0.0: {} @@ -6844,11 +7194,12 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + pump@3.0.0: dependencies: end-of-stream: 1.4.4 once: 1.4.0 - optional: true punycode.js@2.3.1: {} @@ -6912,6 +7263,16 @@ snapshots: dependencies: mute-stream: 0.0.8 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -6987,8 +7348,12 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + sax@1.3.0: {} scheduler@0.27.0: {} @@ -6999,6 +7364,10 @@ snapshots: refa: 0.12.1 regexp-ast-analysis: 0.7.1 + sdk-base@2.0.1: + dependencies: + get-ready: 1.0.0 + secretlint@10.2.2: dependencies: '@secretlint/config-creator': 10.2.2 @@ -7089,10 +7458,22 @@ snapshots: stackback@0.0.2: {} + statuses@1.5.0: {} + std-env@3.10.0: {} stoppable@1.1.0: {} + stream-http@2.8.2: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 2.3.8 + to-arraybuffer: 1.0.1 + xtend: 4.0.2 + + stream-wormhole@1.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -7105,6 +7486,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -7190,6 +7575,16 @@ snapshots: dependencies: editions: 6.22.0 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through@2.3.8: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -7203,6 +7598,8 @@ snapshots: tmp@0.2.3: {} + to-arraybuffer@1.0.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7275,6 +7672,10 @@ snapshots: undici-types@7.16.0: {} + unescape@1.0.1: + dependencies: + extend-shallow: 2.0.1 + unicorn-magic@0.1.0: {} unicorn-magic@0.3.0: {} @@ -7312,12 +7713,35 @@ snapshots: url-join@4.0.1: {} + urllib@2.44.1: + dependencies: + any-promise: 1.3.0 + content-type: 1.0.5 + default-user-agent: 1.0.0 + digest-header: 1.1.0 + ee-first: 1.1.1 + formstream: 1.5.2 + humanize-ms: 1.2.1 + iconv-lite: 0.6.3 + pump: 3.0.0 + qs: 6.11.2 + statuses: 1.5.0 + utility: 1.18.0 + use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 util-deprecate@1.0.2: {} + utility@1.18.0: + dependencies: + copy-to: 2.0.1 + escape-html: 1.0.3 + mkdirp: 0.5.6 + mz: 2.7.0 + unescape: 1.0.1 + uuid@8.3.2: {} validate-npm-package-license@3.0.4: @@ -7401,6 +7825,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + win-release@1.1.1: + dependencies: + semver: 5.7.2 + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -7415,8 +7843,7 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 - wrappy@1.0.2: - optional: true + wrappy@1.0.2: {} xml-name-validator@4.0.0: {} @@ -7425,8 +7852,15 @@ snapshots: sax: 1.3.0 xmlbuilder: 11.0.1 + xml2js@0.6.2: + dependencies: + sax: 1.3.0 + xmlbuilder: 11.0.1 + xmlbuilder@11.0.1: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/vscode/schemas/state-json.schema.json b/vscode/schemas/state-json.schema.json index 029748b..7df85b3 100644 --- a/vscode/schemas/state-json.schema.json +++ b/vscode/schemas/state-json.schema.json @@ -122,9 +122,9 @@ "type": "boolean", "description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。" }, - "handoffAttachmentId": { + "handoffBundleKey": { "type": "string", - "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" + "description": "移交会话包在阿里云 OSS 上的对象 key(spx-handoff///issue--<时间>.tar.xz);存在即「待接管」,接管完成后清空;清空时写空串。" }, "handoffFrom": { "type": "string", diff --git a/vscode/src/auth/secrets.ts b/vscode/src/auth/secrets.ts index 86dffd6..f51ef0e 100644 --- a/vscode/src/auth/secrets.ts +++ b/vscode/src/auth/secrets.ts @@ -43,3 +43,19 @@ export async function setYouTrackToken(ctx: ExtensionContext, host: string, toke export async function deleteYouTrackToken(ctx: ExtensionContext, host: string): Promise { 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 { + await ctx.secrets.store(HANDOFF_OSS_KEY_ID, keys.accessKeyId) + await ctx.secrets.store(HANDOFF_OSS_KEY_SECRET, keys.accessKeySecret) +} diff --git a/vscode/src/cc/handoffArchive.test.ts b/vscode/src/cc/handoffArchive.test.ts new file mode 100644 index 0000000..ead7f2f --- /dev/null +++ b/vscode/src/cc/handoffArchive.test.ts @@ -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')) + }) +}) diff --git a/vscode/src/cc/handoffArchive.ts b/vscode/src/cc/handoffArchive.ts index 66b6857..7482c7f 100644 --- a/vscode/src/cc/handoffArchive.ts +++ b/vscode/src/cc/handoffArchive.ts @@ -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 { return new Promise((resolve, reject) => { execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => { @@ -43,74 +28,65 @@ function tarUnpack(args: string[]): Promise { }) } -export async function packHandoff( - stagingDir: string, - outFile: string, - opts?: { maxBytes?: number, onProgress?: (packedBytes: number) => void }, -): Promise { - const maxBytes = opts?.maxBytes ?? HANDOFF_ATTACHMENT_MAX_BYTES +/** 跑一次打包;给了 `onProgress` 就每 200ms 报一次包文件已写出的字节数,结束时再报一次最终大小。 */ +async function tarPack(args: string[], outFile: string, onProgress?: (packedBytes: number) => void): Promise { await fsp.mkdir(path.dirname(outFile), { recursive: true }) + const reportSize = (): void => { + try { + onProgress?.(statSync(outFile).size) + } + catch { + // 包还没写出来 + } + } await new Promise((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 | 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 { + await tarPack(['--options', 'xz:compression-level=1', '-cJf', outFile, '-C', stagingDir, '.'], outFile, opts?.onProgress) +} + export async function unpackHandoff(archive: string, dstDir: string): Promise { + 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 { + await tarPack(['-czf', outFile, '-C', stagingDir, '.'], outFile) +} + +export async function unpackSessionHandoff(archive: string, dstDir: string): Promise { await fsp.mkdir(dstDir, { recursive: true }) await tarUnpack(['-xzf', archive, '-C', dstDir]) } diff --git a/vscode/src/cc/handoffBundle.test.ts b/vscode/src/cc/handoffBundle.test.ts index 78236e9..b9105d0 100644 --- a/vscode/src/cc/handoffBundle.test.ts +++ b/vscode/src/cc/handoffBundle.test.ts @@ -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) diff --git a/vscode/src/cc/handoffManifest.test.ts b/vscode/src/cc/handoffManifest.test.ts index 303f69f..d038da3 100644 --- a/vscode/src/cc/handoffManifest.test.ts +++ b/vscode/src/cc/handoffManifest.test.ts @@ -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) }) }) diff --git a/vscode/src/cc/handoffManifest.ts b/vscode/src/cc/handoffManifest.ts index 45b904f..ff651b2 100644 --- a/vscode/src/cc/handoffManifest.ts +++ b/vscode/src/cc/handoffManifest.ts @@ -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///issue--.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 = new Set(['brainstorm', 'implement', 'test', 'prototype']) diff --git a/vscode/src/cc/handoffSize.test.ts b/vscode/src/cc/handoffSize.test.ts deleted file mode 100644 index 6207f5f..0000000 --- a/vscode/src/cc/handoffSize.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/vscode/src/cc/handoffSize.ts b/vscode/src/cc/handoffSize.ts deleted file mode 100644 index 9c8bbe4..0000000 --- a/vscode/src/cc/handoffSize.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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(() => {}) - } -} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 3e5aad9..c9db911 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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) } diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 085928d..479baae 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -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 { - 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/),Gitea 的 web * 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML, diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/gitea/issueComment.ts index c463d59..5f38d33 100644 --- a/vscode/src/gitea/issueComment.ts +++ b/vscode/src/gitea/issueComment.ts @@ -14,7 +14,7 @@ const STATE_KEYS = new Set([ 'reviewSessionId', 'testSessionId', 'prototypeSessionId', - 'handoffAttachmentId', + 'handoffBundleKey', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError', diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 776a886..63c2bd5 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -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 } : {}), diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index 5714b89..77a20f6 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -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。 diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index cb1d5e5..148a682 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -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 /** 谁在申请把这个审查工单移交给自己。 */ diff --git a/vscode/src/oss/handoffStore.ts b/vscode/src/oss/handoffStore.ts new file mode 100644 index 0000000..2ed6c33 --- /dev/null +++ b/vscode/src/oss/handoffStore.ts @@ -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 { + 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 { + const { stream, res } = await client(store).getStream(key) + const total = Number((res.headers as Record)['content-length'] ?? 0) || 0 + let received = 0 + let idle: ReturnType | 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 { + await client(store).delete(key) +} diff --git a/vscode/src/oss/handoffStoreSetup.ts b/vscode/src/oss/handoffStoreSetup.ts new file mode 100644 index 0000000..82ee1e9 --- /dev/null +++ b/vscode/src/oss/handoffStoreSetup.ts @@ -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 { + 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 { + 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 { + 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})`) +} diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index c56295d..39a20d4 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -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', diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index 35875de..f1e8b8e 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -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 { - return { handoffAttachmentId: String(attachmentId), handoffFrom: from } +export function handoffStartedStateExtra(bundleKey: string, from: string): Record { + 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 { +export function handoffStartedUiPatch(bundleKey: string, from: string, to: string, opts: { keepWorktreePath: boolean }): Record { return { - handoffAttachmentId: String(attachmentId), + handoffBundleKey: bundleKey, handoffFrom: from, assignees: [to], sessionId: null, @@ -97,7 +105,7 @@ export function handoffAcceptedStateExtra(local: { worktreePath?: string }): Record { return { - handoffAttachmentId: '', + handoffBundleKey: '', handoffFrom: '', ...definedEntries(local.sessions), ...definedEntries(local.profiles), @@ -113,7 +121,7 @@ export function handoffAcceptedUiPatch(local: { missingSessionIds: string[] }): Record { return { - handoffAttachmentId: null, + handoffBundleKey: null, handoffFrom: null, ...definedEntries(local.sessions), ...definedEntries(local.profiles), diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index eeb3ec2..cdfda12 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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 { return fsp.mkdtemp(path.join(os.tmpdir(), prefix)) } +/** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */ +async function requireHandoffStore(panel: KanbanWebviewPanel): Promise { + 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 { 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 { 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 { // 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 { diff --git a/vscode/src/panel/handlers/handoffRequest.test.ts b/vscode/src/panel/handlers/handoffRequest.test.ts index 81108a6..453d863 100644 --- a/vscode/src/panel/handlers/handoffRequest.test.ts +++ b/vscode/src/panel/handlers/handoffRequest.test.ts @@ -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) }) diff --git a/vscode/src/panel/handlers/handoffRequest.ts b/vscode/src/panel/handlers/handoffRequest.ts index cde49a9..5cdebdd 100644 --- a/vscode/src/panel/handlers/handoffRequest.ts +++ b/vscode/src/panel/handlers/handoffRequest.ts @@ -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 } /** 审查列里同事负责的工单,且当前没有别的申请。 */ diff --git a/vscode/src/panel/handlers/sessionHandoff.ts b/vscode/src/panel/handlers/sessionHandoff.ts index d59aca0..26091c0 100644 --- a/vscode/src/panel/handlers/sessionHandoff.ts +++ b/vscode/src/panel/handlers/sessionHandoff.ts @@ -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},不是你`) diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index fab7d97..5e226f9 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 } | { 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 } diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 5f9edfe..fdc29f0 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -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, } } diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index e91d2ed..4f7bc88 100644 --- a/vscode/src/youtrack/stateComment.ts +++ b/vscode/src/youtrack/stateComment.ts @@ -20,7 +20,7 @@ import { addComment, listComments } from './api' export const STATE_MARKER = '' /** 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 diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index e56fa5b..30cc57b 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -539,7 +539,7 @@ export function App() { setHandoffIssue(null)} onSubmit={(to) => { diff --git a/vscode/webview-ui/src/components/IssueCard.tsx b/vscode/webview-ui/src/components/IssueCard.tsx index 2f4895c..567953a 100644 --- a/vscode/webview-ui/src/components/IssueCard.tsx +++ b/vscode/webview-ui/src/components/IssueCard.tsx @@ -129,7 +129,7 @@ export const IssueCard = forwardRef( : null} {readOnly && ownerLogin ? : null} - {issue.handoffAttachmentId + {issue.handoffBundleKey ? ( - {issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && ( + {issue.handoffBundleKey && me !== undefined && (issue.assignees ?? []).includes(me) && ( )} - {!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))) && ( )} - {issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( + {issue.handoffBundleKey && !(me !== undefined && (issue.assignees ?? []).includes(me)) && ( void - /** 发起移交:删本机 worktree、打包会话记录挂到附件、把工单指派给对方。 */ + /** 发起移交:删本机 worktree、打包会话记录传到 OSS、把工单指派给对方。 */ startHandoff: (issueNumber: number, to: string) => void - /** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */ + /** 接管一个待接管工单:从 OSS 下载会话包、恢复会话与 worktree。 */ acceptHandoff: (issueNumber: number) => void /** 审查列里向同事申请把工单移交给自己。 */ askHandoff: (issueNumber: number) => void diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index f470acc..6ae3153 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -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 } | { 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 } diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 74122a7..5640044 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -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 /** 谁在申请把这个审查工单移交给自己。 */