✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI
- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写 - 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新 - 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版 - 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口 - session-handoff/accept 与待接收项改用 OSS 对象 key 标识 - 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表 - 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话 - 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
This commit is contained in:
1 parent
81fe14f6dc
commit
fcee4143d4
26 files changed
+505
-478
No files matched your search
@@ -27,7 +27,7 @@
|
||||
|
||||
- **发送方**:当前持有 worktree / 会话的机器(同事)。
|
||||
- **接管方**:目标 assignee 的机器(我)。
|
||||
- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。
|
||||
- **会话包**:tar.xz,OSS 对象 key 为 `spx-handoff/<owner>/<repo>/issue-<N>-<UTC 时间>.tar.xz`(时间形如 `20261008T010203Z`,同一工单多次移交不撞名)。会话交接的包另放在收件箱目录下,见下文「会话交接(单个会话)」。
|
||||
- **移交存储**:放会话包的 OSS 存储桶,见下文「移交存储配置」。
|
||||
|
||||
## 会话包格式
|
||||
@@ -77,7 +77,9 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
|
||||
|
||||
命令「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 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因)。
|
||||
也可以从 env 文件一次配好:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=<env 文件绝对路径>`,读文件里的 `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY`(luffy-agent-platform 后端 `.env` 的写法,`KEY=value`,注释和别的变量跳过;endpoint 去掉 `https://`),四项齐了按命令同样的方式保存并提示「移交存储已保存:<bucket>(<endpoint>)」;参数不对、文件读不到、缺项只报错(只报变量名),什么都不改。AccessKey 不进日志、不进提示。URI 参数值按入口的编码契约做两层 urlencode(见 `uri/uriHandler.ts`)。
|
||||
|
||||
四项缺一项就算没配置:移交、接管、同意移交申请,以及会话交接的发送、接收、刷新待接收列表,都直接 toast 提示去跑这个命令,什么都不改(同意申请时也不把它记成申请失败的原因;刷新时给 webview 回空列表)。
|
||||
|
||||
## 发送方:移交
|
||||
|
||||
@@ -121,6 +123,17 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
|
||||
|
||||
幂等:3、4 可重复执行;6 之前失败会话包仍在,按钮仍显示,用户可重试。
|
||||
|
||||
## 会话交接(单个会话)
|
||||
|
||||
会话 tab 里的一个 claude 会话交给同事,和工单无关,也走移交存储。
|
||||
|
||||
- **收件箱**:每人每仓库一个 OSS 目录 `spx-handoff/<owner>/<repo>/session/<收件人>/`。会话包 key 为 `<收件箱><sid>!<发送人>!<会话名 slug>.tar.xz`,slug 把字母、数字、`._-` 以外的字符换成 `_`、最长 40 字。owner / repo / 收件人 / 发送人只收 `[\w.-]`,sid 必须是 UUID,否则拒绝生成。owner / repo / 收件人转小写(Gitea 不分大小写,各人 remote 地址的写法可能不同)。key 的生成与解析在 `cc/sessionHandoff.ts`(`sessionInboxPrefix` / `sessionHandoffKey` / `parseSessionHandoffKey`)。
|
||||
- **包内容**:`handoff.json`(`kind: 'managed-session'`,含 from / to / workspacePath / session)+ `claude/<sid>.jsonl` + `claude/<sid>/`,xz 1 级,和工单移交共用 `packHandoff` / `unpackHandoff`。
|
||||
- **发送**(`session-handoff/start { sessionId, to }`):关掉该会话的终端等 600ms → 收集会话、写清单、打包 → 列对方收件箱,删掉同 sid 的旧包 → 分片上传(对象私有)→ 本机会话记录写 `handedOffTo` / `handedOffAt`。
|
||||
- **待接收列表**(`session-handoff/refresh` → `session-handoff/pending { items: [{ key, sid, from, name }] }`):列自己的收件箱,从 key 解析出 sid / 发送人 / 会话名,不下载。面板加载时和每次发送、接收后各拉一次,没有推送。
|
||||
- **接收**(`session-handoff/accept { key }`):key 必须解析得出、且落在我在当前仓库的收件箱里,否则拒绝 → 下载(包名固定,不拿 key 拼本机路径)→ 解包、校验清单(收件人是我、会话 id 和 key 一致)→ 工作区路径不同就改写会话里的路径 → 装到 `projectsDirFor(workspaceRoot)`(先清同 sid 副本)→ 写会话 tab 记录 → 删 OSS 上的包(失败只 warn)。包已不在(别处收过)时提示并刷新列表。
|
||||
- 进度提示和工单移交一样:同一条 toast 依次显示 关闭终端、收集会话、`打包 x MB`、`上传 x MB / y MB`;接收方 `下载 x MB / y MB`、解包、装会话、写入会话列表,完成前不消失。
|
||||
|
||||
## 附带修正
|
||||
|
||||
完成列流程里「把 jsonl 拷回主仓库」(`panel/handlers/issues.ts` ~L388)改为整包**搬迁**:jsonl + `<sid>/` 一起移动到 `projectsDirFor(workspaceRoot)`,并删除源文件——否则新版 claude 因重复 sid 拒绝 resume。与接管方第 4 步共用同一个安装函数。
|
||||
@@ -131,14 +144,18 @@ 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)`、OSS 对象 key `handoffBundleKey(...)` | 纯函数 vitest |
|
||||
| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级;另有会话交接(单会话,仍走 Gitea 附件,白名单没有 .xz)用的 gzip 版 `packSessionHandoff` / `unpackSessionHandoff` | 真 tar 往返 vitest |
|
||||
| `cc/handoffArchive.ts` | `packHandoff(stagingDir, outFile, { onProgress })` / `unpackHandoff(file, dstDir)`,xz 1 级,工单移交和会话交接共用 | 真 tar 往返 vitest |
|
||||
| `cc/sessionHandoff.ts` | 会话交接:收件箱前缀、包 key 的生成与解析,`managed-session` 清单 | 纯函数 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` | — |
|
||||
| `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle` | 真机往返脚本(不进仓库) |
|
||||
| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令 | — |
|
||||
| `oss/handoffStore.ts` | 纯 Node:`uploadHandoffBundle`(分片上传、私有权限、进度)、`downloadHandoffBundle`(流式写文件、进度、空闲超时)、`deleteHandoffBundle`、`listHandoffBundles`(按前缀列、翻页)、`isHandoffBundleMissing` | 真机往返脚本(不进仓库) |
|
||||
| `oss/handoffStoreSetup.ts` | `readHandoffStore(ctx)`、「配置移交存储」命令、`saveHandoffStore`(命令和 URI 共用) | — |
|
||||
| `oss/handoffStoreEnv.ts` | `parseHandoffStoreEnv(text)`:从 env 文本读四项 | 纯函数 vitest |
|
||||
| `uri/uriHandler.ts` | 外部 URI 按 path 分发:`/create-session`、`/configure-handoff-store`,未知 path 报错 | — |
|
||||
| `panel/handlers/handoff.ts` | 纯决策:`canStartHandoff(issue)`、`canAcceptHandoff(issue, me)`、`handoffStateExtra` / `handoffUiPatch` 两套 payload、`worktreePushedCheck` 结果判定、`transferProgressText` 进度文案 | vitest |
|
||||
| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers` | — |
|
||||
| `panel/handlers/handoffFlow.ts` | 两个 orchestrator:`handleHandoffStart`、`handleHandoffAccept`、`handleHandoffUsers`;`progressToast`、`requireHandoffStore` 会话交接也用 | — |
|
||||
| `panel/handlers/sessionHandoff.ts` | 会话交接:选人、发送、待接收列表、接收 | — |
|
||||
| webview | `HandoffModal.tsx`、`IssueDetailPanel` 两个按钮、`IssueCard` 角标、`useIssues` 三个 postMessage、`me` 状态 | — |
|
||||
|
||||
消息(`src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts` 镜像):
|
||||
@@ -150,6 +167,7 @@ codex/YYYY/MM/DD/rollout-*.jsonl 审查会话(存在才有)
|
||||
|
||||
- `pnpm test` / `pnpm lint` / `pnpm typecheck`;`cd cli && make test`。
|
||||
- OSS 真机往返(临时脚本,不进仓库,对象放 `spx-handoff/_selftest/` 下):上传 → 匿名 `curl` 必须 403 → 下载 → 解包一致 → 删除 → 匿名 `curl` 404、带密钥下载报 NoSuchKey。分片上传(≥100KB)和单次上传(<100KB)两条路都测私有权限。
|
||||
- 会话交接真机往返(同上):上传进收件箱 → 按收件人前缀列到、key 解析回 sid / 发送人 / 会话名,相近名字的收件箱列不到 → 匿名 `curl` 403 → 同一 sid 再交一次只剩新包 → 下载解包一致 → 删除后收件箱为空、再下载报对象不存在 → `_selftest/` 下 0 个对象。
|
||||
- 端到端:本机用两个 VS Code 窗口(不同 Gitea 账号 token)走一遍 移交 → 接管 → 恢复实施会话 → 拖到完成。
|
||||
|
||||
## 不做
|
||||
|
||||
+3
-1
@@ -91,12 +91,14 @@ cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/
|
||||
|
||||
**Profile tab**:工作区级 KV 表(行 = key,列 = dev / prod 等),值可多行、可贴图,落盘 `.spx/profiles.json`。
|
||||
|
||||
**会话 tab**:项目级 Claude 会话管理器——新建(可指定 profile 和首条 prompt)、重命名、恢复、导入已 `/rename` 的会话。落盘 `.spx/session-names.json`。
|
||||
**会话 tab**:项目级 Claude 会话管理器——新建(可指定 profile 和首条 prompt)、重命名、恢复、导入已 `/rename` 的会话。落盘 `.spx/session-names.json`。单个会话可以「交接」给同事:会话包打成 xz 包、以私有权限传到移交存储(同工单移交,没配会直接提示去跑「配置移交存储」)里对方的收件箱 `spx-handoff/<owner>/<repo>/session/<对方>/`;对方会话 tab 顶部「待接收」列出自己收件箱里的包,点「接收」下载装好会话、删掉 OSS 上的包。同一会话再交给同一人只保留最新一份;两边进度提示一步步显示打包 / 上传 / 下载了多少 MB。
|
||||
|
||||
**改动 tab**:PR 文件审阅。左侧文件树 + 统计(可过滤 `tests/`),右侧内联 diff(统一 / 分栏),「已查看」按文件变更指纹失效,每个文件可用 deepseek 生成一句话改动说明。
|
||||
|
||||
也可以从外部拉起会话:`vscode://clurdra.superpowers-vscode-clurdra/create-session?profile=<名>&name=<tab名>&prompt=<提示词>&cwd=<路径>`(参数需两层 urlencode)。
|
||||
|
||||
从 env 文件配置移交存储:`vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=<env 文件绝对路径>`(参数同样两层 urlencode),读文件里的 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY`,四项齐了就保存,缺项或读不到只报错、什么都不改。
|
||||
|
||||
## 会话与 profile
|
||||
|
||||
| 会话 | 终端名 | CLI | 工作目录 | profile |
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statS
|
||||
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'
|
||||
import { formatByteSize, packHandoff, unpackHandoff } from './handoffArchive'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -72,16 +72,3 @@ describe('packHandoff / unpackHandoff', () => {
|
||||
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'))
|
||||
})
|
||||
})
|
||||
@@ -64,7 +64,7 @@ async function tarPack(args: string[], outFile: string, onProgress?: (packedByte
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单移交的会话包:tar.xz,xz 1 级。会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB
|
||||
* 工单移交、会话交接的会话包:tar.xz,xz 1 级。会话 jsonl 里的重复内容隔得很远,gzip 只看 32KB
|
||||
* 以内的重复,压不动;xz 1 级能压到 gzip 的三分之一左右,速度也够。用的是 macOS 自带
|
||||
* bsdtar 内建的 xz,不用另装软件。
|
||||
*/
|
||||
@@ -80,13 +80,3 @@ export async function unpackHandoff(archive: string, dstDir: string): Promise<vo
|
||||
await fsp.mkdir(dstDir, { recursive: true })
|
||||
await tarUnpack(['-xJf', archive, '-C', dstDir])
|
||||
}
|
||||
|
||||
/** 会话交接(单个会话)的包:tgz。它挂在 Gitea 工单附件上,Gitea 的附件白名单里没有 .xz。 */
|
||||
export async function packSessionHandoff(stagingDir: string, outFile: string): Promise<void> {
|
||||
await tarPack(['-czf', outFile, '-C', stagingDir, '.'], outFile)
|
||||
}
|
||||
|
||||
export async function unpackSessionHandoff(archive: string, dstDir: string): Promise<void> {
|
||||
await fsp.mkdir(dstDir, { recursive: true })
|
||||
await tarUnpack(['-xzf', archive, '-C', dstDir])
|
||||
}
|
||||
@@ -1,68 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { handoffBundleKey } from './handoffManifest'
|
||||
import {
|
||||
buildSessionHandoffManifest,
|
||||
isMailboxIssue,
|
||||
MAILBOX_MARKER,
|
||||
MAILBOX_TITLE,
|
||||
parseSessionHandoffAttachmentName,
|
||||
parseSessionHandoffKey,
|
||||
parseSessionHandoffManifest,
|
||||
sessionHandoffAttachmentName,
|
||||
sessionHandoffKey,
|
||||
sessionInboxPrefix,
|
||||
slugSessionName,
|
||||
} from './sessionHandoff'
|
||||
|
||||
const SID = '11111111-1111-4111-8111-111111111111'
|
||||
const REPO = { owner: 'hkjx', repo: 'luffy-agent-platform' }
|
||||
|
||||
describe('isMailboxIssue', () => {
|
||||
it('title 命中', () => {
|
||||
expect(isMailboxIssue({ title: MAILBOX_TITLE })).toBe(true)
|
||||
describe('sessionInboxPrefix', () => {
|
||||
it('收件人是目录,结尾带 /', () => {
|
||||
expect(sessionInboxPrefix({ ...REPO, to: 'cruldra' })).toBe('spx-handoff/hkjx/luffy-agent-platform/session/cruldra/')
|
||||
})
|
||||
it('label 命中', () => {
|
||||
expect(isMailboxIssue({ title: '其他', labels: [{ name: 'spx-session-mailbox' }] })).toBe(true)
|
||||
it('owner / repo / 收件人不分大小写,都转小写', () => {
|
||||
expect(sessionInboxPrefix({ owner: 'HKJX', repo: 'Luffy-Agent-Platform', to: 'Cruldra' })).toBe(sessionInboxPrefix({ ...REPO, to: 'cruldra' }))
|
||||
})
|
||||
it('body marker 命中', () => {
|
||||
expect(isMailboxIssue({ title: 'x', body: `hello ${MAILBOX_MARKER}` })).toBe(true)
|
||||
it('bob 的收件箱前缀不是 bobby 包的前缀', () => {
|
||||
const bobby = sessionHandoffKey({ ...REPO, to: 'bobby', sid: SID, from: 'a', name: '' })
|
||||
expect(bobby.startsWith(sessionInboxPrefix({ ...REPO, to: 'bob' }))).toBe(false)
|
||||
})
|
||||
it('普通工单不是', () => {
|
||||
expect(isMailboxIssue({ title: '修登录', body: 'foo', labels: [{ name: 'bug' }] })).toBe(false)
|
||||
it.each([
|
||||
['owner', { owner: 'a/b', repo: 'r', to: 'c' }],
|
||||
['repo', { owner: 'o', repo: '../x', to: 'c' }],
|
||||
['to', { owner: 'o', repo: 'r', to: 'c!d' }],
|
||||
['to', { owner: 'o', repo: 'r', to: '' }],
|
||||
])('%s 非法 → 抛错', (label, opts) => {
|
||||
expect(() => sessionInboxPrefix(opts)).toThrow(new RegExp(label))
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionHandoffAttachmentName', () => {
|
||||
describe('sessionHandoffKey / parseSessionHandoffKey', () => {
|
||||
it('编解码往返', () => {
|
||||
const name = sessionHandoffAttachmentName({
|
||||
sid: SID,
|
||||
from: 'chw',
|
||||
to: 'cruldra',
|
||||
name: '测试服初始化',
|
||||
const key = sessionHandoffKey({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: '测试服初始化' })
|
||||
expect(key).toBe(`spx-handoff/hkjx/luffy-agent-platform/session/cruldra/${SID}!chw!测试服初始化.tar.xz`)
|
||||
expect(parseSessionHandoffKey(key)).toEqual({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: '测试服初始化' })
|
||||
})
|
||||
expect(name).toBe(`spx-handoff-session!${SID}!chw!cruldra!测试服初始化.tgz`)
|
||||
expect(parseSessionHandoffAttachmentName(name)).toEqual({
|
||||
sid: SID,
|
||||
from: 'chw',
|
||||
to: 'cruldra',
|
||||
name: '测试服初始化',
|
||||
it('发送人和会话名保留大小写', () => {
|
||||
const key = sessionHandoffKey({ owner: 'HKJX', repo: 'R', to: 'Bob', sid: SID, from: 'Alice', name: 'Fix-Login' })
|
||||
expect(parseSessionHandoffKey(key)).toEqual({ owner: 'hkjx', repo: 'r', to: 'bob', sid: SID, from: 'Alice', name: 'Fix-Login' })
|
||||
})
|
||||
it('key 落在收件人的收件箱里', () => {
|
||||
const key = sessionHandoffKey({ ...REPO, to: 'cruldra', sid: SID, from: 'chw', name: 'x' })
|
||||
expect(key.startsWith(sessionInboxPrefix({ ...REPO, to: 'cruldra' }))).toBe(true)
|
||||
})
|
||||
it('空名字也合法', () => {
|
||||
const name = sessionHandoffAttachmentName({ sid: SID, from: 'a', to: 'b', name: '' })
|
||||
expect(parseSessionHandoffAttachmentName(name)).toEqual({
|
||||
sid: SID,
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
name: '',
|
||||
const key = sessionHandoffKey({ ...REPO, to: 'b', sid: SID, from: 'a', name: ' ' })
|
||||
expect(parseSessionHandoffKey(key)).toEqual({ ...REPO, to: 'b', sid: SID, from: 'a', name: '' })
|
||||
})
|
||||
it('会话名里的 / ! 空格变成 _,不会弄乱 key', () => {
|
||||
const key = sessionHandoffKey({ ...REPO, to: 'b', sid: SID, from: 'a', name: 'foo/bar!baz qux' })
|
||||
expect(parseSessionHandoffKey(key)?.name).toBe('foo_bar_baz_qux')
|
||||
})
|
||||
it('slug 去掉非法字符', () => {
|
||||
expect(slugSessionName('foo/bar baz')).toBe('foo_bar_baz')
|
||||
})
|
||||
it('sid 非 UUID → 抛错', () => {
|
||||
expect(() => sessionHandoffAttachmentName({ sid: '../x', from: 'a', to: 'b', name: '' })).toThrow(/UUID/)
|
||||
expect(() => sessionHandoffKey({ ...REPO, to: 'b', sid: '../x', from: 'a', name: '' })).toThrow(/UUID/)
|
||||
})
|
||||
it('login 含 ! → 抛错', () => {
|
||||
expect(() => sessionHandoffAttachmentName({ sid: SID, from: 'a!b', to: 'c', name: '' })).toThrow(/from/)
|
||||
it('from 含 ! → 抛错', () => {
|
||||
expect(() => sessionHandoffKey({ ...REPO, to: 'c', sid: SID, from: 'a!b', name: '' })).toThrow(/from/)
|
||||
})
|
||||
it('工单移交包名解析不到', () => {
|
||||
expect(parseSessionHandoffAttachmentName('spx-handoff-issue-12.tgz')).toBeUndefined()
|
||||
it('to 含 / → 抛错', () => {
|
||||
expect(() => sessionHandoffKey({ ...REPO, to: 'c/d', sid: SID, from: 'a', name: '' })).toThrow(/to/)
|
||||
})
|
||||
it.each([
|
||||
['工单移交的包', handoffBundleKey({ ...REPO, issueNumber: 12, at: new Date('2026-10-08T01:02:03Z') })],
|
||||
['不在收件箱目录里的文件名', `spx-handoff-session!${SID}!a!b!x.tgz`],
|
||||
['收件箱下面多一层目录', `spx-handoff/o/r/session/b/sub/${SID}!a!x.tar.xz`],
|
||||
['sid 不是 UUID', 'spx-handoff/o/r/session/b/not-a-uuid!a!x.tar.xz'],
|
||||
['扩展名不对', `spx-handoff/o/r/session/b/${SID}!a!x.tgz`],
|
||||
['少了发送人', `spx-handoff/o/r/session/b/${SID}!x.tar.xz`],
|
||||
])('%s 解析不到', (_label, key) => {
|
||||
expect(parseSessionHandoffKey(key)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
/**
|
||||
* 会话交接:mailbox 工单识别、附件文件名、managed-session 清单。
|
||||
* 会话交接:会话包在 OSS 上的对象 key(收件箱)、managed-session 清单。
|
||||
*
|
||||
* 与工单移交的 handoff.json 分开:那边有 issue/worktree/四种 sid,这边只有一个
|
||||
* claude 会话。混用解析会把会话包当工单包读崩。
|
||||
*/
|
||||
|
||||
export const MAILBOX_TITLE = 'spx-session-mailbox'
|
||||
export const MAILBOX_LABEL = 'spx-session-mailbox'
|
||||
export const MAILBOX_MARKER = '<!-- spx:session-mailbox -->'
|
||||
export const MAILBOX_BODY = `${MAILBOX_MARKER}
|
||||
Superwork 会话交接邮箱。不要当任务处理,不要关闭。
|
||||
`
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const LOGIN_RE = /^[\w.-]+$/
|
||||
const FILENAME_RE = /^spx-handoff-session!([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([\w.-]+)!([^!]*)\.tgz$/i
|
||||
/** Gitea 的用户名、仓库名只有字母、数字和 `_` `.` `-`;`/`、`!` 这类会弄乱 key 的字符一律不收。 */
|
||||
const NAME_RE = /^[\w.-]+$/
|
||||
const KEY_RE = /^spx-handoff\/([\w.-]+)\/([\w.-]+)\/session\/([\w.-]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([^!/]*)\.tar\.xz$/i
|
||||
|
||||
export interface SessionHandoffManifest {
|
||||
version: 1
|
||||
@@ -27,25 +21,21 @@ export interface SessionHandoffManifest {
|
||||
claude: Array<{ id: string }>
|
||||
}
|
||||
|
||||
export interface SessionHandoffAttachmentRef {
|
||||
/** 一个会话包是谁在哪个仓库交给谁的:全都编在它的对象 key 里,列出收件箱不用下载就能显示。 */
|
||||
export interface SessionHandoffRef {
|
||||
/** owner、repo、to 是转成小写后的,见 [sessionInboxPrefix] */
|
||||
owner: string
|
||||
repo: string
|
||||
to: string
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
/** 会话名经 [slugSessionName] 处理后的样子,可能为空 */
|
||||
name: string
|
||||
}
|
||||
|
||||
export function isMailboxIssue(issue: {
|
||||
title?: string
|
||||
body?: string
|
||||
labels?: Array<{ name: string }> | null
|
||||
}): boolean {
|
||||
if (issue.title === MAILBOX_TITLE)
|
||||
return true
|
||||
if ((issue.labels ?? []).some(l => l.name === MAILBOX_LABEL))
|
||||
return true
|
||||
if (typeof issue.body === 'string' && issue.body.includes(MAILBOX_MARKER))
|
||||
return true
|
||||
return false
|
||||
function requireName(label: string, value: string): void {
|
||||
if (!NAME_RE.test(value))
|
||||
throw new Error(`${label} 含非法字符:${value}`)
|
||||
}
|
||||
|
||||
export function slugSessionName(name: string): string {
|
||||
@@ -55,26 +45,39 @@ export function slugSessionName(name: string): string {
|
||||
return trimmed.replace(/[^\p{L}\p{N}._-]+/gu, '_').slice(0, 40)
|
||||
}
|
||||
|
||||
export function sessionHandoffAttachmentName(opts: {
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}): string {
|
||||
if (!UUID_RE.test(opts.sid))
|
||||
throw new Error(`会话 id 不是 UUID:${opts.sid}`)
|
||||
if (!LOGIN_RE.test(opts.from))
|
||||
throw new Error(`from 含非法字符:${opts.from}`)
|
||||
if (!LOGIN_RE.test(opts.to))
|
||||
throw new Error(`to 含非法字符:${opts.to}`)
|
||||
return `spx-handoff-session!${opts.sid}!${opts.from}!${opts.to}!${slugSessionName(opts.name)}.tgz`
|
||||
/**
|
||||
* `to` 在这个仓库的会话交接收件箱:OSS 上的目录前缀 `spx-handoff/<owner>/<repo>/session/<to>/`。
|
||||
* 结尾带 `/`,列 `bob` 的收件箱不会列到 `bobby` 的包。三段都转小写:Gitea 的用户名、仓库名
|
||||
* 不分大小写,各人 git remote 地址里的写法可能不一样,转小写两边才对得上同一个收件箱。
|
||||
*
|
||||
* @throws owner / repo / to 含 `[\w.-]` 以外的字符。
|
||||
*/
|
||||
export function sessionInboxPrefix(opts: { owner: string, repo: string, to: string }): string {
|
||||
requireName('owner', opts.owner)
|
||||
requireName('repo', opts.repo)
|
||||
requireName('to', opts.to)
|
||||
return `spx-handoff/${opts.owner}/${opts.repo}/session/${opts.to}/`.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseSessionHandoffAttachmentName(filename: string): SessionHandoffAttachmentRef | undefined {
|
||||
const m = FILENAME_RE.exec(filename)
|
||||
/**
|
||||
* 会话包的对象 key:收件箱前缀后面跟 `<sid>!<from>!<会话名>.tar.xz`。
|
||||
*
|
||||
* @throws sid 不是 UUID,或 owner / repo / from / to 含 `[\w.-]` 以外的字符。
|
||||
*/
|
||||
export function sessionHandoffKey(opts: { owner: string, repo: string, to: string, sid: string, from: string, name: string }): string {
|
||||
const prefix = sessionInboxPrefix(opts)
|
||||
if (!UUID_RE.test(opts.sid))
|
||||
throw new Error(`会话 id 不是 UUID:${opts.sid}`)
|
||||
requireName('from', opts.from)
|
||||
return `${prefix}${opts.sid}!${opts.from}!${slugSessionName(opts.name)}.tar.xz`
|
||||
}
|
||||
|
||||
/** 把对象 key 拆回 [SessionHandoffRef];不是 [sessionHandoffKey] 生成的样子就返回 undefined。 */
|
||||
export function parseSessionHandoffKey(key: string): SessionHandoffRef | undefined {
|
||||
const m = KEY_RE.exec(key)
|
||||
if (!m)
|
||||
return undefined
|
||||
return { sid: m[1], from: m[2], to: m[3], name: m[4] ?? '' }
|
||||
return { owner: m[1], repo: m[2], to: m[3], sid: m[4], from: m[5], name: m[6] }
|
||||
}
|
||||
|
||||
function optString(v: unknown): string | undefined {
|
||||
@@ -95,7 +98,7 @@ export function parseSessionHandoffManifest(json: string): SessionHandoffManifes
|
||||
const workspacePath = optString(raw.workspacePath)
|
||||
if (!from || !to || !createdAt || !workspacePath)
|
||||
throw new Error('handoff.json 缺少 from / to / createdAt / workspacePath')
|
||||
if (!LOGIN_RE.test(from) || !LOGIN_RE.test(to))
|
||||
if (!NAME_RE.test(from) || !NAME_RE.test(to))
|
||||
throw new Error('handoff.json from/to 非法')
|
||||
const sessionRaw = (raw.session ?? {}) as Record<string, unknown>
|
||||
const id = optString(sessionRaw.id)
|
||||
@@ -134,7 +137,7 @@ export function buildSessionHandoffManifest(opts: {
|
||||
}): SessionHandoffManifest {
|
||||
if (!UUID_RE.test(opts.session.id))
|
||||
throw new Error(`会话 id 不是 UUID:${opts.session.id}`)
|
||||
if (!LOGIN_RE.test(opts.from) || !LOGIN_RE.test(opts.to))
|
||||
if (!NAME_RE.test(opts.from) || !NAME_RE.test(opts.to))
|
||||
throw new Error('from/to 非法')
|
||||
return {
|
||||
version: 1,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { CONFIGURE_HANDOFF_STORE_COMMAND, configureHandoffStore } from './oss/ha
|
||||
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
||||
import { offerPromptUpdates } from './settings/promptUpdate'
|
||||
import { getSettings, migratePromptTemplates, persistSeededProfiles } from './settings/store'
|
||||
import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler'
|
||||
import { registerUriHandler } from './uri/uriHandler'
|
||||
import { webhookCoordinator } from './webhook/coordinator'
|
||||
|
||||
/**
|
||||
@@ -56,8 +56,8 @@ export function activate(context: ExtensionContext): SuperpowersApi {
|
||||
// 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。
|
||||
void initIdentity(context)
|
||||
|
||||
// vscode://clurdra.superpowers-vscode-clurdra/create-session 外部触发开 cc 会话 tab。
|
||||
registerCreateSessionUriHandler(context)
|
||||
// vscode://clurdra.superpowers-vscode-clurdra/<path> 外部入口:开 cc 会话 tab、从 env 文件配置移交存储。
|
||||
registerUriHandler(context)
|
||||
|
||||
const treeView = window.createTreeView('superpowers.kanban', {
|
||||
treeDataProvider: new EmptyTreeProvider(),
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* distinguish 401 (token invalid) from other failure modes.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
import { logger } from '../logging/logger'
|
||||
|
||||
export const PAGE_SIZE = 50
|
||||
@@ -100,8 +98,6 @@ export async function listIssuesByFilter(opts: {
|
||||
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
|
||||
filter?: 'assigned_by' | 'created_by'
|
||||
user?: string
|
||||
/** 逗号分隔的 label 名。label 必须已存在,否则 Gitea 会丢掉过滤条件。 */
|
||||
labels?: string
|
||||
}): Promise<GiteaIssue[]> {
|
||||
const out: GiteaIssue[] = []
|
||||
let page = 1
|
||||
@@ -113,8 +109,6 @@ export async function listIssuesByFilter(opts: {
|
||||
url.searchParams.set('state', 'all')
|
||||
if (opts.filter && opts.user)
|
||||
url.searchParams.set(opts.filter, opts.user)
|
||||
if (opts.labels)
|
||||
url.searchParams.set('labels', opts.labels)
|
||||
url.searchParams.set('limit', String(PAGE_SIZE))
|
||||
url.searchParams.set('page', String(page))
|
||||
|
||||
@@ -131,77 +125,6 @@ export async function listIssuesByFilter(opts: {
|
||||
return out
|
||||
}
|
||||
|
||||
export interface GiteaLabel {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export async function listRepoLabels(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
}): Promise<GiteaLabel[]> {
|
||||
const out: GiteaLabel[] = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`)
|
||||
url.searchParams.set('limit', String(PAGE_SIZE))
|
||||
url.searchParams.set('page', String(page))
|
||||
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
|
||||
await ensureOk(res)
|
||||
const batch = await res.json() as GiteaLabel[]
|
||||
if (!Array.isArray(batch) || batch.length === 0)
|
||||
break
|
||||
out.push(...batch)
|
||||
if (batch.length < PAGE_SIZE)
|
||||
break
|
||||
page += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function createRepoLabel(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
name: string
|
||||
color: string
|
||||
description?: string
|
||||
}): Promise<GiteaLabel> {
|
||||
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/labels`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: opts.name, color: opts.color, description: opts.description ?? '' }),
|
||||
})
|
||||
await ensureOk(res)
|
||||
return (await res.json()) as GiteaLabel
|
||||
}
|
||||
|
||||
export async function createIssue(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
title: string
|
||||
body: string
|
||||
labels?: number[]
|
||||
}): Promise<GiteaIssue> {
|
||||
const res = await fetch(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders(opts.token), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
...(opts.labels && opts.labels.length > 0 ? { labels: opts.labels } : {}),
|
||||
}),
|
||||
})
|
||||
await ensureOk(res)
|
||||
return (await res.json()) as GiteaIssue
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which
|
||||
* returns every comment in the repo across all issues. Paginated until a short
|
||||
@@ -713,15 +636,6 @@ export async function getRawFile(opts: {
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export interface GiteaAttachment {
|
||||
id: number
|
||||
name: string
|
||||
size: number
|
||||
uuid: string
|
||||
browser_download_url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 可被指派的用户(仓库协作者 + owner),移交选人用。 */
|
||||
export async function listRepoAssignees(opts: {
|
||||
host: string
|
||||
@@ -736,72 +650,3 @@ export async function listRepoAssignees(opts: {
|
||||
await ensureOk(res)
|
||||
return (await res.json()) as GiteaUser[]
|
||||
}
|
||||
|
||||
export async function listIssueAttachments(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
index: number
|
||||
}): Promise<GiteaAttachment[]> {
|
||||
const res = await fetch(
|
||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`,
|
||||
{ headers: authHeaders(opts.token) },
|
||||
)
|
||||
await ensureOk(res)
|
||||
return (await res.json()) as GiteaAttachment[]
|
||||
}
|
||||
|
||||
export async function uploadIssueAttachment(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
index: number
|
||||
name: string
|
||||
data: Buffer
|
||||
}): Promise<GiteaAttachment> {
|
||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets`)
|
||||
url.searchParams.set('name', opts.name)
|
||||
const form = new FormData()
|
||||
// Buffer 的底层 ArrayBufferLike 包含 SharedArrayBuffer,BlobPart 只认 ArrayBuffer;
|
||||
// 用 Uint8Array 包一层拷贝到普通 ArrayBuffer 上,绕开这条 TS 类型不兼容。
|
||||
form.append('attachment', new Blob([new Uint8Array(opts.data)], { type: 'application/gzip' }), opts.name)
|
||||
// 不能带 Content-Type:multipart boundary 由 fetch 按 FormData 自动生成。
|
||||
const res = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `token ${opts.token}`, Accept: 'application/json' },
|
||||
body: form,
|
||||
})
|
||||
await ensureOk(res)
|
||||
return (await res.json()) as GiteaAttachment
|
||||
}
|
||||
|
||||
/**
|
||||
* `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
|
||||
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
|
||||
* 所以除 ok 外还要拒掉 HTML 响应,别把登录页当 tgz 存下来。
|
||||
*/
|
||||
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer> {
|
||||
const res = await fetch(opts.url, { headers: { Authorization: `token ${opts.token}` }, redirect: 'follow' })
|
||||
await ensureOk(res)
|
||||
const contentType = res.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('text/html'))
|
||||
throw new GiteaApiError(res.status, `附件下载被重定向到页面(鉴权失败?):${opts.url}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
export async function deleteIssueAttachment(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
index: number
|
||||
attachmentId: number
|
||||
}): Promise<void> {
|
||||
const res = await fetch(
|
||||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
|
||||
{ method: 'DELETE', headers: authHeaders(opts.token) },
|
||||
)
|
||||
await ensureOk(res)
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
import type { GiteaComment, GiteaIssue } from './api'
|
||||
import type { Issue, IssueColumn } from './types'
|
||||
import * as fs from 'node:fs'
|
||||
import { isMailboxIssue } from '../cc/sessionHandoff'
|
||||
import { resolveWorktreePath } from '../git/worktree'
|
||||
import {
|
||||
getCurrentUser,
|
||||
@@ -488,7 +487,6 @@ export async function loadIssues(opts: {
|
||||
])
|
||||
merged = mergeIssues(assigned, created)
|
||||
}
|
||||
merged = merged.filter(issue => !isMailboxIssue(issue))
|
||||
|
||||
// ② 每张工单读取一次评论,再从中解析 column
|
||||
const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue =>
|
||||
@@ -590,7 +588,7 @@ export async function loadSingleIssue(opts: {
|
||||
const { host, token, owner, repo, workspaceRoot, issueNumber } = opts
|
||||
|
||||
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
|
||||
if (!issue || isMailboxIssue(issue))
|
||||
if (!issue)
|
||||
return null
|
||||
|
||||
const [comments, prerequisite] = await Promise.all([
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { GiteaLabel } from './api'
|
||||
import { isMailboxIssue, MAILBOX_BODY, MAILBOX_LABEL, MAILBOX_TITLE } from '../cc/sessionHandoff'
|
||||
import { createIssue, createRepoLabel, listIssuesByFilter, listRepoLabels } from './api'
|
||||
|
||||
export async function findMailboxIssue(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
}): Promise<number | undefined> {
|
||||
const labels = await listRepoLabels(opts)
|
||||
if (!labels.some(l => l.name === MAILBOX_LABEL))
|
||||
return undefined
|
||||
const issues = await listIssuesByFilter({ ...opts, labels: MAILBOX_LABEL })
|
||||
return issues.find(i => isMailboxIssue(i))?.number
|
||||
}
|
||||
|
||||
export async function ensureMailboxIssue(opts: {
|
||||
host: string
|
||||
token: string
|
||||
owner: string
|
||||
repo: string
|
||||
}): Promise<number> {
|
||||
const existing = await findMailboxIssue(opts)
|
||||
if (existing !== undefined)
|
||||
return existing
|
||||
const labels = await listRepoLabels(opts)
|
||||
let label: GiteaLabel | undefined = labels.find(l => l.name === MAILBOX_LABEL)
|
||||
if (!label)
|
||||
label = await createRepoLabel({ ...opts, name: MAILBOX_LABEL, color: '586069', description: 'Superwork session mailbox' })
|
||||
const created = await createIssue({
|
||||
...opts,
|
||||
title: MAILBOX_TITLE,
|
||||
body: MAILBOX_BODY,
|
||||
labels: [label.id],
|
||||
})
|
||||
return created.number
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 工单移交的会话包放在阿里云 OSS 上:发送方上传,接管方下载后删掉。
|
||||
* 工单移交和会话交接的会话包放在阿里云 OSS 上:发送方上传,接收方下载后删掉。
|
||||
*
|
||||
* 这里只管传文件,不碰 VS Code;配置从哪来见 `src/oss/handoffStoreSetup.ts`。
|
||||
*/
|
||||
@@ -107,6 +107,32 @@ export async function downloadHandoffBundle(
|
||||
throw new Error(`下载不完整:收到 ${received} 字节,应为 ${total} 字节`)
|
||||
}
|
||||
|
||||
/** `err` 是不是「对象不存在」:会话包已经被别人收走或删掉了。 */
|
||||
export function isHandoffBundleMissing(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'NoSuchKey'
|
||||
}
|
||||
|
||||
export async function deleteHandoffBundle(store: HandoffStore, key: string): Promise<void> {
|
||||
await client(store).delete(key)
|
||||
}
|
||||
|
||||
/** OSS 上的一个会话包。 */
|
||||
export interface HandoffBundleObject {
|
||||
key: string
|
||||
size: number
|
||||
/** ISO 时间,如 `2026-10-08T01:02:03.000Z` */
|
||||
lastModified: string
|
||||
}
|
||||
|
||||
/** 列出 key 以 `prefix` 开头的全部对象。OSS 一页最多给 1000 个,这里翻页直到列完。 */
|
||||
export async function listHandoffBundles(store: HandoffStore, prefix: string): Promise<HandoffBundleObject[]> {
|
||||
const oss = client(store)
|
||||
const out: HandoffBundleObject[] = []
|
||||
let token: string | undefined
|
||||
do {
|
||||
const page = await oss.listV2({ 'prefix': prefix, 'max-keys': 1000, ...(token ? { 'continuation-token': token } : {}) })
|
||||
out.push(...page.objects.map(o => ({ key: o.name, size: o.size, lastModified: o.lastModified })))
|
||||
token = page.isTruncated ? page.nextContinuationToken : undefined
|
||||
} while (token)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseHandoffStoreEnv } from './handoffStoreEnv'
|
||||
|
||||
const STORE = {
|
||||
endpoint: 'oss-cn-beijing.aliyuncs.com',
|
||||
bucket: 'luffy-agent-platform',
|
||||
accessKeyId: 'AK-ID',
|
||||
accessKeySecret: 'AK-SECRET',
|
||||
}
|
||||
|
||||
describe('parseHandoffStoreEnv', () => {
|
||||
it('luffy 后端 .env 的写法:跳过注释、空行和别的变量', () => {
|
||||
const text = [
|
||||
'# 数据库',
|
||||
'DATABASE_URL=postgres://x',
|
||||
'',
|
||||
'OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com',
|
||||
'OSS_ACCESS_KEY=AK-ID',
|
||||
'OSS_SECRET_KEY=AK-SECRET',
|
||||
'OSS_BUCKET_NAME=luffy-agent-platform',
|
||||
'OSS_PUBLIC_BASE=https://cdn.example.com',
|
||||
].join('\n')
|
||||
expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE })
|
||||
})
|
||||
|
||||
it('认 CRLF 换行、export 前缀、等号两边空格、成对引号和行尾注释', () => {
|
||||
const text = [
|
||||
'export OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"',
|
||||
'OSS_BUCKET_NAME=luffy-agent-platform # 正式服',
|
||||
'OSS_ACCESS_KEY=\'AK-ID\'',
|
||||
'OSS_SECRET_KEY=AK-SECRET',
|
||||
].join('\r\n')
|
||||
expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE })
|
||||
})
|
||||
|
||||
it('值里的 # 不挨着空格就不算注释', () => {
|
||||
const text = 'OSS_ENDPOINT=e\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s#ec+ret/=='
|
||||
const result = parseHandoffStoreEnv(text)
|
||||
expect(result.ok && result.store.accessKeySecret).toBe('s#ec+ret/==')
|
||||
})
|
||||
|
||||
it('endpoint 去掉协议和结尾的 /', () => {
|
||||
const text = 'OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com/\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s'
|
||||
const result = parseHandoffStoreEnv(text)
|
||||
expect(result.ok && result.store.endpoint).toBe('oss-cn-beijing.aliyuncs.com')
|
||||
})
|
||||
|
||||
it('同一个变量写两遍以后一遍为准', () => {
|
||||
const text = 'OSS_ENDPOINT=old\nOSS_ENDPOINT=new\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s'
|
||||
const result = parseHandoffStoreEnv(text)
|
||||
expect(result.ok && result.store.endpoint).toBe('new')
|
||||
})
|
||||
|
||||
it('缺项和空值都报出变量名', () => {
|
||||
const text = 'OSS_ENDPOINT=e\nOSS_ACCESS_KEY=\n# OSS_SECRET_KEY=s\nOSS_BUCKET_NAME=b'
|
||||
expect(parseHandoffStoreEnv(text)).toEqual({ ok: false, missing: ['OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] })
|
||||
})
|
||||
|
||||
it('空文本四项全缺', () => {
|
||||
expect(parseHandoffStoreEnv('')).toEqual({ ok: false, missing: ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 从 env 文件的文本里读移交存储配置。认的是 luffy-agent-platform 后端 `.env` 的写法:
|
||||
* `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY` 四行 `KEY=value`,
|
||||
* 空行、`#` 注释和别的变量都跳过。
|
||||
*/
|
||||
|
||||
import type { HandoffStore } from './handoffStore'
|
||||
|
||||
const ENV_NAMES = ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] as const
|
||||
|
||||
type EnvName = typeof ENV_NAMES[number]
|
||||
|
||||
const LINE_RE = /^(?:export\s+)?(\w+)\s*=(.*)$/
|
||||
|
||||
/** 读出来的配置,或者缺了哪几个变量(只有变量名,不带值)。 */
|
||||
export type HandoffStoreEnvResult = { ok: true, store: HandoffStore } | { ok: false, missing: EnvName[] }
|
||||
|
||||
/** 去掉值两边成对的引号;没加引号的值去掉 ` #` 起的行尾注释。 */
|
||||
function unquote(raw: string): string {
|
||||
const v = raw.trim()
|
||||
const quote = v[0]
|
||||
if (quote === '"' || quote === '\'') {
|
||||
const end = v.indexOf(quote, 1)
|
||||
if (end > 0)
|
||||
return v.slice(1, end)
|
||||
}
|
||||
return v.replace(/\s#.*$/, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一个变量写了几遍以最后一遍为准,值为空算缺。endpoint 去掉 `https://` 和结尾的 `/`:
|
||||
* 设置里存的是不带协议的域名,上传下载一律走 https。
|
||||
*/
|
||||
export function parseHandoffStoreEnv(text: string): HandoffStoreEnvResult {
|
||||
const values = new Map<string, string>()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue
|
||||
const m = LINE_RE.exec(trimmed)
|
||||
if (m && (ENV_NAMES as readonly string[]).includes(m[1]))
|
||||
values.set(m[1], unquote(m[2]))
|
||||
}
|
||||
const missing = ENV_NAMES.filter(name => !values.get(name))
|
||||
if (missing.length > 0)
|
||||
return { ok: false, missing }
|
||||
const get = (name: EnvName): string => values.get(name) ?? ''
|
||||
return {
|
||||
ok: true,
|
||||
store: {
|
||||
endpoint: get('OSS_ENDPOINT').replace(/^https?:\/\//i, '').replace(/\/+$/, ''),
|
||||
bucket: get('OSS_BUCKET_NAME'),
|
||||
accessKeyId: get('OSS_ACCESS_KEY'),
|
||||
accessKeySecret: get('OSS_SECRET_KEY'),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,15 @@ export async function configureHandoffStore(ctx: ExtensionContext): Promise<void
|
||||
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})`)
|
||||
await saveHandoffStore(ctx, { endpoint, bucket, accessKeyId, accessKeySecret })
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存移交存储配置并提示保存好了:endpoint、bucket 进扩展设置,两个 AccessKey 进密钥存储。
|
||||
* 「配置移交存储」命令和 `configure-handoff-store` URI 都走这里。
|
||||
*/
|
||||
export async function saveHandoffStore(ctx: ExtensionContext, store: HandoffStore): Promise<void> {
|
||||
await saveSettings(ctx, { handoffOssEndpoint: store.endpoint, handoffOssBucket: store.bucket })
|
||||
await setHandoffOssKeys(ctx, { accessKeyId: store.accessKeyId, accessKeySecret: store.accessKeySecret })
|
||||
void window.showInformationMessage(`移交存储已保存:${store.bucket}(${store.endpoint})`)
|
||||
}
|
||||
@@ -571,7 +571,7 @@ export class KanbanWebviewPanel {
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/accept') {
|
||||
void sessionHandoff.handleSessionHandoffAccept(this, msg.attachmentId)
|
||||
void sessionHandoff.handleSessionHandoffAccept(this, msg.key)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'session-handoff/refresh') {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function scratchDir(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
/** 读移交存储配置;没配置就 toast 提示去跑「配置移交存储」,返回 undefined。 */
|
||||
async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
|
||||
export async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffStore | undefined> {
|
||||
const store = await readHandoffStore(panel.context)
|
||||
if (!store)
|
||||
toast(panel, 'error', HANDOFF_STORE_MISSING_MESSAGE)
|
||||
@@ -110,7 +110,7 @@ async function requireHandoffStore(panel: KanbanWebviewPanel): Promise<HandoffSt
|
||||
* 同一条进度 toast 随步骤改文案,直到流程结束才收掉。文案没变就不重发,
|
||||
* 上传 / 下载的进度回调很密。
|
||||
*/
|
||||
function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
|
||||
export function progressToast(panel: KanbanWebviewPanel, prefix: string): { say: (step: string) => void, dismiss: () => void } {
|
||||
let id: string | undefined
|
||||
let shown = ''
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
/**
|
||||
* 会话交接:把会话 tab 里的一个 claude 会话交给同事。
|
||||
*
|
||||
* 会话包放在移交存储(阿里云 OSS)上,每个人在每个仓库有一个收件箱目录,
|
||||
* key 的格式见 `src/cc/sessionHandoff.ts`。发送方打包上传进对方的收件箱,
|
||||
* 接收方列自己的收件箱得到待接收列表,接收后把包删掉。
|
||||
*/
|
||||
|
||||
import type { HandoffStore } from '../../oss/handoffStore'
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import type { SessionHandoffPendingItem } from '../messages'
|
||||
import type { RepoCtx } from './handoffFlow'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { packSessionHandoff, unpackSessionHandoff } from '../../cc/handoffArchive'
|
||||
import { formatByteSize, packHandoff, unpackHandoff } from '../../cc/handoffArchive'
|
||||
import { stageClaudeSessionFiles } from '../../cc/handoffBundle'
|
||||
import { HANDOFF_MANIFEST_FILE } from '../../cc/handoffManifest'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
@@ -14,32 +25,26 @@ import {
|
||||
} from '../../cc/sessionBundle'
|
||||
import {
|
||||
buildSessionHandoffManifest,
|
||||
parseSessionHandoffAttachmentName,
|
||||
parseSessionHandoffKey,
|
||||
parseSessionHandoffManifest,
|
||||
sessionHandoffAttachmentName,
|
||||
sessionHandoffKey,
|
||||
sessionInboxPrefix,
|
||||
} from '../../cc/sessionHandoff'
|
||||
import { projectsDirFor } from '../../cc/sessionWatcher'
|
||||
import {
|
||||
deleteIssueAttachment,
|
||||
downloadAttachment,
|
||||
listIssueAttachments,
|
||||
listRepoAssignees,
|
||||
uploadIssueAttachment,
|
||||
} from '../../gitea/api'
|
||||
import { ensureMailboxIssue, findMailboxIssue } from '../../gitea/mailboxIssue'
|
||||
import { listRepoAssignees } from '../../gitea/api'
|
||||
import { logger } from '../../logging/logger'
|
||||
import {
|
||||
deleteHandoffBundle,
|
||||
downloadHandoffBundle,
|
||||
isHandoffBundleMissing,
|
||||
listHandoffBundles,
|
||||
uploadHandoffBundle,
|
||||
} from '../../oss/handoffStore'
|
||||
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
||||
import { dismiss, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
|
||||
import { transferProgressText } from './handoff'
|
||||
import { progressToast, requireHandoffStore, resolveRepoCtx, scratchDir, toast } from './handoffFlow'
|
||||
import { pushManagedSessions } from './managedSessions'
|
||||
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Promise<void> {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx) {
|
||||
@@ -60,27 +65,40 @@ export async function handleSessionHandoffUsers(panel: KanbanWebviewPanel): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/** 列我的收件箱发给 webview。拿不到列表(没配移交存储、没仓库、网络错)也回一个空列表,webview 不用一直等。 */
|
||||
export async function handleSessionHandoffRefresh(panel: KanbanWebviewPanel): Promise<void> {
|
||||
const post = (items: SessionHandoffPendingItem[]): void => panel.postMessage({ type: 'session-handoff/pending', items })
|
||||
const store = await requireHandoffStore(panel)
|
||||
if (!store) {
|
||||
post([])
|
||||
return
|
||||
}
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx) {
|
||||
panel.postMessage({ type: 'session-handoff/pending', items: [] })
|
||||
post([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
const items = await listPendingForMe(ctx)
|
||||
panel.postMessage({ type: 'session-handoff/pending', items })
|
||||
post(await listPendingForMe(store, ctx))
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'panel', message: '拉取待接收会话失败', details: message })
|
||||
panel.postMessage({ type: 'session-handoff/pending', items: [] })
|
||||
post([])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送方:关掉会话终端 → 收集会话文件、写清单、xz 打包 → 删掉对方收件箱里这个会话的旧包
|
||||
* → 上传 → 本机记下交给了谁。同一条进度提示随步骤改文案,结束才收掉。
|
||||
*/
|
||||
export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessionId: string, to: string): Promise<void> {
|
||||
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId })
|
||||
let spinner: string | undefined
|
||||
let progress: ReturnType<typeof progressToast> | undefined
|
||||
try {
|
||||
const store = await requireHandoffStore(panel)
|
||||
if (!store)
|
||||
return
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
@@ -95,62 +113,47 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi
|
||||
toast(panel, 'error', `找不到会话文件 ${sessionId},无法交接`)
|
||||
return
|
||||
}
|
||||
const key = sessionHandoffKey({ owner: ctx.owner, repo: ctx.repo, to, sid: session.id, from: ctx.me, name: session.name })
|
||||
|
||||
progress = progressToast(panel, `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}`)
|
||||
const say = progress.say
|
||||
|
||||
const existing = panel.managedTerminals.get(sessionId)
|
||||
if (existing) {
|
||||
say('关闭终端')
|
||||
try {
|
||||
existing.dispose()
|
||||
}
|
||||
// 终端已经死了也无所谓,目的只是让 jsonl 不再被写
|
||||
catch {}
|
||||
await new Promise<void>(r => setTimeout(r, 600))
|
||||
}
|
||||
|
||||
spinner = toast(panel, 'info', `正在把「${session.name || sessionId.slice(0, 8)}」交给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
say('收集会话')
|
||||
const manifest = buildSessionHandoffManifest({
|
||||
from: ctx.me,
|
||||
to,
|
||||
workspacePath: ctx.workspaceRoot,
|
||||
session: { id: session.id, name: session.name, profilePath: session.profilePath },
|
||||
})
|
||||
const attachmentName = sessionHandoffAttachmentName({
|
||||
sid: session.id,
|
||||
from: ctx.me,
|
||||
to,
|
||||
name: session.name,
|
||||
})
|
||||
|
||||
// 包必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
||||
const staging = await scratchDir('spx-session-handoff-')
|
||||
const outDir = await scratchDir('spx-session-handoff-out-')
|
||||
let mailboxIndex: number
|
||||
try {
|
||||
const claudeDir = path.join(staging, 'claude')
|
||||
await stageClaudeSessionFiles(files, claudeDir)
|
||||
await stageClaudeSessionFiles(files, path.join(staging, 'claude'))
|
||||
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 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)
|
||||
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId: old.id })
|
||||
const archive = path.join(outDir, 'session-handoff.tar.xz')
|
||||
await packHandoff(staging, archive, { onProgress: bytes => say(`打包 ${formatByteSize(bytes)}`) })
|
||||
// 同一个会话再交给同一人:先删掉对方收件箱里这个会话的旧包,对方只看到最新的一份。
|
||||
for (const old of await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to }))) {
|
||||
if (parseSessionHandoffKey(old.key)?.sid === session.id)
|
||||
await deleteHandoffBundle(store, old.key)
|
||||
}
|
||||
await uploadIssueAttachment({
|
||||
...ctx,
|
||||
index: mailboxIndex,
|
||||
name: attachmentName,
|
||||
data: await fsp.readFile(archive),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `会话交接打包/上传失败 ${sessionId}`, details: message })
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'error', `交接失败:${message}`)
|
||||
return
|
||||
await uploadHandoffBundle(store, key, archive, (sent, total) => say(transferProgressText('上传', sent, total)))
|
||||
logger.add({ level: 'info', source: 'panel', message: `会话交接:会话包已上传 (${key}, ${(await fsp.stat(archive)).size} bytes)` })
|
||||
}
|
||||
finally {
|
||||
// rm 失败不该抛出掩盖上面已经判定好的成败——scratch 目录只是垃圾。
|
||||
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
|
||||
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
@@ -160,58 +163,71 @@ export async function handleSessionHandoffStart(panel: KanbanWebviewPanel, sessi
|
||||
await writeManagedSessions(ctx.workspaceRoot, data)
|
||||
await pushAll(panel)
|
||||
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
progress.dismiss()
|
||||
toast(panel, 'success', `已交给 ${to}(对方在会话 tab 待接收)`)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `会话交接未预期错误 ${sessionId}`, details: message })
|
||||
logger.add({ level: 'error', source: 'panel', message: `会话交接失败 ${sessionId}`, details: message })
|
||||
progress?.dismiss()
|
||||
toast(panel, 'error', `交接失败:${message}`)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, attachmentId: number): Promise<void> {
|
||||
/**
|
||||
* 接收方:只认我在这个仓库收件箱里的 key → 下载 → 解包、校验清单 → 发送方和我的
|
||||
* 工作区路径不同就改写会话里的路径 → 装会话文件 → 记进会话 tab → 删掉 OSS 上的包。
|
||||
* 删包之前失败,包还在列表里,可以重试。
|
||||
*/
|
||||
export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, key: string): Promise<void> {
|
||||
const finish = (): void => panel.postMessage({ type: 'session-handoff/done', sessionId: '' })
|
||||
let spinner: string | undefined
|
||||
let progress: ReturnType<typeof progressToast> | undefined
|
||||
// extracted 声明在 try 外面:mkdtemp 之前就失败时 finally 不能去 rm 它。
|
||||
let extracted: string | undefined
|
||||
try {
|
||||
const store = await requireHandoffStore(panel)
|
||||
if (!store)
|
||||
return
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
const mailboxIndex = await findMailboxIssue(ctx)
|
||||
if (mailboxIndex === undefined) {
|
||||
toast(panel, 'error', '还没有会话交接邮箱')
|
||||
return
|
||||
}
|
||||
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
|
||||
const asset = assets.find(a => a.id === attachmentId)
|
||||
if (!asset) {
|
||||
toast(panel, 'error', '找不到这份待接收附件')
|
||||
return
|
||||
}
|
||||
const named = parseSessionHandoffAttachmentName(asset.name)
|
||||
if (!named || named.to !== ctx.me) {
|
||||
// key 是 webview 发来的,只认我在当前仓库收件箱里的包。
|
||||
const ref = parseSessionHandoffKey(key)
|
||||
if (!ref || !key.startsWith(sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me }))) {
|
||||
toast(panel, 'error', '这份交接不是给你的')
|
||||
return
|
||||
}
|
||||
const label = ref.name || ref.sid.slice(0, 8)
|
||||
|
||||
spinner = toast(panel, 'info', `正在接收「${named.name || named.sid.slice(0, 8)}」…`, { spinner: true, dismissOnTimer: 120_000 })
|
||||
|
||||
const archiveDir = await scratchDir('spx-session-handoff-in-')
|
||||
const extracted = await scratchDir('spx-session-handoff-ex-')
|
||||
progress = progressToast(panel, `正在接收「${label}」`)
|
||||
const say = progress.say
|
||||
extracted = await scratchDir('spx-session-handoff-in-')
|
||||
// 包名固定,不拿 key 拼本机路径。
|
||||
const archive = path.join(extracted, 'session-handoff.tar.xz')
|
||||
try {
|
||||
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 unpackSessionHandoff(archive, extracted)
|
||||
await downloadHandoffBundle(store, key, archive, (got, total) => say(transferProgressText('下载', got, total)))
|
||||
}
|
||||
catch (err) {
|
||||
if (!isHandoffBundleMissing(err))
|
||||
throw err
|
||||
progress.dismiss()
|
||||
toast(panel, 'error', `「${label}」已经不在收件箱里了,可能在别的机器上接收过`)
|
||||
await handleSessionHandoffRefresh(panel)
|
||||
return
|
||||
}
|
||||
|
||||
say('解包')
|
||||
await unpackHandoff(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},不是你`)
|
||||
if (manifest.session.id !== ref.sid)
|
||||
throw new Error(`清单里的会话 ${manifest.session.id} 和收件箱里的 ${ref.sid} 对不上`)
|
||||
|
||||
say('装会话')
|
||||
const claudeDir = path.join(extracted, 'claude')
|
||||
if (manifest.workspacePath !== ctx.workspaceRoot) {
|
||||
for (const { id } of manifest.claude) {
|
||||
@@ -230,6 +246,7 @@ export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, atta
|
||||
})
|
||||
}
|
||||
|
||||
say('写入会话列表')
|
||||
const data = await readManagedSessions(ctx.workspaceRoot)
|
||||
const existing = data.sessions.find(s => s.id === manifest.session.id)
|
||||
const profilePath = resolveProfilePath(manifest.session.profilePath)
|
||||
@@ -241,56 +258,44 @@ export async function handleSessionHandoffAccept(panel: KanbanWebviewPanel, atta
|
||||
else {
|
||||
data.sessions.push({
|
||||
id: manifest.session.id,
|
||||
name: manifest.session.name || named.name || manifest.session.id.slice(0, 8),
|
||||
name: manifest.session.name || label,
|
||||
profilePath,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
}
|
||||
await writeManagedSessions(ctx.workspaceRoot, data)
|
||||
await deleteIssueAttachment({ ...ctx, index: mailboxIndex, attachmentId }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'warn', source: 'panel', message: '接收后删附件失败', details: message })
|
||||
})
|
||||
try {
|
||||
await deleteHandoffBundle(store, key)
|
||||
}
|
||||
finally {
|
||||
await fsp.rm(archiveDir, { recursive: true, force: true }).catch(() => {})
|
||||
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'panel', message: `接收会话后删除 OSS 上的会话包失败 (${key})`, details: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
await pushAll(panel)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
toast(panel, 'success', `已接收「${named.name || named.sid.slice(0, 8)}」,来自 ${named.from}`)
|
||||
progress.dismiss()
|
||||
toast(panel, 'success', `已接收「${label}」,来自 ${manifest.from}`)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 attachment=${attachmentId}`, details: message })
|
||||
logger.add({ level: 'error', source: 'panel', message: `接收会话失败 ${key}`, details: message })
|
||||
progress?.dismiss()
|
||||
toast(panel, 'error', `接收失败:${message}`)
|
||||
if (spinner)
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
finally {
|
||||
// finish 先发:下面的 rm 失败不该让 webview 一直等。
|
||||
finish()
|
||||
if (extracted)
|
||||
await fsp.rm(extracted, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function listPendingForMe(ctx: { host: string, token: string, owner: string, repo: string, me: string }): Promise<SessionHandoffPendingItem[]> {
|
||||
const mailboxIndex = await findMailboxIssue(ctx)
|
||||
if (mailboxIndex === undefined)
|
||||
return []
|
||||
const assets = await listIssueAttachments({ ...ctx, index: mailboxIndex })
|
||||
async function listPendingForMe(store: HandoffStore, ctx: RepoCtx): Promise<SessionHandoffPendingItem[]> {
|
||||
const objects = await listHandoffBundles(store, sessionInboxPrefix({ owner: ctx.owner, repo: ctx.repo, to: ctx.me }))
|
||||
const items: SessionHandoffPendingItem[] = []
|
||||
for (const a of assets) {
|
||||
const parsed = parseSessionHandoffAttachmentName(a.name)
|
||||
if (!parsed || parsed.to !== ctx.me)
|
||||
continue
|
||||
items.push({
|
||||
attachmentId: a.id,
|
||||
sid: parsed.sid,
|
||||
from: parsed.from,
|
||||
to: parsed.to,
|
||||
name: parsed.name,
|
||||
})
|
||||
for (const o of objects) {
|
||||
const ref = parseSessionHandoffKey(o.key)
|
||||
if (ref)
|
||||
items.push({ key: o.key, sid: ref.sid, from: ref.from, name: ref.name })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ export interface ManagedSessionShowItem extends ManagedSession {
|
||||
tabOpen?: boolean
|
||||
}
|
||||
|
||||
/** 我收件箱里一份待接收的会话包,`key` 是它在移交存储上的对象 key。 */
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
key: string
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export type WebviewToExtension
|
||||
| { type: 'handoff/request-reject', issueNumber: number }
|
||||
| { type: 'session-handoff/users' }
|
||||
| { type: 'session-handoff/start', sessionId: string, to: string }
|
||||
| { type: 'session-handoff/accept', attachmentId: number }
|
||||
| { type: 'session-handoff/accept', key: string }
|
||||
| { type: 'session-handoff/refresh' }
|
||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { window } from 'vscode'
|
||||
import { logger } from '../logging/logger'
|
||||
import { parseHandoffStoreEnv } from '../oss/handoffStoreEnv'
|
||||
import { saveHandoffStore } from '../oss/handoffStoreSetup'
|
||||
|
||||
/**
|
||||
* 从 env 文件配置移交存储,由 `uriHandler.ts` 分发过来:
|
||||
*
|
||||
* vscode://clurdra.superpowers-vscode-clurdra/configure-handoff-store?envFile=<env 文件的绝对路径>
|
||||
*
|
||||
* 读文件里 `OSS_ENDPOINT` / `OSS_BUCKET_NAME` / `OSS_ACCESS_KEY` / `OSS_SECRET_KEY` 四个变量,
|
||||
* 齐了就和「配置移交存储」命令一样保存;参数不对、文件读不到、缺项都只报错,什么都不改。
|
||||
* 提示和日志里只出现 bucket、endpoint 和变量名,AccessKey 不出现。
|
||||
*/
|
||||
export async function handleConfigureHandoffStoreUri(context: ExtensionContext, uri: Uri): Promise<void> {
|
||||
const envFile = (new URLSearchParams(uri.query).get('envFile') ?? '').trim()
|
||||
if (!envFile) {
|
||||
void window.showErrorMessage('configure-handoff-store URI 缺少 envFile 参数')
|
||||
return
|
||||
}
|
||||
if (!path.isAbsolute(envFile)) {
|
||||
void window.showErrorMessage(`configure-handoff-store URI:envFile 要写绝对路径(${envFile})`)
|
||||
return
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = await fsp.readFile(envFile, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
void window.showErrorMessage(`configure-handoff-store URI:读不到 ${envFile}:${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
const parsed = parseHandoffStoreEnv(text)
|
||||
if (!parsed.ok) {
|
||||
void window.showErrorMessage(`configure-handoff-store URI:${envFile} 里缺少 ${parsed.missing.join('、')}`)
|
||||
return
|
||||
}
|
||||
await saveHandoffStore(context, parsed.store)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'uri',
|
||||
message: `URI 已保存移交存储:${parsed.store.bucket}(${parsed.store.endpoint}),来自 ${envFile}`,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import type { Uri } from 'vscode'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { listClaudeProfiles } from '../cc/profiles'
|
||||
@@ -6,34 +6,15 @@ import { logger } from '../logging/logger'
|
||||
import { createCcSessionTab } from '../panel/handlers/managedSessions'
|
||||
|
||||
/**
|
||||
* 外部触发开 cc 会话 tab 的 URI 入口(供 delegate-fix skill 等外部方调用):
|
||||
* 外部触发开 cc 会话 tab(供 delegate-fix skill 等外部方调用),由 `uriHandler.ts` 分发过来:
|
||||
*
|
||||
* vscode://clurdra.superpowers-vscode-clurdra/create-session
|
||||
* ?profile=<profile 名,不带 .json>
|
||||
* &name=<终端 tab 名,如 fix:fable5>
|
||||
* &prompt=<初始 prompt>
|
||||
* &cwd=<绝对路径,可选,缺省用 workspaceRoot>
|
||||
*
|
||||
* 编码契约:VS Code 把外部 URI 交给扩展前会对 query 整体做一次 percent-decode
|
||||
* (vs/base/common/uri.ts 的 URI.parse),这里的 URLSearchParams 是第二次 decode。
|
||||
* 因此发送方对参数值必须做两层 urlencode,值里的 & = % + 等字符才能安全通过。
|
||||
*/
|
||||
export function registerCreateSessionUriHandler(context: ExtensionContext): void {
|
||||
context.subscriptions.push(window.registerUriHandler({
|
||||
handleUri: (uri: Uri): void => {
|
||||
void handleCreateSessionUri(uri).catch((err) => {
|
||||
void window.showErrorMessage(`处理 create-session URI 失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleCreateSessionUri(uri: Uri): Promise<void> {
|
||||
if (uri.path !== '/create-session') {
|
||||
void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`)
|
||||
return
|
||||
}
|
||||
|
||||
export async function handleCreateSessionUri(uri: Uri): Promise<void> {
|
||||
const params = new URLSearchParams(uri.query)
|
||||
const profileName = (params.get('profile') ?? '').trim()
|
||||
const name = (params.get('name') ?? '').trim()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import { window } from 'vscode'
|
||||
import { handleConfigureHandoffStoreUri } from './configureHandoffStoreUri'
|
||||
import { handleCreateSessionUri } from './createSessionUriHandler'
|
||||
|
||||
/** 每个 path 一个路由,参数说明见各自的文件。 */
|
||||
const ROUTES = new Map<string, (context: ExtensionContext, uri: Uri) => Promise<void>>([
|
||||
['/create-session', (_context, uri) => handleCreateSessionUri(uri)],
|
||||
['/configure-handoff-store', handleConfigureHandoffStoreUri],
|
||||
])
|
||||
|
||||
/**
|
||||
* 扩展的外部 URI 入口 `vscode://clurdra.superpowers-vscode-clurdra/<path>?<参数>`:按 path
|
||||
* 分给 [ROUTES] 里的路由,未知 path 报错。
|
||||
*
|
||||
* 编码契约:VS Code 把外部 URI 交给扩展前会对 query 整体做一次 percent-decode
|
||||
* (vs/base/common/uri.ts 的 URI.parse),路由里的 URLSearchParams 是第二次 decode。
|
||||
* 因此发送方对参数值必须做两层 urlencode,值里的 & = % + 空格等字符才能安全通过。
|
||||
*/
|
||||
export function registerUriHandler(context: ExtensionContext): void {
|
||||
context.subscriptions.push(window.registerUriHandler({
|
||||
handleUri: (uri: Uri): void => {
|
||||
const route = ROUTES.get(uri.path)
|
||||
if (!route) {
|
||||
void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`)
|
||||
return
|
||||
}
|
||||
void route(context, uri).catch((err) => {
|
||||
void window.showErrorMessage(`处理 ${uri.path.slice(1)} URI 失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { env, Uri, window, workspace } from 'vscode'
|
||||
import { loginForToken } from '../auth/identity'
|
||||
import { getToken } from '../auth/secrets'
|
||||
import { renderReviewPrompt } from '../cc/prompts'
|
||||
import { isMailboxIssue } from '../cc/sessionHandoff'
|
||||
import { detectRepo } from '../git/remote'
|
||||
import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api'
|
||||
import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment'
|
||||
@@ -577,15 +576,6 @@ class WebhookCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
if (isMailboxIssue({ title: event.title, body: event.body })) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `issue #${event.issueNumber} 是会话交接 mailbox,跳过看板`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nonceMatch = event.body.match(/<!--\s*spx:nonce=([0-9a-f-]+)\s*-->/i)
|
||||
const nonce = nonceMatch ? nonceMatch[1] : null
|
||||
// Promote the in-flight brainstorm terminal into the panel's
|
||||
|
||||
@@ -91,7 +91,7 @@ export interface BottomTabsProps {
|
||||
onImportNamedSession: (sessionId: string, name: string) => void
|
||||
pendingHandoffs: SessionHandoffPendingItem[]
|
||||
onSessionHandoff: (sessionId: string) => void
|
||||
onAcceptSessionHandoff: (attachmentId: number) => void
|
||||
onAcceptSessionHandoff: (key: string) => void
|
||||
}
|
||||
|
||||
export function BottomTabs(props: BottomTabsProps) {
|
||||
|
||||
@@ -29,7 +29,7 @@ interface ManagedSessionsPanelProps {
|
||||
onImport: (sessionId: string, name: string) => void
|
||||
pendingHandoffs: SessionHandoffPendingItem[]
|
||||
onHandoff: (sessionId: string) => void
|
||||
onAcceptHandoff: (attachmentId: number) => void
|
||||
onAcceptHandoff: (key: string) => void
|
||||
}
|
||||
|
||||
function basename(p?: string): string {
|
||||
@@ -254,7 +254,7 @@ export function ManagedSessionsPanel({
|
||||
<div className="mb-1 text-[10px] text-[var(--vscode-descriptionForeground)]">待接收</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pendingHandoffs.map(p => (
|
||||
<li key={p.attachmentId} className="flex items-center gap-2">
|
||||
<li key={p.key} className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs">{p.name || p.sid.slice(0, 8)}</div>
|
||||
<div className="truncate text-[10px] text-[var(--vscode-descriptionForeground)]">
|
||||
@@ -265,7 +265,7 @@ export function ManagedSessionsPanel({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAcceptHandoff(p.attachmentId)}
|
||||
onClick={() => onAcceptHandoff(p.key)}
|
||||
className="shrink-0 rounded bg-[var(--vscode-button-background)] px-2 py-1 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
|
||||
>
|
||||
接收
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface UseManagedSessionsResult {
|
||||
sessionHandoffUsers: string[] | null
|
||||
startSessionHandoff: (sessionId: string) => void
|
||||
submitSessionHandoff: (sessionId: string, to: string) => void
|
||||
acceptSessionHandoff: (attachmentId: number) => void
|
||||
acceptSessionHandoff: (key: string) => void
|
||||
refreshSessionHandoffs: () => void
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ export function useManagedSessions(): UseManagedSessionsResult {
|
||||
postMessage({ type: 'session-handoff/start', sessionId, to })
|
||||
}, [])
|
||||
|
||||
const acceptSessionHandoff = useCallback((attachmentId: number): void => {
|
||||
postMessage({ type: 'session-handoff/accept', attachmentId })
|
||||
const acceptSessionHandoff = useCallback((key: string): void => {
|
||||
postMessage({ type: 'session-handoff/accept', key })
|
||||
}, [])
|
||||
|
||||
const refreshSessionHandoffs = useCallback((): void => {
|
||||
|
||||
@@ -52,11 +52,11 @@ export interface IssueChatComment {
|
||||
systemText?: string
|
||||
}
|
||||
|
||||
/** 我收件箱里一份待接收的会话包,`key` 是它在移交存储上的对象 key。 */
|
||||
export interface SessionHandoffPendingItem {
|
||||
attachmentId: number
|
||||
key: string
|
||||
sid: string
|
||||
from: string
|
||||
to: string
|
||||
name: string
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ export type WebviewToExtension
|
||||
| { type: 'handoff/request-reject', issueNumber: number }
|
||||
| { type: 'session-handoff/users' }
|
||||
| { type: 'session-handoff/start', sessionId: string, to: string }
|
||||
| { type: 'session-handoff/accept', attachmentId: number }
|
||||
| { type: 'session-handoff/accept', key: string }
|
||||
| { type: 'session-handoff/refresh' }
|
||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||
|
||||
Reference in new issue
Block a user