2515 lines
98 KiB
Markdown
2515 lines
98 KiB
Markdown
# 工单移交(handoff)实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 同事在 A 机把工单(worktree + claude/codex 会话)打包挂到 Gitea 工单附件并重新指派,我在 B 机一键接管:重建 worktree、装回会话文件、写本机字段,之后 resume / 合并 / 解冲突照常。
|
||
|
||
**Architecture:** 会话包 `spx-handoff-issue-<N>.tgz`(`handoff.json` 清单 + `claude/` + `codex/`)作为 Gitea 工单附件传输;共享 state JSON 新增两个字符串字段 `handoffAttachmentId` / `handoffFrom` 标记「待接管」;接管方用 `git worktree add -B <branch> <path> origin/<branch>` 重建 worktree,再把会话文件装到对应 `~/.claude/projects/<encoded cwd>/` 目录(先删本机所有同 sid 副本)。纯 fs / 纯决策逻辑拆成小模块配 vitest;副作用编排放 `panel/handlers/handoffFlow.ts`。
|
||
|
||
**Tech Stack:** TypeScript、VS Code Extension API、Node 22 全局 `fetch`/`FormData`/`Blob`、`tar` CLI、React 19 + Tailwind 4(webview)、Vitest、Go(spx 的 schema 副本)。
|
||
|
||
**Spec:** `docs/superpowers/specs/issue-handoff/spec.md`
|
||
|
||
## Global Constraints
|
||
|
||
- 所有命令在 `/home/cruldra/Sources/superwork/vscode` 下执行(`pnpm test` / `pnpm lint` / `pnpm typecheck`;Go 用 `cd cli && make test`)。
|
||
- 代码文件用 Serena MCP 工具读写(`mcp__serena__find_symbol` / `replace_symbol_body` / `insert_after_symbol` / `replace_content`);非代码文件(md / json)才用 Read/Edit。
|
||
- 提交信息格式:emoji + `type(scope)` + 中文描述(参考 `git log`,例:`✨ feat(vscode): …`、`♻️ refactor(vscode): …`、`🐛 fix(vscode): …`)。提交信息末尾加一行 `Claude-Session: https://claude.ai/code/session_011cEyL6k351U2BzX1Qmygph`。
|
||
- 注释只讲「为什么 / 坑」,不复述代码,不留历史痕迹(不写「原来是…现在改成…」)。
|
||
- `src/panel/messages.ts` 与 `webview-ui/src/lib/messages.ts`、`src/gitea/types.ts` 与 `webview-ui/src/types.ts` 是手工镜像,改一处必改另一处。
|
||
- state JSON 清空字段用 `''` 墓碑;`issue/patch` 清空字段用 `null`(`undefined` 会被 postMessage 丢掉)。
|
||
- 移交只支持 Gitea 工单(`issue.source !== 'youtrack'`)。
|
||
- 仅 POSIX:打包/解包直接 `execFile('tar', …)`。
|
||
- 会话 id 与 `~/.claude/projects` 目录编码:`encodeCwdForProjectsDir(abs) = abs.replace(/[/.]/g, '-')`,`projectsDirFor(abs) = ~/.claude/projects/<encoded>`(已有,`src/cc/sessionWatcher.ts`)。
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 文件 | 动作 | 职责 |
|
||
|---|---|---|
|
||
| `schemas/state-json.schema.json` | 改 | 新增 `handoffAttachmentId` / `handoffFrom` |
|
||
| `cli/cmd/spx/main.go` | 改 | `knownStateFields` 加两项;`make sync-schema` 同步 schema 副本 |
|
||
| `src/gitea/stateJson.ts`、`src/youtrack/stateComment.ts` | 改 | `KNOWN_STATE_FIELDS` 加两项 |
|
||
| `src/gitea/types.ts`、`webview-ui/src/types.ts` | 改 | `Issue` 加 `handoffAttachmentId?`、`handoffFrom?`、`assignees?` |
|
||
| `src/gitea/issueLoader.ts` | 改 | 解析并透出上述字段 |
|
||
| `src/panel/messages.ts`、`webview-ui/src/lib/messages.ts` | 改 | 新消息 + `issue/patch` 字段 + `issues/update.me` |
|
||
| `src/git/worktreePath.ts`(新) | 新 | `deriveSlug` / `slugFromBranch` / `resolveWorktreeDir` / `DEFAULT_WORKTREE_TEMPLATE`(从 `sessions.ts` 抽出) |
|
||
| `src/git/worktree.ts` | 改 | 导出 `runGit`、`findLiveWorktreeForBranch`;新增 `ensureWorktreeFromRemote` |
|
||
| `src/cc/sessionBundle.ts`(新) | 新 | claude/codex 会话文件查找、安装(去重)、codex rollout 定位 |
|
||
| `src/cc/handoffManifest.ts`(新) | 新 | 清单类型、附件名、`parseHandoffManifest` |
|
||
| `src/cc/handoffBundle.ts`(新) | 新 | `stageHandoffBundle` / `installHandoffBundle`(组合 sessionBundle + manifest) |
|
||
| `src/cc/handoffArchive.ts`(新) | 新 | `packHandoff` / `unpackHandoff`(tar) |
|
||
| `src/gitea/api.ts` | 改 | assignees 列表、附件 list/upload/get/download/delete |
|
||
| `src/panel/handlers/handoff.ts`(新) | 新 | 纯决策与 payload:`canStartHandoff`、`canAcceptHandoff`、`checkWorktreePushed`、四个 payload 函数 |
|
||
| `src/panel/handlers/handoffFlow.ts`(新) | 新 | `handleHandoffUsers` / `handleHandoffStart` / `handleHandoffAccept` |
|
||
| `src/panel/KanbanPanel.ts` | 改 | 三条消息分发;`issues/update` 带 `me` |
|
||
| `src/panel/handlers/issues.ts` | 改 | 完成列 jsonl 拷贝改为 `installClaudeSession` 搬迁 |
|
||
| `webview-ui/src/components/HandoffModal.tsx`(新) | 新 | 选人弹窗 |
|
||
| `webview-ui/src/hooks/useIssues.ts`、`App.tsx`、`BottomTabs.tsx`、`IssueDetailPanel.tsx`、`IssueCard.tsx` | 改 | 按钮、角标、状态、消息 |
|
||
| `README.md`、`package.json` | 改 | 文档、版本号 |
|
||
|
||
---
|
||
|
||
### Task 1: state JSON 字段 + Issue 类型 + loader
|
||
|
||
**Files:**
|
||
- Modify: `schemas/state-json.schema.json`
|
||
- Modify: `cli/cmd/spx/main.go:391-411`
|
||
- Modify: `src/gitea/stateJson.ts:22`
|
||
- Modify: `src/youtrack/stateComment.ts:23`
|
||
- Modify: `src/gitea/types.ts:10-99`
|
||
- Modify: `webview-ui/src/types.ts:3+`
|
||
- Modify: `src/gitea/issueLoader.ts:70-203, 293-397`
|
||
- Modify: `src/panel/messages.ts:43,45`
|
||
- Modify: `webview-ui/src/lib/messages.ts`(同两行)
|
||
|
||
**Interfaces:**
|
||
- Produces: `Issue.handoffAttachmentId?: string`、`Issue.handoffFrom?: string`、`Issue.assignees?: string[]`;`issues/update` 消息多 `me?: string`;`issue/patch` 支持 `handoffAttachmentId?: string | null`、`handoffFrom?: string | null`、`assignees?: string[]`、`sessionId?: string | null`、`testSessionId?: string | null`。
|
||
|
||
- [ ] **Step 1: schema 加字段**
|
||
|
||
在 `schemas/state-json.schema.json` 的 `properties` 末尾(`autoReview` 之后)追加:
|
||
|
||
```json
|
||
"handoffAttachmentId": {
|
||
"type": "string",
|
||
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空。",
|
||
"minLength": 1
|
||
},
|
||
"handoffFrom": {
|
||
"type": "string",
|
||
"description": "发起移交的 Gitea login。",
|
||
"minLength": 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Go 侧同步**
|
||
|
||
`cli/cmd/spx/main.go` 的 `knownStateFields` 加:
|
||
|
||
```go
|
||
"handoffAttachmentId": {},
|
||
"handoffFrom": {},
|
||
```
|
||
|
||
运行:`cd cli && make sync-schema && make test`
|
||
Expected: `ok .../internal/state`(schema_test 通过,副本一致)
|
||
|
||
- [ ] **Step 3: TS 两处 KNOWN_STATE_FIELDS**
|
||
|
||
`src/gitea/stateJson.ts:22` 与 `src/youtrack/stateComment.ts:23` 的数组末尾各加 `'handoffAttachmentId', 'handoffFrom'`。
|
||
|
||
- [ ] **Step 4: Issue 类型(两份镜像)**
|
||
|
||
`src/gitea/types.ts` 的 `Issue` 在 `autoReview?: boolean` 之后加:
|
||
|
||
```ts
|
||
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
|
||
assignees?: string[]
|
||
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
|
||
handoffAttachmentId?: string
|
||
/** 发起移交的 Gitea login。 */
|
||
handoffFrom?: string
|
||
```
|
||
|
||
`webview-ui/src/types.ts` 的 `Issue` 加同样三个字段。
|
||
|
||
- [ ] **Step 5: issueLoader 解析**
|
||
|
||
`src/gitea/issueLoader.ts` `parseColumnFromComments`:
|
||
- 返回类型加 `handoffAttachmentId?: string`、`handoffFrom?: string`
|
||
- `obj` 断言类型加 `handoffAttachmentId?: unknown`、`handoffFrom?: unknown`
|
||
- 在 `const autoReview = …` 后加:
|
||
|
||
```ts
|
||
const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0
|
||
? obj.handoffAttachmentId
|
||
: undefined
|
||
const handoffFrom = typeof obj.handoffFrom === 'string' && obj.handoffFrom.length > 0
|
||
? obj.handoffFrom
|
||
: undefined
|
||
```
|
||
- 返回对象加 `handoffAttachmentId, handoffFrom`。
|
||
|
||
`buildIssue`:解构处加 `handoffAttachmentId, handoffFrom`;返回对象 `htmlUrl` 之前加:
|
||
|
||
```ts
|
||
...(handoffAttachmentId ? { handoffAttachmentId } : {}),
|
||
...(handoffFrom ? { handoffFrom } : {}),
|
||
assignees: (issue.assignees ?? []).map(a => a.login),
|
||
```
|
||
|
||
- [ ] **Step 6: 消息类型(两份镜像)**
|
||
|
||
`src/panel/messages.ts:43` 改为:
|
||
|
||
```ts
|
||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string }
|
||
```
|
||
|
||
`:45` 的 `issue/patch` patch 对象:`sessionId?: string` → `sessionId?: string | null`,`testSessionId?: string` → `testSessionId?: string | null`,并在末尾追加 `, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null`。
|
||
|
||
`webview-ui/src/lib/messages.ts` 同两行做同样修改。
|
||
|
||
- [ ] **Step 7: KanbanPanel 发 `me`**
|
||
|
||
`src/panel/KanbanPanel.ts` `loadAndPush` 里 `this.postMessage({ type: 'issues/update', … })` 之前加 `const me = await loginForToken(host, token)`(import `loginForToken` from `'../auth/identity'`),消息里加 `me`。
|
||
|
||
- [ ] **Step 8: 校验并提交**
|
||
|
||
Run: `pnpm typecheck && pnpm lint && pnpm test`
|
||
Expected: 全部通过。
|
||
|
||
```bash
|
||
git add schemas cli src webview-ui
|
||
git commit -m "✨ feat(vscode): state JSON 新增 handoffAttachmentId/handoffFrom,Issue 透出 assignees"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `git/worktreePath.ts`(抽出 slug / 模板解析)
|
||
|
||
**Files:**
|
||
- Create: `src/git/worktreePath.ts`
|
||
- Create: `src/git/worktreePath.test.ts`
|
||
- Modify: `src/panel/handlers/sessions.ts:636-680`(删除私有 `deriveSlug` / `resolveWorktreeDir`,改 import)、`:768-770`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'`
|
||
- `export function deriveSlug(planFile: string): string`
|
||
- `export function slugFromBranch(branch: string): string`
|
||
- `export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string`
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
`src/git/worktreePath.test.ts`:
|
||
|
||
```ts
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir, slugFromBranch } from './worktreePath'
|
||
|
||
describe('deriveSlug', () => {
|
||
it('取 plans/ 后一段', () => {
|
||
expect(deriveSlug('docs/superpowers/plans/foo-bar/plan.md')).toBe('foo-bar')
|
||
})
|
||
it('没有 plans/specs 锚点时退回父目录名', () => {
|
||
expect(deriveSlug('docs/x/baz/plan.md')).toBe('baz')
|
||
})
|
||
})
|
||
|
||
describe('slugFromBranch', () => {
|
||
it('去掉 feature/ 前缀', () => {
|
||
expect(slugFromBranch('feature/foo-bar')).toBe('foo-bar')
|
||
})
|
||
it('无前缀原样返回', () => {
|
||
expect(slugFromBranch('hotfix-1')).toBe('hotfix-1')
|
||
})
|
||
})
|
||
|
||
describe('resolveWorktreeDir', () => {
|
||
it('展开三个占位符与 ~', () => {
|
||
const out = resolveWorktreeDir(DEFAULT_WORKTREE_TEMPLATE, '/w/proj', 'slug')
|
||
expect(out).toBe(path.join(os.homedir(), 'Sources', 'worktree', 'proj', 'slug'))
|
||
})
|
||
it('$project_root 展开为工作区绝对路径', () => {
|
||
expect(resolveWorktreeDir('$project_root/.wt/$feature_name', '/w/proj', 's')).toBe('/w/proj/.wt/s')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test src/git/worktreePath.test.ts`
|
||
Expected: FAIL(模块不存在)
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`src/git/worktreePath.ts`:
|
||
|
||
```ts
|
||
import { createHash } from 'node:crypto'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
|
||
export const DEFAULT_WORKTREE_TEMPLATE = '~/Sources/worktree/$project_name/$feature_name'
|
||
|
||
/**
|
||
* spec/plan 以目录形式存放(docs/superpowers/{specs,plans}/<slug>/…),slug 取
|
||
* `plans`/`specs` 后一段。兜底父目录名,再兜底路径 hash,保证目录名不为空。
|
||
*/
|
||
export function deriveSlug(planFile: string): string {
|
||
const segments = planFile.split('/').filter(Boolean)
|
||
const anchor = segments.findIndex(s => s === 'plans' || s === 'specs')
|
||
if (anchor >= 0 && segments[anchor + 1])
|
||
return segments[anchor + 1]
|
||
const parent = path.basename(path.dirname(planFile))
|
||
if (parent && parent !== '.' && parent !== '/')
|
||
return parent
|
||
return createHash('sha256').update(planFile).digest('hex').slice(0, 8)
|
||
}
|
||
|
||
/** 实施分支固定形如 `feature/<slug>`;接管方只有分支名,从这里反推 worktree 目录名。 */
|
||
export function slugFromBranch(branch: string): string {
|
||
return branch.startsWith('feature/') ? branch.slice('feature/'.length) : branch
|
||
}
|
||
|
||
/**
|
||
* 把 worktreeDirectory 模板展开成绝对路径。`~` 在这里展开:git/execFile 不经 shell,
|
||
* 不会自己做 tilde 展开。
|
||
*/
|
||
export function resolveWorktreeDir(template: string, workspaceRoot: string, slug: string): string {
|
||
let p = template
|
||
.split('$project_name')
|
||
.join(path.basename(workspaceRoot))
|
||
.split('$project_root')
|
||
.join(workspaceRoot)
|
||
.split('$feature_name')
|
||
.join(slug)
|
||
if (p.startsWith('~'))
|
||
p = path.join(os.homedir(), p.slice(1))
|
||
return path.resolve(p)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: sessions.ts 改用新模块**
|
||
|
||
删除 `src/panel/handlers/sessions.ts` 中私有的 `deriveSlug`、`resolveWorktreeDir`(`:636-680`),加 `import { DEFAULT_WORKTREE_TEMPLATE, deriveSlug, resolveWorktreeDir } from '../../git/worktreePath'`;`:768-770` 的 `|| '~/Sources/worktree/$project_name/$feature_name'` 改为 `|| DEFAULT_WORKTREE_TEMPLATE`。若 `createHash`/`os` 在 sessions.ts 里因此无引用,删掉对应 import。
|
||
|
||
- [ ] **Step 5: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test src/git/worktreePath.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/git/worktreePath.ts src/git/worktreePath.test.ts src/panel/handlers/sessions.ts
|
||
git commit -m "♻️ refactor(vscode): 抽出 worktreePath 模块(slug / 目录模板解析)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `ensureWorktreeFromRemote`
|
||
|
||
**Files:**
|
||
- Modify: `src/git/worktree.ts:37, 97, 110`
|
||
- Create: `test/git/worktreeFromRemote.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `export function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise<string>`(改 export)
|
||
- `export async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise<string | undefined>`(改 export)
|
||
- `export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string>`
|
||
|
||
- [ ] **Step 1: 写集成测试(真 git,仿 `test/git/worktreeEnsure.test.ts`)**
|
||
|
||
`test/git/worktreeFromRemote.test.ts`:
|
||
|
||
```ts
|
||
import { execFileSync } from 'node:child_process'
|
||
import { existsSync, promises as fsp, 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 { ensureWorktreeFromRemote } from '../../src/git/worktree'
|
||
|
||
const gitEnv: NodeJS.ProcessEnv = {
|
||
...process.env,
|
||
GIT_CONFIG_NOSYSTEM: '1',
|
||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||
GIT_TERMINAL_PROMPT: '0',
|
||
GIT_AUTHOR_NAME: 'remote-test',
|
||
GIT_AUTHOR_EMAIL: 'remote@test.local',
|
||
GIT_COMMITTER_NAME: 'remote-test',
|
||
GIT_COMMITTER_EMAIL: 'remote@test.local',
|
||
}
|
||
|
||
function git(cwd: string, args: string[]): string {
|
||
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim()
|
||
}
|
||
|
||
interface Fixture { root: string, origin: string, repo: string, templatePath: string }
|
||
|
||
/** origin(bare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */
|
||
async function setup(): Promise<Fixture> {
|
||
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-'))
|
||
const origin = path.join(root, 'origin.git')
|
||
git(root, ['init', '--bare', '-b', 'main', origin])
|
||
const peer = path.join(root, 'peer')
|
||
git(root, ['clone', origin, peer])
|
||
writeFileSync(path.join(peer, 'README.md'), 'hello\n')
|
||
git(peer, ['add', 'README.md'])
|
||
git(peer, ['commit', '-m', 'init'])
|
||
git(peer, ['push', '-u', 'origin', 'main'])
|
||
git(peer, ['checkout', '-b', 'feature/x'])
|
||
writeFileSync(path.join(peer, 'x.txt'), 'x\n')
|
||
git(peer, ['add', 'x.txt'])
|
||
git(peer, ['commit', '-m', 'feat x'])
|
||
git(peer, ['push', '-u', 'origin', 'feature/x'])
|
||
const repo = path.join(root, 'repo')
|
||
git(root, ['clone', origin, repo])
|
||
const templatePath = path.join(root, 'wt', 'x')
|
||
return { root, origin, repo, templatePath }
|
||
}
|
||
|
||
let fx: Fixture | undefined
|
||
afterEach(() => {
|
||
if (fx)
|
||
rmSync(fx.root, { recursive: true, force: true })
|
||
fx = undefined
|
||
})
|
||
|
||
describe('ensureWorktreeFromRemote', () => {
|
||
it('从 origin 分支建 worktree,本地分支跟踪远端', async () => {
|
||
fx = await setup()
|
||
const out = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
|
||
expect(out).toBe(fx.templatePath)
|
||
expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true)
|
||
expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||
expect(git(fx.templatePath, ['rev-parse', '--abbrev-ref', '@{u}'])).toBe('origin/feature/x')
|
||
})
|
||
|
||
it('该分支已有 live worktree 时直接复用', async () => {
|
||
fx = await setup()
|
||
const first = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
|
||
const other = path.join(fx.root, 'wt', 'other')
|
||
const second = await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: other, branch: 'feature/x' })
|
||
expect(second).toBe(first)
|
||
expect(existsSync(other)).toBe(false)
|
||
})
|
||
|
||
it('模板路径被非 worktree 目录占着 → 删掉重建', async () => {
|
||
fx = await setup()
|
||
await fsp.mkdir(fx.templatePath, { recursive: true })
|
||
writeFileSync(path.join(fx.templatePath, 'junk'), '')
|
||
await ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x' })
|
||
expect(existsSync(path.join(fx.templatePath, 'junk'))).toBe(false)
|
||
expect(existsSync(path.join(fx.templatePath, 'x.txt'))).toBe(true)
|
||
})
|
||
|
||
it('远端没有该分支 → 抛错', async () => {
|
||
fx = await setup()
|
||
await expect(
|
||
ensureWorktreeFromRemote({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/none' }),
|
||
).rejects.toThrow(/feature\/none/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test test/git/worktreeFromRemote.test.ts`
|
||
Expected: FAIL(`ensureWorktreeFromRemote` 不存在)
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`src/git/worktree.ts`:把 `function runGit` 与 `async function findLiveWorktreeForBranch` 改为 `export`。在 `ensureWorktree` 之后新增:
|
||
|
||
```ts
|
||
/**
|
||
* 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。
|
||
* `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。
|
||
*/
|
||
export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string> {
|
||
const { workspaceRoot, branch } = opts
|
||
const worktreePath = path.resolve(opts.worktreePath)
|
||
|
||
await runGit(workspaceRoot, ['fetch', 'origin', branch], `拉取远端分支 ${branch}`)
|
||
|
||
// ① 已经有 live worktree → 复用,不动模板路径
|
||
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
|
||
if (live)
|
||
return live
|
||
|
||
// ② 模板路径被垃圾目录占着 → kill + 删,否则 add 撞路径
|
||
if (await pathExists(worktreePath)) {
|
||
killProcessesUsingWorktree(worktreePath)
|
||
await removeWorktreeDir(workspaceRoot, worktreePath)
|
||
}
|
||
|
||
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
||
await runGit(
|
||
workspaceRoot,
|
||
['worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`],
|
||
'git worktree add',
|
||
)
|
||
return worktreePath
|
||
}
|
||
```
|
||
|
||
`removeWorktreeDir` 是 `rm -rf` + `git worktree prune`,对非 worktree 的垃圾目录同样适用;它拒绝少于 3 段的浅路径,测试里的模板路径在 `/tmp/sw-remote-*/wt/x` 下满足要求。
|
||
|
||
- [ ] **Step 4: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test test/git/worktreeFromRemote.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/git/worktree.ts test/git/worktreeFromRemote.test.ts
|
||
git commit -m "✨ feat(vscode): ensureWorktreeFromRemote 从远端分支重建 worktree"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `cc/sessionBundle.ts`(会话文件查找 / 安装 / codex 定位)
|
||
|
||
**Files:**
|
||
- Create: `src/cc/sessionBundle.ts`
|
||
- Create: `src/cc/sessionBundle.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```ts
|
||
export interface ClaudeSessionFiles { projectsDir: string, jsonl: string, dir?: string }
|
||
export function claudeProjectsRoot(): string // ~/.claude/projects
|
||
export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]>
|
||
export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined> // 多份取 jsonl mtime 最新
|
||
export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void>
|
||
export async function installClaudeSession(opts: { sid: string, srcDir: string, dstProjectsDir: string, projectsRoot: string }): Promise<void>
|
||
export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined>
|
||
export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void>
|
||
```
|
||
`installClaudeSession` 语义:把 `srcDir/<sid>.jsonl`(+ `srcDir/<sid>/`)拷到 `dstProjectsDir`,然后删除 `projectsRoot` 下所有 `projectsDir !== dstProjectsDir` 的副本。srcDir 本身在 projectsRoot 下时(完成列搬迁)也成立。
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
`src/cc/sessionBundle.test.ts`:
|
||
|
||
```ts
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import { afterEach, describe, expect, it } from 'vitest'
|
||
import {
|
||
codexRolloutRelPathFor,
|
||
findClaudeSessionFiles,
|
||
installClaudeSession,
|
||
installCodexSession,
|
||
listClaudeSessionCopies,
|
||
} from './sessionBundle'
|
||
|
||
const tempDirs: string[] = []
|
||
afterEach(() => {
|
||
while (tempDirs.length > 0) {
|
||
const d = tempDirs.pop()
|
||
if (d)
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
function tempDir(prefix: string): string {
|
||
const d = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||
tempDirs.push(d)
|
||
return d
|
||
}
|
||
|
||
const SID = '11111111-2222-4333-8444-555555555555'
|
||
|
||
function writeSession(projectsRoot: string, encoded: string, sid: string, body: string, withDir = false): string {
|
||
const dir = path.join(projectsRoot, encoded)
|
||
mkdirSync(dir, { recursive: true })
|
||
writeFileSync(path.join(dir, `${sid}.jsonl`), body)
|
||
if (withDir) {
|
||
mkdirSync(path.join(dir, sid, 'subagents'), { recursive: true })
|
||
writeFileSync(path.join(dir, sid, 'subagents', 'agent-a.jsonl'), '{}\n')
|
||
}
|
||
return dir
|
||
}
|
||
|
||
describe('listClaudeSessionCopies / findClaudeSessionFiles', () => {
|
||
it('列出所有 project 目录下的同 sid 副本,含子目录', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const a = writeSession(root, '-home-a-proj', SID, 'a\n', true)
|
||
const b = writeSession(root, '-home-a-wt', SID, 'b\n')
|
||
const copies = await listClaudeSessionCopies(SID, root)
|
||
expect(copies.map(c => c.projectsDir).sort()).toEqual([a, b].sort())
|
||
expect(copies.find(c => c.projectsDir === a)?.dir).toBe(path.join(a, SID))
|
||
expect(copies.find(c => c.projectsDir === b)?.dir).toBeUndefined()
|
||
})
|
||
|
||
it('多份副本取 jsonl mtime 最新的', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const old = writeSession(root, '-old', SID, 'old\n')
|
||
const fresh = writeSession(root, '-fresh', SID, 'fresh\n')
|
||
utimesSync(path.join(old, `${SID}.jsonl`), new Date(0), new Date(0))
|
||
const found = await findClaudeSessionFiles(SID, root)
|
||
expect(found?.projectsDir).toBe(fresh)
|
||
})
|
||
|
||
it('projectsRoot 不存在 → 空 / undefined', async () => {
|
||
expect(await listClaudeSessionCopies(SID, '/nonexistent/x')).toEqual([])
|
||
expect(await findClaudeSessionFiles(SID, '/nonexistent/x')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('installClaudeSession', () => {
|
||
it('从外部目录装入目标 project 目录,并清掉本机其它副本', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const stale = writeSession(root, '-stale', SID, 'stale\n', true)
|
||
const src = tempDir('cc-src-')
|
||
mkdirSync(path.join(src, SID, 'tool-results'), { recursive: true })
|
||
writeFileSync(path.join(src, `${SID}.jsonl`), 'new\n')
|
||
writeFileSync(path.join(src, SID, 'tool-results', 'r.txt'), 'r')
|
||
const dst = path.join(root, '-home-me-wt')
|
||
await installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: dst, projectsRoot: root })
|
||
expect(readFileSync(path.join(dst, `${SID}.jsonl`), 'utf8')).toBe('new\n')
|
||
expect(existsSync(path.join(dst, SID, 'tool-results', 'r.txt'))).toBe(true)
|
||
expect(existsSync(path.join(stale, `${SID}.jsonl`))).toBe(false)
|
||
expect(existsSync(path.join(stale, SID))).toBe(false)
|
||
})
|
||
|
||
it('源目录就在 projectsRoot 下(搬迁):目标保留,源删除', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const from = writeSession(root, '-wt', SID, 'x\n', true)
|
||
const to = path.join(root, '-proj')
|
||
await installClaudeSession({ sid: SID, srcDir: from, dstProjectsDir: to, projectsRoot: root })
|
||
expect(readFileSync(path.join(to, `${SID}.jsonl`), 'utf8')).toBe('x\n')
|
||
expect(existsSync(path.join(to, SID, 'subagents', 'agent-a.jsonl'))).toBe(true)
|
||
expect(existsSync(path.join(from, `${SID}.jsonl`))).toBe(false)
|
||
})
|
||
|
||
it('源就是目标 → 幂等不报错', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const dir = writeSession(root, '-proj', SID, 'x\n')
|
||
await installClaudeSession({ sid: SID, srcDir: dir, dstProjectsDir: dir, projectsRoot: root })
|
||
expect(readFileSync(path.join(dir, `${SID}.jsonl`), 'utf8')).toBe('x\n')
|
||
})
|
||
|
||
it('源缺 jsonl → 抛错', async () => {
|
||
const root = tempDir('cc-projects-')
|
||
const src = tempDir('cc-src-')
|
||
await expect(installClaudeSession({ sid: SID, srcDir: src, dstProjectsDir: path.join(root, '-x'), projectsRoot: root })).rejects.toThrow(SID)
|
||
})
|
||
})
|
||
|
||
describe('codex', () => {
|
||
const UUID = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||
it('按文件名尾部 uuid 定位 rollout,返回相对路径', async () => {
|
||
const codexRoot = tempDir('codex-')
|
||
const day = path.join(codexRoot, '2026', '08', '26')
|
||
mkdirSync(day, { recursive: true })
|
||
writeFileSync(path.join(day, `rollout-2026-08-26T01-02-03-${UUID}.jsonl`), '{}\n')
|
||
expect(await codexRolloutRelPathFor(UUID.toUpperCase(), codexRoot)).toBe(path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`))
|
||
expect(await codexRolloutRelPathFor('00000000-0000-4000-8000-000000000000', codexRoot)).toBeUndefined()
|
||
})
|
||
|
||
it('installCodexSession 按 relPath 落到 codexRoot 下并覆盖', async () => {
|
||
const codexRoot = tempDir('codex-')
|
||
const src = tempDir('codex-src-')
|
||
const rel = path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${UUID}.jsonl`)
|
||
writeFileSync(path.join(src, 'r.jsonl'), 'new\n')
|
||
await installCodexSession(rel, path.join(src, 'r.jsonl'), codexRoot)
|
||
expect(readFileSync(path.join(codexRoot, rel), 'utf8')).toBe('new\n')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test src/cc/sessionBundle.test.ts`
|
||
Expected: FAIL(模块不存在)
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`src/cc/sessionBundle.ts`:
|
||
|
||
```ts
|
||
/**
|
||
* claude / codex 会话文件的搬运原语。
|
||
*
|
||
* claude 会话 = `<projectsDir>/<sid>.jsonl` + 可选 `<projectsDir>/<sid>/`
|
||
* (subagents、tool-results),两者必须一起搬。新版 claude 在任意目录
|
||
* `--resume` 时会搜全部 project 目录,同一 sid 出现两份就拒绝 resume——
|
||
* 所以「安装」总是先拷再删本机其它副本。
|
||
*/
|
||
|
||
import { promises as fsp } from 'node:fs'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import { ROLLOUT_UUID_REGEX } from './codexSessionWatcher'
|
||
|
||
export interface ClaudeSessionFiles {
|
||
projectsDir: string
|
||
jsonl: string
|
||
dir?: string
|
||
}
|
||
|
||
export function claudeProjectsRoot(): string {
|
||
return path.join(os.homedir(), '.claude', 'projects')
|
||
}
|
||
|
||
async function exists(p: string): Promise<boolean> {
|
||
try {
|
||
await fsp.stat(p)
|
||
return true
|
||
}
|
||
catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function sessionFilesIn(projectsDir: string, sid: string): Promise<ClaudeSessionFiles | undefined> {
|
||
const jsonl = path.join(projectsDir, `${sid}.jsonl`)
|
||
if (!(await exists(jsonl)))
|
||
return undefined
|
||
const dir = path.join(projectsDir, sid)
|
||
return (await exists(dir)) ? { projectsDir, jsonl, dir } : { projectsDir, jsonl }
|
||
}
|
||
|
||
export async function listClaudeSessionCopies(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles[]> {
|
||
let entries: string[]
|
||
try {
|
||
entries = await fsp.readdir(projectsRoot)
|
||
}
|
||
catch {
|
||
return []
|
||
}
|
||
const out: ClaudeSessionFiles[] = []
|
||
for (const name of entries) {
|
||
const found = await sessionFilesIn(path.join(projectsRoot, name), sid)
|
||
if (found)
|
||
out.push(found)
|
||
}
|
||
return out
|
||
}
|
||
|
||
export async function findClaudeSessionFiles(sid: string, projectsRoot: string): Promise<ClaudeSessionFiles | undefined> {
|
||
const copies = await listClaudeSessionCopies(sid, projectsRoot)
|
||
if (copies.length === 0)
|
||
return undefined
|
||
const stamped = await Promise.all(copies.map(async c => ({ c, mtime: (await fsp.stat(c.jsonl)).mtimeMs })))
|
||
stamped.sort((a, b) => b.mtime - a.mtime)
|
||
return stamped[0].c
|
||
}
|
||
|
||
export async function copyClaudeSessionFiles(files: ClaudeSessionFiles, dstDir: string): Promise<void> {
|
||
await fsp.mkdir(dstDir, { recursive: true })
|
||
await fsp.copyFile(files.jsonl, path.join(dstDir, path.basename(files.jsonl)))
|
||
if (files.dir)
|
||
await fsp.cp(files.dir, path.join(dstDir, path.basename(files.dir)), { recursive: true, force: true })
|
||
}
|
||
|
||
async function removeClaudeSessionFiles(files: ClaudeSessionFiles): Promise<void> {
|
||
await fsp.rm(files.jsonl, { force: true })
|
||
if (files.dir)
|
||
await fsp.rm(files.dir, { recursive: true, force: true })
|
||
}
|
||
|
||
export async function installClaudeSession(opts: {
|
||
sid: string
|
||
srcDir: string
|
||
dstProjectsDir: string
|
||
projectsRoot: string
|
||
}): Promise<void> {
|
||
const src = await sessionFilesIn(path.resolve(opts.srcDir), opts.sid)
|
||
if (!src)
|
||
throw new Error(`源目录缺少会话文件 ${opts.sid}.jsonl:${opts.srcDir}`)
|
||
const dst = path.resolve(opts.dstProjectsDir)
|
||
if (src.projectsDir !== dst)
|
||
await copyClaudeSessionFiles(src, dst)
|
||
for (const copy of await listClaudeSessionCopies(opts.sid, opts.projectsRoot)) {
|
||
if (path.resolve(copy.projectsDir) !== dst)
|
||
await removeClaudeSessionFiles(copy)
|
||
}
|
||
}
|
||
|
||
/** codex rollout 全局存放,按文件名尾部 uuid 定位;返回相对 codexRoot 的路径。 */
|
||
export async function codexRolloutRelPathFor(sid: string, codexRoot: string): Promise<string | undefined> {
|
||
const want = sid.toLowerCase()
|
||
let entries: string[]
|
||
try {
|
||
entries = await fsp.readdir(codexRoot, { recursive: true })
|
||
}
|
||
catch {
|
||
return undefined
|
||
}
|
||
for (const entry of entries) {
|
||
const m = path.basename(entry).match(ROLLOUT_UUID_REGEX)
|
||
if (m && m[1].toLowerCase() === want)
|
||
return entry
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
export async function installCodexSession(relPath: string, srcFile: string, codexRoot: string): Promise<void> {
|
||
const dst = path.join(codexRoot, relPath)
|
||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||
await fsp.copyFile(srcFile, dst)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test src/cc/sessionBundle.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/cc/sessionBundle.ts src/cc/sessionBundle.test.ts
|
||
git commit -m "✨ feat(vscode): sessionBundle 会话文件查找/安装/去重原语"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `cc/handoffManifest.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/cc/handoffManifest.ts`
|
||
- Create: `src/cc/handoffManifest.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```ts
|
||
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
|
||
export const CLAUDE_SESSION_FIELDS: ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
|
||
export interface HandoffSessions { sessionId?: string, implementSessionId?: string, testSessionId?: string, reviewSessionId?: string }
|
||
export interface HandoffProfiles { profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string }
|
||
export interface HandoffManifest {
|
||
version: 1, issue: number, from: string, createdAt: string, branch?: string,
|
||
sessions: HandoffSessions, profiles: HandoffProfiles,
|
||
claude: Array<{ id: string, kind: ClaudeSessionKind }>,
|
||
codex: Array<{ id: string, relPath: string }>
|
||
}
|
||
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
|
||
export function handoffAttachmentName(issueNumber: number): string // `spx-handoff-issue-${n}.tgz`
|
||
export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest // 不合法抛 Error(中文)
|
||
```
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
`src/cc/handoffManifest.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import { handoffAttachmentName, parseHandoffManifest } from './handoffManifest'
|
||
|
||
const good = {
|
||
version: 1,
|
||
issue: 42,
|
||
from: 'chw',
|
||
createdAt: '2026-08-26T00:00:00Z',
|
||
branch: 'feature/x',
|
||
sessions: { implementSessionId: 'a' },
|
||
profiles: {},
|
||
claude: [{ id: 'a', kind: 'implement' }],
|
||
codex: [],
|
||
}
|
||
|
||
describe('handoffAttachmentName', () => {
|
||
it('固定命名', () => {
|
||
expect(handoffAttachmentName(42)).toBe('spx-handoff-issue-42.tgz')
|
||
})
|
||
})
|
||
|
||
describe('parseHandoffManifest', () => {
|
||
it('合法清单原样返回', () => {
|
||
expect(parseHandoffManifest(JSON.stringify(good), 42)).toEqual(good)
|
||
})
|
||
it('issue 不匹配 → 抛错', () => {
|
||
expect(() => parseHandoffManifest(JSON.stringify(good), 43)).toThrow(/42/)
|
||
})
|
||
it('version 不是 1 → 抛错', () => {
|
||
expect(() => parseHandoffManifest(JSON.stringify({ ...good, version: 2 }), 42)).toThrow(/version/)
|
||
})
|
||
it('claude 条目 kind 非法 → 抛错', () => {
|
||
expect(() => parseHandoffManifest(JSON.stringify({ ...good, claude: [{ id: 'a', kind: 'x' }] }), 42)).toThrow(/claude/)
|
||
})
|
||
it('不是 JSON → 抛错', () => {
|
||
expect(() => parseHandoffManifest('nope', 42)).toThrow()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test src/cc/handoffManifest.test.ts`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`src/cc/handoffManifest.ts`:
|
||
|
||
```ts
|
||
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
|
||
|
||
export const CLAUDE_SESSION_FIELDS = [
|
||
{ field: 'sessionId', kind: 'brainstorm' },
|
||
{ field: 'implementSessionId', kind: 'implement' },
|
||
{ field: 'testSessionId', kind: 'test' },
|
||
] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
|
||
|
||
export interface HandoffSessions {
|
||
sessionId?: string
|
||
implementSessionId?: string
|
||
testSessionId?: string
|
||
reviewSessionId?: string
|
||
}
|
||
|
||
export interface HandoffProfiles {
|
||
profilePath?: string
|
||
brainstormProfilePath?: string
|
||
testProfilePath?: string
|
||
}
|
||
|
||
export interface HandoffManifest {
|
||
version: 1
|
||
issue: number
|
||
from: string
|
||
createdAt: string
|
||
branch?: string
|
||
sessions: HandoffSessions
|
||
profiles: HandoffProfiles
|
||
claude: Array<{ id: string, kind: ClaudeSessionKind }>
|
||
codex: Array<{ id: string, relPath: string }>
|
||
}
|
||
|
||
export const HANDOFF_MANIFEST_FILE = 'handoff.json'
|
||
|
||
export function handoffAttachmentName(issueNumber: number): string {
|
||
return `spx-handoff-issue-${issueNumber}.tgz`
|
||
}
|
||
|
||
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test'])
|
||
|
||
function optString(v: unknown): string | undefined {
|
||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||
}
|
||
|
||
export function parseHandoffManifest(json: string, expectedIssue: number): HandoffManifest {
|
||
const raw = JSON.parse(json) as Record<string, unknown>
|
||
if (!raw || typeof raw !== 'object')
|
||
throw new Error('handoff.json 不是对象')
|
||
if (raw.version !== 1)
|
||
throw new Error(`handoff.json version 不支持:${String(raw.version)}`)
|
||
if (raw.issue !== expectedIssue)
|
||
throw new Error(`handoff.json 属于工单 #${String(raw.issue)},不是 #${expectedIssue}`)
|
||
const from = optString(raw.from)
|
||
const createdAt = optString(raw.createdAt)
|
||
if (!from || !createdAt)
|
||
throw new Error('handoff.json 缺少 from / createdAt')
|
||
const sessionsRaw = (raw.sessions ?? {}) as Record<string, unknown>
|
||
const profilesRaw = (raw.profiles ?? {}) as Record<string, unknown>
|
||
const claudeRaw = Array.isArray(raw.claude) ? raw.claude : []
|
||
const codexRaw = Array.isArray(raw.codex) ? raw.codex : []
|
||
const claude = claudeRaw.map((e) => {
|
||
const o = e as Record<string, unknown>
|
||
const id = optString(o.id)
|
||
const kind = optString(o.kind)
|
||
if (!id || !kind || !KINDS.has(kind))
|
||
throw new Error(`handoff.json claude 条目非法:${JSON.stringify(e)}`)
|
||
return { id, kind: kind as ClaudeSessionKind }
|
||
})
|
||
const codex = codexRaw.map((e) => {
|
||
const o = e as Record<string, unknown>
|
||
const id = optString(o.id)
|
||
const relPath = optString(o.relPath)
|
||
if (!id || !relPath || relPath.startsWith('/') || relPath.includes('..'))
|
||
throw new Error(`handoff.json codex 条目非法:${JSON.stringify(e)}`)
|
||
return { id, relPath }
|
||
})
|
||
const sessions: HandoffSessions = {}
|
||
for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) {
|
||
const v = optString(sessionsRaw[k])
|
||
if (v)
|
||
sessions[k] = v
|
||
}
|
||
const profiles: HandoffProfiles = {}
|
||
for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) {
|
||
const v = optString(profilesRaw[k])
|
||
if (v)
|
||
profiles[k] = v
|
||
}
|
||
return {
|
||
version: 1,
|
||
issue: expectedIssue,
|
||
from,
|
||
createdAt,
|
||
...(optString(raw.branch) ? { branch: optString(raw.branch) } : {}),
|
||
sessions,
|
||
profiles,
|
||
claude,
|
||
codex,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test src/cc/handoffManifest.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/cc/handoffManifest.ts src/cc/handoffManifest.test.ts
|
||
git commit -m "✨ feat(vscode): handoff 清单类型与校验"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `cc/handoffArchive.ts` + `cc/handoffBundle.ts`
|
||
|
||
**Files:**
|
||
- Create: `src/cc/handoffArchive.ts`
|
||
- Create: `src/cc/handoffBundle.ts`
|
||
- Create: `src/cc/handoffBundle.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 4 全部、Task 5 类型。
|
||
- Produces:
|
||
```ts
|
||
// handoffArchive.ts
|
||
export async function packHandoff(stagingDir: string, outFile: string): Promise<void>
|
||
export async function unpackHandoff(archive: string, dstDir: string): Promise<void>
|
||
// handoffBundle.ts
|
||
export interface HandoffSource { issueNumber: number, from: string, branch?: string, sessions: HandoffSessions, profiles: HandoffProfiles }
|
||
export interface HandoffRoots { projectsRoot: string, codexRoot: string }
|
||
export async function stageHandoffBundle(src: HandoffSource, stagingDir: string, roots: HandoffRoots, now?: Date): Promise<HandoffManifest>
|
||
export interface HandoffInstallTargets extends HandoffRoots { workspaceProjectsDir: string, worktreeProjectsDir?: string }
|
||
export async function installHandoffBundle(extractedDir: string, manifest: HandoffManifest, targets: HandoffInstallTargets): Promise<void>
|
||
```
|
||
`stageHandoffBundle`:在 `stagingDir` 下生成 `claude/`、`codex/`、`handoff.json`,返回清单(找不到的会话不进 `claude`/`codex` 列表,但 `sessions` 仍原样记录 id)。
|
||
`installHandoffBundle`:brainstorm → `workspaceProjectsDir`;implement/test → `worktreeProjectsDir ?? workspaceProjectsDir`;codex → `codexRoot/<relPath>`。
|
||
|
||
- [ ] **Step 1: 写测试(stage → pack → unpack → install 全链路)**
|
||
|
||
`src/cc/handoffBundle.test.ts`:
|
||
|
||
```ts
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { packHandoff, unpackHandoff } from './handoffArchive'
|
||
import { installHandoffBundle, stageHandoffBundle } from './handoffBundle'
|
||
import { HANDOFF_MANIFEST_FILE, parseHandoffManifest } from './handoffManifest'
|
||
|
||
const tempDirs: string[] = []
|
||
afterEach(() => {
|
||
while (tempDirs.length > 0) {
|
||
const d = tempDirs.pop()
|
||
if (d)
|
||
rmSync(d, { recursive: true, force: true })
|
||
}
|
||
})
|
||
function tempDir(prefix: string): string {
|
||
const d = mkdtempSync(path.join(os.tmpdir(), prefix))
|
||
tempDirs.push(d)
|
||
return d
|
||
}
|
||
|
||
const BRAIN = 'aaaaaaaa-0000-4000-8000-000000000001'
|
||
const IMPL = 'bbbbbbbb-0000-4000-8000-000000000002'
|
||
const REVIEW = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a'
|
||
|
||
function senderRoots(): { projectsRoot: string, codexRoot: string } {
|
||
const projectsRoot = tempDir('sender-projects-')
|
||
const codexRoot = tempDir('sender-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`), 'impl\n')
|
||
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 }
|
||
}
|
||
|
||
describe('stageHandoffBundle', () => {
|
||
it('收集存在的会话,缺失的不进列表', async () => {
|
||
const roots = senderRoots()
|
||
const staging = tempDir('staging-')
|
||
const manifest = await stageHandoffBundle({
|
||
issueNumber: 7,
|
||
from: 'chw',
|
||
branch: 'feature/x',
|
||
sessions: { sessionId: BRAIN, implementSessionId: IMPL, testSessionId: 'missing-0000-4000-8000-000000000000', reviewSessionId: REVIEW },
|
||
profiles: { profilePath: '/Users/chw/p.json' },
|
||
}, staging, roots, new Date('2026-08-26T00:00:00Z'))
|
||
expect(manifest.claude).toEqual([{ id: BRAIN, kind: 'brainstorm' }, { id: IMPL, kind: 'implement' }])
|
||
expect(manifest.codex).toEqual([{ id: REVIEW, relPath: path.join('2026', '08', '26', `rollout-2026-08-26T01-02-03-${REVIEW}.jsonl`) }])
|
||
expect(manifest.sessions.testSessionId).toBe('missing-0000-4000-8000-000000000000')
|
||
expect(manifest.createdAt).toBe('2026-08-26T00:00:00.000Z')
|
||
expect(existsSync(path.join(staging, 'claude', `${IMPL}.jsonl`))).toBe(true)
|
||
expect(existsSync(path.join(staging, 'claude', IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true)
|
||
expect(existsSync(path.join(staging, 'codex', manifest.codex[0].relPath))).toBe(true)
|
||
expect(parseHandoffManifest(readFileSync(path.join(staging, HANDOFF_MANIFEST_FILE), 'utf8'), 7)).toEqual(manifest)
|
||
})
|
||
})
|
||
|
||
describe('pack → unpack → install', () => {
|
||
it('接管方装到对应 project 目录与 codex 目录', async () => {
|
||
const roots = senderRoots()
|
||
const staging = tempDir('staging-')
|
||
const manifest = await stageHandoffBundle({
|
||
issueNumber: 7,
|
||
from: 'chw',
|
||
branch: 'feature/x',
|
||
sessions: { sessionId: BRAIN, implementSessionId: IMPL, reviewSessionId: REVIEW },
|
||
profiles: {},
|
||
}, staging, roots)
|
||
const archive = path.join(tempDir('archive-'), 'spx-handoff-issue-7.tgz')
|
||
await packHandoff(staging, archive)
|
||
expect(existsSync(archive)).toBe(true)
|
||
|
||
const extracted = tempDir('extracted-')
|
||
await unpackHandoff(archive, extracted)
|
||
const parsed = parseHandoffManifest(readFileSync(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), 7)
|
||
expect(parsed).toEqual(manifest)
|
||
|
||
const projectsRoot = tempDir('receiver-projects-')
|
||
const codexRoot = tempDir('receiver-codex-')
|
||
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
|
||
const worktreeProjectsDir = path.join(projectsRoot, '-home-me-wt-x')
|
||
// 本机残留的同 sid 副本必须被清掉
|
||
mkdirSync(path.join(projectsRoot, '-stale'), { recursive: true })
|
||
writeFileSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`), 'stale\n')
|
||
await installHandoffBundle(extracted, parsed, { projectsRoot, codexRoot, workspaceProjectsDir, worktreeProjectsDir })
|
||
expect(readFileSync(path.join(workspaceProjectsDir, `${BRAIN}.jsonl`), 'utf8')).toBe('brain\n')
|
||
expect(readFileSync(path.join(worktreeProjectsDir, `${IMPL}.jsonl`), 'utf8')).toBe('impl\n')
|
||
expect(existsSync(path.join(worktreeProjectsDir, IMPL, 'subagents', 'agent-1.jsonl'))).toBe(true)
|
||
expect(existsSync(path.join(projectsRoot, '-stale', `${IMPL}.jsonl`))).toBe(false)
|
||
expect(readFileSync(path.join(codexRoot, parsed.codex[0].relPath), 'utf8')).toBe('review\n')
|
||
})
|
||
|
||
it('无 worktreeProjectsDir 时实施会话也装到工作区目录', async () => {
|
||
const roots = senderRoots()
|
||
const staging = tempDir('staging-')
|
||
const manifest = await stageHandoffBundle({ issueNumber: 7, from: 'chw', sessions: { implementSessionId: IMPL }, profiles: {} }, staging, roots)
|
||
const projectsRoot = tempDir('receiver-projects-')
|
||
const workspaceProjectsDir = path.join(projectsRoot, '-home-me-proj')
|
||
await installHandoffBundle(staging, manifest, { projectsRoot, codexRoot: tempDir('receiver-codex-'), workspaceProjectsDir })
|
||
expect(existsSync(path.join(workspaceProjectsDir, `${IMPL}.jsonl`))).toBe(true)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test src/cc/handoffBundle.test.ts`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: 实现 handoffArchive.ts**
|
||
|
||
```ts
|
||
import { execFile } from 'node:child_process'
|
||
import { promises as fsp } from 'node:fs'
|
||
import * as path from 'node:path'
|
||
|
||
function tar(args: string[]): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
execFile('tar', args, { timeout: 120_000 }, (err, _stdout, stderr) => {
|
||
if (err) {
|
||
reject(new Error(`tar ${args[0]} 失败: ${(stderr ?? '').toString().trim() || err.message}`))
|
||
return
|
||
}
|
||
resolve()
|
||
})
|
||
})
|
||
}
|
||
|
||
export async function packHandoff(stagingDir: string, outFile: string): Promise<void> {
|
||
await fsp.mkdir(path.dirname(outFile), { recursive: true })
|
||
await tar(['-czf', outFile, '-C', stagingDir, '.'])
|
||
}
|
||
|
||
export async function unpackHandoff(archive: string, dstDir: string): Promise<void> {
|
||
await fsp.mkdir(dstDir, { recursive: true })
|
||
await tar(['-xzf', archive, '-C', dstDir])
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 实现 handoffBundle.ts**
|
||
|
||
```ts
|
||
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from './handoffManifest'
|
||
import { promises as fsp } from 'node:fs'
|
||
import * as path from 'node:path'
|
||
import { CLAUDE_SESSION_FIELDS, HANDOFF_MANIFEST_FILE } from './handoffManifest'
|
||
import {
|
||
codexRolloutRelPathFor,
|
||
copyClaudeSessionFiles,
|
||
findClaudeSessionFiles,
|
||
installClaudeSession,
|
||
installCodexSession,
|
||
} from './sessionBundle'
|
||
|
||
export interface HandoffSource {
|
||
issueNumber: number
|
||
from: string
|
||
branch?: string
|
||
sessions: HandoffSessions
|
||
profiles: HandoffProfiles
|
||
}
|
||
|
||
export interface HandoffRoots {
|
||
projectsRoot: string
|
||
codexRoot: string
|
||
}
|
||
|
||
/** 把本机会话文件收进 stagingDir(claude/、codex/、handoff.json),返回清单。找不到的会话只从文件列表缺席,id 仍记录。 */
|
||
export async function stageHandoffBundle(
|
||
src: HandoffSource,
|
||
stagingDir: string,
|
||
roots: HandoffRoots,
|
||
now: Date = new Date(),
|
||
): Promise<HandoffManifest> {
|
||
const claudeDir = path.join(stagingDir, 'claude')
|
||
const codexDir = path.join(stagingDir, 'codex')
|
||
await fsp.mkdir(claudeDir, { recursive: true })
|
||
await fsp.mkdir(codexDir, { recursive: true })
|
||
|
||
const claude: HandoffManifest['claude'] = []
|
||
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
|
||
await copyClaudeSessionFiles(files, claudeDir)
|
||
claude.push({ id, kind })
|
||
}
|
||
|
||
const codex: HandoffManifest['codex'] = []
|
||
if (src.sessions.reviewSessionId) {
|
||
const relPath = await codexRolloutRelPathFor(src.sessions.reviewSessionId, roots.codexRoot)
|
||
if (relPath) {
|
||
const dst = path.join(codexDir, relPath)
|
||
await fsp.mkdir(path.dirname(dst), { recursive: true })
|
||
await fsp.copyFile(path.join(roots.codexRoot, relPath), dst)
|
||
codex.push({ id: src.sessions.reviewSessionId, relPath })
|
||
}
|
||
}
|
||
|
||
const manifest: HandoffManifest = {
|
||
version: 1,
|
||
issue: src.issueNumber,
|
||
from: src.from,
|
||
createdAt: now.toISOString(),
|
||
...(src.branch ? { branch: src.branch } : {}),
|
||
sessions: src.sessions,
|
||
profiles: src.profiles,
|
||
claude,
|
||
codex,
|
||
}
|
||
await fsp.writeFile(path.join(stagingDir, HANDOFF_MANIFEST_FILE), JSON.stringify(manifest, null, 2))
|
||
return manifest
|
||
}
|
||
|
||
export interface HandoffInstallTargets extends HandoffRoots {
|
||
workspaceProjectsDir: string
|
||
worktreeProjectsDir?: string
|
||
}
|
||
|
||
export async function installHandoffBundle(
|
||
extractedDir: string,
|
||
manifest: HandoffManifest,
|
||
targets: HandoffInstallTargets,
|
||
): Promise<void> {
|
||
const claudeDir = path.join(extractedDir, 'claude')
|
||
for (const { id, kind } of manifest.claude) {
|
||
const dstProjectsDir = kind === 'brainstorm'
|
||
? targets.workspaceProjectsDir
|
||
: (targets.worktreeProjectsDir ?? targets.workspaceProjectsDir)
|
||
await installClaudeSession({ sid: id, srcDir: claudeDir, dstProjectsDir, projectsRoot: targets.projectsRoot })
|
||
}
|
||
for (const { relPath } of manifest.codex)
|
||
await installCodexSession(relPath, path.join(extractedDir, 'codex', relPath), targets.codexRoot)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test src/cc/handoffBundle.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/cc/handoffArchive.ts src/cc/handoffBundle.ts src/cc/handoffBundle.test.ts
|
||
git commit -m "✨ feat(vscode): handoff 会话包 stage/pack/unpack/install"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Gitea API:assignees 列表 + 附件
|
||
|
||
**Files:**
|
||
- Modify: `src/gitea/api.ts`(末尾追加)
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```ts
|
||
export interface GiteaAttachment { id: number, name: string, size: number, uuid: string, browser_download_url: string, created_at: string }
|
||
export async function listRepoAssignees(opts: { host, token, owner, repo }): Promise<GiteaUser[]>
|
||
export async function listIssueAttachments(opts: { host, token, owner, repo, index: number }): Promise<GiteaAttachment[]>
|
||
export async function uploadIssueAttachment(opts: { host, token, owner, repo, index: number, name: string, data: Buffer }): Promise<GiteaAttachment>
|
||
export async function getIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<GiteaAttachment>
|
||
export async function downloadAttachment(opts: { token: string, url: string }): Promise<Buffer>
|
||
export async function deleteIssueAttachment(opts: { host, token, owner, repo, index: number, attachmentId: number }): Promise<void>
|
||
```
|
||
|
||
- [ ] **Step 1: 实现**
|
||
|
||
在 `src/gitea/api.ts` 末尾追加(`Buffer` 从 `'node:buffer'` import;`FormData`/`Blob` 用 Node 22 全局):
|
||
|
||
```ts
|
||
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
|
||
token: string
|
||
owner: string
|
||
repo: string
|
||
}): Promise<GiteaUser[]> {
|
||
const res = await fetch(
|
||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/assignees`,
|
||
{ headers: authHeaders(opts.token) },
|
||
)
|
||
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()
|
||
form.append('attachment', new Blob([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
|
||
}
|
||
|
||
export async function getIssueAttachment(opts: {
|
||
host: string
|
||
token: string
|
||
owner: string
|
||
repo: string
|
||
index: number
|
||
attachmentId: number
|
||
}): Promise<GiteaAttachment> {
|
||
const res = await fetch(
|
||
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/assets/${opts.attachmentId}`,
|
||
{ headers: authHeaders(opts.token) },
|
||
)
|
||
await ensureOk(res)
|
||
return (await res.json()) as GiteaAttachment
|
||
}
|
||
|
||
/**
|
||
* `browser_download_url` 走的是 web 路由(/attachments/<uuid>),Gitea 的 web
|
||
* 鉴权组同样接受 `Authorization: token`。未登录时它会 302 到登录页返回 HTML,
|
||
* 所以除 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)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 用真实 Gitea 验证下载鉴权(一次性脚本,放 scratchpad,不入库)**
|
||
|
||
在本仓库自己的 Gitea(`gitea.ailoveworld.cn`,`git remote -v` 取 owner/repo)上挑一个已关闭的测试工单 N,token 从 `~/.config/tea/config.yml` 读(若 token 走 keyring,取 `GITEA_TOKEN` 环境变量):
|
||
|
||
```bash
|
||
T=<token>; O=<owner>; R=<repo>; N=<issue>
|
||
echo hi > /tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt
|
||
curl -s -H "Authorization: token $T" -F "attachment=@/tmp/claude-1000/-home-cruldra-Sources-superwork/62cd8318-2051-42ed-9511-9c135e5f9c93/scratchpad/t.txt" \
|
||
"https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets?name=t.txt" | tee /dev/stderr | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['id'], d['browser_download_url'])"
|
||
# 用输出的 URL:
|
||
curl -sL -o /dev/null -w '%{http_code} %{content_type}\n' -H "Authorization: token $T" <browser_download_url>
|
||
# 期望 200 且 content_type 不是 text/html;然后删掉测试附件:
|
||
curl -s -X DELETE -H "Authorization: token $T" "https://gitea.ailoveworld.cn/api/v1/repos/$O/$R/issues/$N/assets/<id>"
|
||
```
|
||
|
||
Expected: `200 text/plain…`。若返回 HTML / 302 登录页,改 `downloadAttachment` 为请求 `https://<host>/attachments/<uuid>` 并加 `?token=` 方案前先把结果报告给用户再定。
|
||
|
||
- [ ] **Step 3: typecheck + 提交**
|
||
|
||
Run: `pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/gitea/api.ts
|
||
git commit -m "✨ feat(vscode): Gitea API 增加 assignees 列表与工单附件读写"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `panel/handlers/handoff.ts`(纯决策 + payload)
|
||
|
||
**Files:**
|
||
- Create: `src/panel/handlers/handoff.ts`
|
||
- Create: `src/panel/handlers/handoff.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
```ts
|
||
export function canStartHandoff(issue: { source?: 'gitea' | 'youtrack', column: IssueColumn, handoffAttachmentId?: string }): boolean
|
||
export function canAcceptHandoff(issue: { source?: 'gitea' | 'youtrack', handoffAttachmentId?: string, assignees?: string[] }, me: string | undefined): boolean
|
||
export function checkWorktreePushed(input: { statusPorcelain: string, localHead: string, remoteHead: string }): { ok: true } | { ok: false, reason: string }
|
||
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown>
|
||
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown>
|
||
export function handoffAcceptedStateExtra(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string }): Record<string, unknown>
|
||
export function handoffAcceptedUiPatch(local: { sessions: HandoffSessions, profiles: HandoffProfiles, worktreePath?: string, worktreeExists: boolean }): Record<string, unknown>
|
||
```
|
||
|
||
- [ ] **Step 1: 写测试**
|
||
|
||
`src/panel/handlers/handoff.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
canAcceptHandoff,
|
||
canStartHandoff,
|
||
checkWorktreePushed,
|
||
handoffAcceptedStateExtra,
|
||
handoffAcceptedUiPatch,
|
||
handoffStartedStateExtra,
|
||
handoffStartedUiPatch,
|
||
} from './handoff'
|
||
|
||
describe('canStartHandoff', () => {
|
||
it('gitea 工单、非 done、无待接管 → true', () => {
|
||
expect(canStartHandoff({ column: 'in-progress' })).toBe(true)
|
||
expect(canStartHandoff({ column: 'todo', source: 'gitea' })).toBe(true)
|
||
})
|
||
it('done / youtrack / 已在移交中 → false', () => {
|
||
expect(canStartHandoff({ column: 'done' })).toBe(false)
|
||
expect(canStartHandoff({ column: 'review', source: 'youtrack' })).toBe(false)
|
||
expect(canStartHandoff({ column: 'review', handoffAttachmentId: '9' })).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('canAcceptHandoff', () => {
|
||
it('有附件且 assignee 含我 → true', () => {
|
||
expect(canAcceptHandoff({ handoffAttachmentId: '9', assignees: ['me', 'x'] }, 'me')).toBe(true)
|
||
})
|
||
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)
|
||
})
|
||
})
|
||
|
||
describe('checkWorktreePushed', () => {
|
||
it('干净且 HEAD 与远端一致 → ok', () => {
|
||
expect(checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'abc' })).toEqual({ ok: true })
|
||
})
|
||
it('有未提交改动 → 拒绝', () => {
|
||
const r = checkWorktreePushed({ statusPorcelain: ' M a.ts\n', localHead: 'abc', remoteHead: 'abc' })
|
||
expect(r.ok).toBe(false)
|
||
if (!r.ok)
|
||
expect(r.reason).toMatch(/未提交/)
|
||
})
|
||
it('本地领先远端 → 拒绝', () => {
|
||
const r = checkWorktreePushed({ statusPorcelain: '', localHead: 'abc', remoteHead: 'def' })
|
||
expect(r.ok).toBe(false)
|
||
if (!r.ok)
|
||
expect(r.reason).toMatch(/push/)
|
||
})
|
||
})
|
||
|
||
describe('payloads', () => {
|
||
it('移交落盘:两个共享字段 + 本机字段全部墓碑', () => {
|
||
expect(handoffStartedStateExtra(9, 'chw')).toEqual({
|
||
handoffAttachmentId: '9',
|
||
handoffFrom: 'chw',
|
||
sessionId: '',
|
||
implementSessionId: '',
|
||
reviewSessionId: '',
|
||
testSessionId: '',
|
||
profilePath: '',
|
||
brainstormProfilePath: '',
|
||
testProfilePath: '',
|
||
worktreePath: '',
|
||
prDiffFile: '',
|
||
})
|
||
})
|
||
it('移交 UI patch 用 null 清字段并更新 assignees', () => {
|
||
expect(handoffStartedUiPatch(9, 'chw', 'me')).toEqual({
|
||
handoffAttachmentId: '9',
|
||
handoffFrom: 'chw',
|
||
assignees: ['me'],
|
||
sessionId: null,
|
||
implementSessionId: null,
|
||
reviewSessionId: null,
|
||
testSessionId: null,
|
||
worktreePath: null,
|
||
worktreeExists: false,
|
||
})
|
||
})
|
||
it('接管落盘:清两个共享字段,写本机 sid / profile / worktree', () => {
|
||
expect(handoffAcceptedStateExtra({
|
||
sessions: { implementSessionId: 'i', reviewSessionId: 'r' },
|
||
profiles: { profilePath: '/p.json' },
|
||
worktreePath: '/wt',
|
||
})).toEqual({
|
||
handoffAttachmentId: '',
|
||
handoffFrom: '',
|
||
implementSessionId: 'i',
|
||
reviewSessionId: 'r',
|
||
profilePath: '/p.json',
|
||
worktreePath: '/wt',
|
||
})
|
||
})
|
||
it('接管 UI patch', () => {
|
||
expect(handoffAcceptedUiPatch({
|
||
sessions: { sessionId: 's' },
|
||
profiles: {},
|
||
worktreePath: '/wt',
|
||
worktreeExists: true,
|
||
})).toEqual({
|
||
handoffAttachmentId: null,
|
||
handoffFrom: null,
|
||
sessionId: 's',
|
||
worktreePath: '/wt',
|
||
worktreeExists: true,
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test src/panel/handlers/handoff.test.ts`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: 实现**
|
||
|
||
`src/panel/handlers/handoff.ts`:
|
||
|
||
```ts
|
||
import type { HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
|
||
import type { IssueColumn } from '../../gitea/types'
|
||
import { LOCAL_STATE_FIELDS } from '../../issues/localState'
|
||
|
||
export function canStartHandoff(issue: {
|
||
source?: 'gitea' | 'youtrack'
|
||
column: IssueColumn
|
||
handoffAttachmentId?: string
|
||
}): boolean {
|
||
return issue.source !== 'youtrack' && issue.column !== 'done' && !issue.handoffAttachmentId
|
||
}
|
||
|
||
export function canAcceptHandoff(issue: {
|
||
source?: 'gitea' | 'youtrack'
|
||
handoffAttachmentId?: string
|
||
assignees?: string[]
|
||
}, me: string | undefined): boolean {
|
||
return issue.source !== 'youtrack'
|
||
&& !!issue.handoffAttachmentId
|
||
&& me !== undefined
|
||
&& (issue.assignees ?? []).includes(me)
|
||
}
|
||
|
||
/** 移交前 worktree 必须「干净 + 全部 push」,否则接管方从远端重建会丢工作。 */
|
||
export function checkWorktreePushed(input: {
|
||
statusPorcelain: string
|
||
localHead: string
|
||
remoteHead: string
|
||
}): { ok: true } | { ok: false, reason: string } {
|
||
if (input.statusPorcelain.trim().length > 0)
|
||
return { ok: false, reason: '工作区有未提交改动,请先提交并 push' }
|
||
if (input.localHead.trim() !== input.remoteHead.trim())
|
||
return { ok: false, reason: '本地分支与远端不一致,请先 push' }
|
||
return { ok: true }
|
||
}
|
||
|
||
export function handoffStartedStateExtra(attachmentId: number, from: string): Record<string, unknown> {
|
||
const extra: Record<string, unknown> = { handoffAttachmentId: String(attachmentId), handoffFrom: from }
|
||
for (const f of LOCAL_STATE_FIELDS)
|
||
extra[f] = ''
|
||
return extra
|
||
}
|
||
|
||
export function handoffStartedUiPatch(attachmentId: number, from: string, to: string): Record<string, unknown> {
|
||
return {
|
||
handoffAttachmentId: String(attachmentId),
|
||
handoffFrom: from,
|
||
assignees: [to],
|
||
sessionId: null,
|
||
implementSessionId: null,
|
||
reviewSessionId: null,
|
||
testSessionId: null,
|
||
worktreePath: null,
|
||
worktreeExists: false,
|
||
}
|
||
}
|
||
|
||
function definedEntries(o: Record<string, string | undefined>): Record<string, string> {
|
||
const out: Record<string, string> = {}
|
||
for (const [k, v] of Object.entries(o)) {
|
||
if (v)
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
export function handoffAcceptedStateExtra(local: {
|
||
sessions: HandoffSessions
|
||
profiles: HandoffProfiles
|
||
worktreePath?: string
|
||
}): Record<string, unknown> {
|
||
return {
|
||
handoffAttachmentId: '',
|
||
handoffFrom: '',
|
||
...definedEntries(local.sessions),
|
||
...definedEntries(local.profiles),
|
||
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
|
||
}
|
||
}
|
||
|
||
export function handoffAcceptedUiPatch(local: {
|
||
sessions: HandoffSessions
|
||
profiles: HandoffProfiles
|
||
worktreePath?: string
|
||
worktreeExists: boolean
|
||
}): Record<string, unknown> {
|
||
return {
|
||
handoffAttachmentId: null,
|
||
handoffFrom: null,
|
||
...definedEntries(local.sessions),
|
||
...definedEntries(local.profiles),
|
||
...(local.worktreePath ? { worktreePath: local.worktreePath } : {}),
|
||
worktreeExists: local.worktreeExists,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试 + 提交**
|
||
|
||
Run: `pnpm test src/panel/handlers/handoff.test.ts && pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/panel/handlers/handoff.ts src/panel/handlers/handoff.test.ts
|
||
git commit -m "✨ feat(vscode): handoff 纯决策与 state/UI payload"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 消息类型 + `handoffFlow.ts` + KanbanPanel 分发
|
||
|
||
**Files:**
|
||
- Modify: `src/panel/messages.ts`、`webview-ui/src/lib/messages.ts`
|
||
- Create: `src/panel/handlers/handoffFlow.ts`
|
||
- Modify: `src/panel/KanbanPanel.ts:335+`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 3 `ensureWorktreeFromRemote`;Task 2 `resolveWorktreeDir` / `slugFromBranch` / `DEFAULT_WORKTREE_TEMPLATE`;Task 6 `stageHandoffBundle` / `installHandoffBundle` / `packHandoff` / `unpackHandoff`;Task 5 `handoffAttachmentName` / `parseHandoffManifest` / `HANDOFF_MANIFEST_FILE`;Task 4 `claudeProjectsRoot`;Task 7 API;Task 8 决策。
|
||
- Produces:
|
||
- 消息:`WebviewToExtension` 加 `{ type: 'handoff/users', issueNumber: number }`、`{ type: 'handoff/start', issueNumber: number, to: string }`、`{ type: 'handoff/accept', issueNumber: number }`;`ExtensionToWebview` 加 `{ type: 'handoff/users-result', issueNumber: number, users: string[] }`、`{ type: 'handoff/done', issueNumber: number }`。
|
||
- `export async function handleHandoffUsers(panel, issueNumber): Promise<void>`
|
||
- `export async function handleHandoffStart(panel, issueNumber, to): Promise<void>`
|
||
- `export async function handleHandoffAccept(panel, issueNumber): Promise<void>`
|
||
|
||
- [ ] **Step 1: 消息类型(两份镜像)**
|
||
|
||
`src/panel/messages.ts` `ExtensionToWebview` 在 `issue/pr-diff-summary-done` 之后加:
|
||
|
||
```ts
|
||
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||
| { type: 'handoff/done', issueNumber: number }
|
||
```
|
||
|
||
`WebviewToExtension` 在 `column/change` 之后加:
|
||
|
||
```ts
|
||
| { type: 'handoff/users', issueNumber: number }
|
||
| { type: 'handoff/start', issueNumber: number, to: string }
|
||
| { type: 'handoff/accept', issueNumber: number }
|
||
```
|
||
|
||
`webview-ui/src/lib/messages.ts` 同样加。
|
||
|
||
- [ ] **Step 2: 写 `handoffFlow.ts`**
|
||
|
||
`src/panel/handlers/handoffFlow.ts`:
|
||
|
||
```ts
|
||
import type { HandoffManifest, HandoffProfiles, HandoffSessions } from '../../cc/handoffManifest'
|
||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||
import { execFile } from 'node:child_process'
|
||
import * as fs from 'node:fs'
|
||
import { promises as fsp } from 'node:fs'
|
||
import * as os from 'node:os'
|
||
import * as path from 'node:path'
|
||
import { window, workspace } from 'vscode'
|
||
import { loginForToken } from '../../auth/identity'
|
||
import { getToken } from '../../auth/secrets'
|
||
import { packHandoff, unpackHandoff } from '../../cc/handoffArchive'
|
||
import { installHandoffBundle, stageHandoffBundle } from '../../cc/handoffBundle'
|
||
import { HANDOFF_MANIFEST_FILE, handoffAttachmentName, 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,
|
||
getIssueAttachment,
|
||
listIssueAttachments,
|
||
listRepoAssignees,
|
||
updateIssueAssignees,
|
||
uploadIssueAttachment,
|
||
} from '../../gitea/api'
|
||
import { logger } from '../../logging/logger'
|
||
import { defaultCodexSessionsDir } from '../../sessions/codexSessions'
|
||
import { getSettings } from '../../settings/store'
|
||
import { makeNonce } from '../KanbanPanel'
|
||
import { cleanupFeatureBranch } from './branchCleanup'
|
||
import {
|
||
checkWorktreePushed,
|
||
handoffAcceptedStateExtra,
|
||
handoffAcceptedUiPatch,
|
||
handoffStartedStateExtra,
|
||
handoffStartedUiPatch,
|
||
} from './handoff'
|
||
|
||
interface RepoCtx {
|
||
workspaceRoot: string
|
||
host: string
|
||
owner: string
|
||
repo: string
|
||
token: string
|
||
me: string
|
||
}
|
||
|
||
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string, extra?: { id?: string, spinner?: boolean, dismissOnTimer?: number }): string {
|
||
const id = extra?.id ?? makeNonce()
|
||
panel.postMessage({ type: 'toast/show', id, level, message, spinner: extra?.spinner, dismissOnTimer: extra?.dismissOnTimer ?? 6000 })
|
||
return id
|
||
}
|
||
|
||
function dismiss(panel: KanbanWebviewPanel, id: string): void {
|
||
panel.postMessage({ type: 'toast/dismiss', id })
|
||
}
|
||
|
||
async function resolveRepoCtx(panel: KanbanWebviewPanel): Promise<RepoCtx | undefined> {
|
||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
if (!workspaceRoot) {
|
||
toast(panel, 'error', '请先打开一个工作区文件夹')
|
||
return undefined
|
||
}
|
||
const remote = await detectRepo(workspaceRoot)
|
||
if (!remote) {
|
||
toast(panel, 'error', '当前工作区没有 Gitea 远程仓库')
|
||
return undefined
|
||
}
|
||
const token = await getToken(panel.context, remote.host)
|
||
if (!token) {
|
||
toast(panel, 'error', '请先完成 Gitea 配置')
|
||
return undefined
|
||
}
|
||
const me = await loginForToken(remote.host, token)
|
||
if (!me) {
|
||
toast(panel, 'error', '无法解析当前 Gitea 身份,token 是否失效?')
|
||
return undefined
|
||
}
|
||
return { workspaceRoot, host: remote.host, owner: remote.owner, repo: remote.repo, token, me }
|
||
}
|
||
|
||
function git(cwd: string, args: string[]): Promise<{ ok: boolean, stdout: string, stderr: string }> {
|
||
return new Promise((resolve) => {
|
||
execFile('git', ['-C', cwd, ...args], { timeout: 30_000, encoding: 'utf8' }, (err, stdout, stderr) => {
|
||
resolve({ ok: !err, stdout: (stdout ?? '').toString(), stderr: (stderr ?? '').toString() })
|
||
})
|
||
})
|
||
}
|
||
|
||
function str(v: unknown): string | undefined {
|
||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||
}
|
||
|
||
function scratchDir(prefix: string): Promise<string> {
|
||
return fsp.mkdtemp(path.join(os.tmpdir(), prefix))
|
||
}
|
||
|
||
export async function handleHandoffUsers(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||
const ctx = await resolveRepoCtx(panel)
|
||
if (!ctx)
|
||
return
|
||
try {
|
||
const users = await listRepoAssignees(ctx)
|
||
panel.postMessage({
|
||
type: 'handoff/users-result',
|
||
issueNumber,
|
||
users: users.map(u => u.login).filter(l => l !== ctx.me).sort(),
|
||
})
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
toast(panel, 'error', `读取可指派用户失败:${message}`)
|
||
panel.postMessage({ type: 'handoff/users-result', issueNumber, users: [] })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送方:校验 worktree 已 push → 关终端 → 打包上传 → 写共享字段 → 重新指派 →
|
||
* 本机清理。远端三步(上传 / 写状态 / 指派)任一失败都回滚前面的远端改动;
|
||
* 本机清理失败只告警,移交在远端已经生效。
|
||
*/
|
||
export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber: number, to: string): Promise<void> {
|
||
const ctx = await resolveRepoCtx(panel)
|
||
if (!ctx)
|
||
return
|
||
const { workspaceRoot, host, owner, repo, token, me } = ctx
|
||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||
|
||
let state: Record<string, unknown>
|
||
try {
|
||
state = await panel.readIssueState(issueNumber)
|
||
}
|
||
catch (err) {
|
||
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
|
||
finish()
|
||
return
|
||
}
|
||
const branch = str(state.branch)
|
||
const worktreePath = str(state.worktreePath)
|
||
const sessions: HandoffSessions = {
|
||
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
|
||
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
|
||
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
|
||
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
|
||
}
|
||
const profiles: HandoffProfiles = {
|
||
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
|
||
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
|
||
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
|
||
}
|
||
|
||
// ① worktree 守卫:干净 + 与远端一致
|
||
const worktreeAbs = worktreePath ? resolveWorktreePath(worktreePath, workspaceRoot) : undefined
|
||
if (worktreeAbs && fs.existsSync(worktreeAbs) && branch) {
|
||
const status = await git(worktreeAbs, ['status', '--porcelain'])
|
||
const fetched = await git(worktreeAbs, ['fetch', 'origin', branch])
|
||
if (!status.ok || !fetched.ok) {
|
||
toast(panel, 'error', `检查 worktree 失败:${(status.stderr || fetched.stderr).trim()}`)
|
||
finish()
|
||
return
|
||
}
|
||
const localHead = await git(worktreeAbs, ['rev-parse', 'HEAD'])
|
||
const remoteHead = await git(worktreeAbs, ['rev-parse', `origin/${branch}`])
|
||
const check = checkWorktreePushed({ statusPorcelain: status.stdout, localHead: localHead.stdout, remoteHead: remoteHead.stdout })
|
||
if (!check.ok) {
|
||
toast(panel, 'error', `无法移交 #${issueNumber}:${check.reason}`)
|
||
finish()
|
||
return
|
||
}
|
||
}
|
||
|
||
const spinner = toast(panel, 'info', `正在移交 #${issueNumber} 给 ${to}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||
|
||
// ② 关终端,等 jsonl 刷盘
|
||
let disposedAny = false
|
||
for (const [terminal, origin] of panel.terminalOrigin) {
|
||
if (origin.issueNumber === issueNumber) {
|
||
try {
|
||
terminal.dispose()
|
||
disposedAny = true
|
||
}
|
||
catch {}
|
||
}
|
||
}
|
||
for (const t of window.terminals) {
|
||
if (t.name === `issue-${issueNumber}-冲突解决`) {
|
||
try {
|
||
t.dispose()
|
||
disposedAny = true
|
||
}
|
||
catch {}
|
||
}
|
||
}
|
||
if (disposedAny)
|
||
await new Promise<void>(r => setTimeout(r, 600))
|
||
|
||
// ③ 打包 + 上传
|
||
let attachmentId: number
|
||
let manifest: HandoffManifest
|
||
// tgz 必须放在 staging 之外,否则 tar 会把半成品包也打进去。
|
||
const staging = await scratchDir('spx-handoff-')
|
||
const outDir = await scratchDir('spx-handoff-out-')
|
||
try {
|
||
manifest = await stageHandoffBundle(
|
||
{ issueNumber, from: me, branch, sessions, profiles },
|
||
staging,
|
||
{ projectsRoot: claudeProjectsRoot(), codexRoot: defaultCodexSessionsDir() },
|
||
)
|
||
const name = handoffAttachmentName(issueNumber)
|
||
const archive = path.join(outDir, name)
|
||
await packHandoff(staging, archive)
|
||
for (const old of await listIssueAttachments({ host, token, owner, repo, index: issueNumber })) {
|
||
if (old.name === name)
|
||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId: old.id })
|
||
}
|
||
const uploaded = await uploadIssueAttachment({ host, token, owner, repo, index: issueNumber, name, data: await fsp.readFile(archive) })
|
||
attachmentId = uploaded.id
|
||
logger.add({ level: 'info', source: 'panel', message: `移交 #${issueNumber}:会话包已上传 (attachment ${attachmentId}, ${uploaded.size} bytes)`, details: JSON.stringify(manifest) })
|
||
}
|
||
catch (err) {
|
||
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', `移交失败(打包/上传):${message}`)
|
||
finish()
|
||
return
|
||
}
|
||
finally {
|
||
await fsp.rm(staging, { recursive: true, force: true })
|
||
await fsp.rm(outDir, { recursive: true, force: true })
|
||
}
|
||
|
||
// ④ 写共享字段 + 本机墓碑;⑤ 重新指派。失败回滚远端改动。
|
||
try {
|
||
await panel.mergeIssueState(issueNumber, handoffStartedStateExtra(attachmentId, me))
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
await deleteIssueAttachment({ host, token, owner, repo, index: issueNumber, attachmentId }).catch(() => {})
|
||
dismiss(panel, spinner)
|
||
toast(panel, 'error', `移交失败(写状态):${message}`)
|
||
finish()
|
||
return
|
||
}
|
||
try {
|
||
await updateIssueAssignees({ host, token, owner, repo, index: issueNumber, assignees: [to] })
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
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}`)
|
||
finish()
|
||
return
|
||
}
|
||
|
||
// ⑥ 本机清理:worktree + 本地分支(远端分支留给接管方)
|
||
if (worktreeAbs && fs.existsSync(worktreeAbs)) {
|
||
const settings = getSettings(panel.context)
|
||
try {
|
||
await panel.dispatchWorktreeHook('pre-remove', {
|
||
workspaceRoot,
|
||
worktreePath: worktreeAbs,
|
||
branch: branch ?? '',
|
||
issueNumber,
|
||
mainBranch: settings.devBranch || 'main',
|
||
customScriptPath: settings.worktreePreRemoveScript,
|
||
})
|
||
killProcessesUsingWorktree(worktreeAbs)
|
||
await removeWorktreeDir(workspaceRoot, worktreeAbs)
|
||
}
|
||
catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
logger.add({ level: 'warn', source: 'panel', message: `移交 #${issueNumber}:清理 worktree 失败`, details: message })
|
||
toast(panel, 'error', `已移交,但本机 worktree 清理失败:${message}`)
|
||
}
|
||
}
|
||
if (branch) {
|
||
const settings = getSettings(panel.context)
|
||
await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settings.devBranch, autoBuildBranch: settings.autoBuildBranch })
|
||
}
|
||
|
||
dismiss(panel, spinner)
|
||
panel.postMessage({ type: 'issue/patch', issueNumber, patch: handoffStartedUiPatch(attachmentId, me, to) })
|
||
toast(panel, 'success', `#${issueNumber} 已移交给 ${to}(${manifest.claude.length} 个 claude 会话,${manifest.codex.length} 个 codex 会话)`)
|
||
finish()
|
||
}
|
||
|
||
/**
|
||
* 接管方:下载解包 → 从远端重建 worktree → 装会话文件 → 写本机字段 →
|
||
* 清共享字段 + 删附件。前几步失败时附件还在,按钮仍显示,可重试。
|
||
*/
|
||
export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||
const ctx = await resolveRepoCtx(panel)
|
||
if (!ctx)
|
||
return
|
||
const { workspaceRoot, host, owner, repo, token } = ctx
|
||
const finish = (): void => panel.postMessage({ type: 'handoff/done', issueNumber })
|
||
|
||
let state: Record<string, unknown>
|
||
try {
|
||
state = await panel.readIssueState(issueNumber)
|
||
}
|
||
catch (err) {
|
||
toast(panel, 'error', `读取工单 #${issueNumber} 状态失败:${err instanceof Error ? err.message : String(err)}`)
|
||
finish()
|
||
return
|
||
}
|
||
const attachmentId = Number.parseInt(str(state.handoffAttachmentId) ?? '', 10)
|
||
if (!Number.isFinite(attachmentId)) {
|
||
toast(panel, 'error', `#${issueNumber} 没有待接管的会话包`)
|
||
finish()
|
||
return
|
||
}
|
||
|
||
const spinner = toast(panel, 'info', `正在接管 #${issueNumber}…`, { spinner: true, dismissOnTimer: 120_000 })
|
||
const extracted = await scratchDir('spx-takeover-')
|
||
try {
|
||
// ① 下载 + 解包 + 校验清单
|
||
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)
|
||
await unpackHandoff(archive, extracted)
|
||
const manifest = parseHandoffManifest(await fsp.readFile(path.join(extracted, HANDOFF_MANIFEST_FILE), 'utf8'), issueNumber)
|
||
|
||
// ② 重建 worktree
|
||
const branch = manifest.branch ?? str(state.branch)
|
||
let worktreeAbs: string | undefined
|
||
if (branch) {
|
||
const template = getSettings(panel.context).worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE
|
||
const templatePath = resolveWorktreeDir(template, workspaceRoot, slugFromBranch(branch))
|
||
worktreeAbs = await ensureWorktreeFromRemote({ workspaceRoot, worktreePath: templatePath, branch })
|
||
const settings = getSettings(panel.context)
|
||
await panel.dispatchWorktreeHook('post-create', {
|
||
workspaceRoot,
|
||
worktreePath: worktreeAbs,
|
||
branch,
|
||
issueNumber,
|
||
mainBranch: settings.devBranch || 'main',
|
||
customScriptPath: settings.worktreePostCreateScript,
|
||
})
|
||
}
|
||
|
||
// ③ 装会话文件
|
||
await installHandoffBundle(extracted, manifest, {
|
||
projectsRoot: claudeProjectsRoot(),
|
||
codexRoot: defaultCodexSessionsDir(),
|
||
workspaceProjectsDir: projectsDirFor(workspaceRoot),
|
||
...(worktreeAbs ? { worktreeProjectsDir: projectsDirFor(worktreeAbs) } : {}),
|
||
})
|
||
|
||
// ④ 本机字段 + 清共享字段;⑤ 删附件(失败只 warn)
|
||
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 })
|
||
}
|
||
catch (err) {
|
||
logger.add({ level: 'warn', source: 'panel', message: `接管 #${issueNumber}:删除附件失败`, details: err instanceof Error ? err.message : String(err) })
|
||
}
|
||
|
||
dismiss(panel, spinner)
|
||
panel.postMessage({
|
||
type: 'issue/patch',
|
||
issueNumber,
|
||
patch: handoffAcceptedUiPatch({ ...local, worktreeExists: !!worktreeAbs }),
|
||
})
|
||
toast(panel, 'success', `已接管 #${issueNumber}(来自 ${manifest.from})${worktreeAbs ? `,worktree:${worktreeAbs}` : ''}`)
|
||
logger.add({ level: 'info', source: 'panel', message: `接管 #${issueNumber} 完成`, details: JSON.stringify({ manifest, worktreeAbs }) })
|
||
}
|
||
catch (err) {
|
||
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', `接管失败:${message}`)
|
||
}
|
||
finally {
|
||
await fsp.rm(extracted, { recursive: true, force: true })
|
||
finish()
|
||
}
|
||
}
|
||
```
|
||
|
||
`toast/show` 的 `spinner` 为 `undefined` 时 postMessage 会丢键,无碍。
|
||
|
||
- [ ] **Step 3: KanbanPanel 分发**
|
||
|
||
`src/panel/KanbanPanel.ts` `handleMessage` 里 `column/change` 分支之后加:
|
||
|
||
```ts
|
||
if (msg.type === 'handoff/users') {
|
||
void handoffFlow.handleHandoffUsers(this, msg.issueNumber)
|
||
return
|
||
}
|
||
if (msg.type === 'handoff/start') {
|
||
void handoffFlow.handleHandoffStart(this, msg.issueNumber, msg.to)
|
||
return
|
||
}
|
||
if (msg.type === 'handoff/accept') {
|
||
void handoffFlow.handleHandoffAccept(this, msg.issueNumber)
|
||
return
|
||
}
|
||
```
|
||
|
||
顶部 `import * as handoffFlow from './handlers/handoffFlow'`。
|
||
|
||
- [ ] **Step 4: typecheck + lint + 提交**
|
||
|
||
Run: `pnpm typecheck && pnpm lint && pnpm test`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/panel/messages.ts webview-ui/src/lib/messages.ts src/panel/handlers/handoffFlow.ts src/panel/KanbanPanel.ts
|
||
git commit -m "✨ feat(vscode): 移交/接管扩展端流程与消息分发"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: 完成列会话归档改为整包搬迁
|
||
|
||
**Files:**
|
||
- Modify: `src/panel/handlers/issues.ts:383-433`(`// Before removing the worktree, copy the impl- and test-session jsonl …` 那一段)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 4 `installClaudeSession`、`claudeProjectsRoot`。
|
||
|
||
- [ ] **Step 1: 替换实现**
|
||
|
||
把从 `// Before removing the worktree, copy the impl- and test-session jsonl into` 到该 `if` 块结束(含 `catch` 的 warn)整段替换为:
|
||
|
||
```ts
|
||
// 删 worktree 前把实施/测试会话整包(jsonl + 子目录)搬到主仓库 projects 目录,
|
||
// 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。
|
||
if ((implementSessionId || testSessionId) && worktreePath) {
|
||
const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot)
|
||
const srcProjectsDir = projectsDirFor(worktreeAbs)
|
||
const dstProjectsDir = projectsDirFor(workspaceRoot)
|
||
for (const sid of [implementSessionId, testSessionId]) {
|
||
if (typeof sid !== 'string' || sid.length === 0)
|
||
continue
|
||
try {
|
||
await installClaudeSession({ sid, srcDir: srcProjectsDir, dstProjectsDir, projectsRoot: claudeProjectsRoot() })
|
||
logger.add({ level: 'info', source: 'panel', message: `cc 会话 ${sid} 已搬到主仓库 projects 目录 (issue #${issueNumber})` })
|
||
}
|
||
catch (err) {
|
||
logger.add({
|
||
level: 'warn',
|
||
source: 'panel',
|
||
message: `搬迁 cc 会话 ${sid} 失败 (issue #${issueNumber}),worktree 清理仍继续`,
|
||
details: err instanceof Error ? err.message : String(err),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
import:`import { claudeProjectsRoot, installClaudeSession } from '../../cc/sessionBundle'`。若 `fs`/`path` 因此在该文件无其它引用,保留(文件其它地方仍在用)。
|
||
|
||
- [ ] **Step 2: typecheck + lint + 提交**
|
||
|
||
Run: `pnpm typecheck && pnpm lint && pnpm test`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add src/panel/handlers/issues.ts
|
||
git commit -m "🐛 fix(vscode): 完成列归档会话改为整包搬迁并清理重复 sid"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: webview:状态、按钮、选人弹窗、角标
|
||
|
||
**Files:**
|
||
- Create: `webview-ui/src/components/HandoffModal.tsx`
|
||
- Modify: `webview-ui/src/hooks/useIssues.ts`
|
||
- Modify: `webview-ui/src/App.tsx:340-380, 444-462`
|
||
- Modify: `webview-ui/src/components/BottomTabs.tsx:36-60, 120-140`
|
||
- Modify: `webview-ui/src/components/IssueDetailPanel.tsx:20-93, 574-608`
|
||
- Modify: `webview-ui/src/components/IssueCard.tsx`
|
||
- Modify: `webview-ui/src/components/KanbanBoard.tsx` / `KanbanColumn.tsx`(若 `IssueCard` 由它们渲染,无需传新 prop——角标直接读 `issue.handoffAttachmentId`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 1 类型与消息、Task 9 消息。
|
||
- Produces(useIssues 返回对象新增):`me: string | undefined`、`handoffUsers: { issueNumber: number, users: string[] } | null`、`requestHandoffUsers(issueNumber)`、`startHandoff(issueNumber, to)`、`acceptHandoff(issueNumber)`、`isHandoffRunning(issueNumber)`。
|
||
|
||
- [ ] **Step 1: useIssues**
|
||
|
||
在 `useIssues.ts` 状态区加:
|
||
|
||
```ts
|
||
const [me, setMe] = useState<string | undefined>(undefined)
|
||
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
|
||
const handoffRunningRef = useRef<Set<number>>(new Set())
|
||
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
|
||
```
|
||
|
||
回调:
|
||
|
||
```ts
|
||
const requestHandoffUsers = useCallback((issueNumber: number): void => {
|
||
setHandoffUsers(null)
|
||
postMessage({ type: 'handoff/users', issueNumber })
|
||
}, [])
|
||
|
||
const markHandoffRunning = useCallback((issueNumber: number, running: boolean): void => {
|
||
if (running)
|
||
handoffRunningRef.current.add(issueNumber)
|
||
else
|
||
handoffRunningRef.current.delete(issueNumber)
|
||
setHandoffRunning(new Set(handoffRunningRef.current))
|
||
}, [])
|
||
|
||
const startHandoff = useCallback((issueNumber: number, to: string): void => {
|
||
if (handoffRunningRef.current.has(issueNumber))
|
||
return
|
||
markHandoffRunning(issueNumber, true)
|
||
postMessage({ type: 'handoff/start', issueNumber, to })
|
||
}, [markHandoffRunning])
|
||
|
||
const acceptHandoff = useCallback((issueNumber: number): void => {
|
||
if (handoffRunningRef.current.has(issueNumber))
|
||
return
|
||
markHandoffRunning(issueNumber, true)
|
||
postMessage({ type: 'handoff/accept', issueNumber })
|
||
}, [markHandoffRunning])
|
||
|
||
const isHandoffRunning = useCallback((issueNumber: number): boolean => handoffRunning.has(issueNumber), [handoffRunning])
|
||
```
|
||
|
||
`onMessage` switch:`issues/update` 分支加 `setMe(msg.me)`;新增:
|
||
|
||
```ts
|
||
case 'handoff/users-result':
|
||
setHandoffUsers({ issueNumber: msg.issueNumber, users: msg.users })
|
||
break
|
||
case 'handoff/done':
|
||
markHandoffRunning(msg.issueNumber, false)
|
||
break
|
||
```
|
||
|
||
`useEffect` 依赖数组加 `markHandoffRunning`。返回对象加 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。
|
||
|
||
- [ ] **Step 2: HandoffModal**
|
||
|
||
`webview-ui/src/components/HandoffModal.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react'
|
||
import { Loader2, X } from 'lucide-react'
|
||
import { SelectMenu } from './ui/select-menu'
|
||
|
||
interface Props {
|
||
open: boolean
|
||
issueNumber: number | null
|
||
/** null = 候选列表还在加载 */
|
||
users: string[] | null
|
||
onCancel: () => void
|
||
onSubmit: (issueNumber: number, to: string) => void
|
||
}
|
||
|
||
export function HandoffModal({ open, issueNumber, users, onCancel, onSubmit }: Props) {
|
||
const [to, setTo] = useState<string>('')
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setTo('')
|
||
return
|
||
}
|
||
function handleKey(e: KeyboardEvent): void {
|
||
if (e.key === 'Escape')
|
||
onCancel()
|
||
}
|
||
document.addEventListener('keydown', handleKey)
|
||
return () => document.removeEventListener('keydown', handleKey)
|
||
}, [open, onCancel])
|
||
|
||
useEffect(() => {
|
||
if (users && users.length > 0 && !to)
|
||
setTo(users[0])
|
||
}, [users, to])
|
||
|
||
if (!open || issueNumber === null)
|
||
return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
className="w-[360px] rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] p-4 text-sm shadow-lg"
|
||
>
|
||
<div className="mb-3 flex items-center">
|
||
<h2 className="flex-1 font-medium">
|
||
移交 #
|
||
{issueNumber}
|
||
</h2>
|
||
<button type="button" onClick={onCancel} aria-label="关闭" className="rounded p-1 hover:bg-white/10">
|
||
<X className="size-4" />
|
||
</button>
|
||
</div>
|
||
<p className="mb-3 text-xs opacity-70">
|
||
会把本机的 worktree 删除、会话记录打包挂到工单附件,并把工单指派给对方。移交前分支必须已全部 push。
|
||
</p>
|
||
{users === null
|
||
? (
|
||
<div className="flex items-center gap-2 text-xs opacity-70">
|
||
<Loader2 className="size-3.5 animate-spin" />
|
||
读取可指派用户…
|
||
</div>
|
||
)
|
||
: users.length === 0
|
||
? <div className="text-xs text-red-400">没有可指派的其他用户</div>
|
||
: (
|
||
<SelectMenu
|
||
value={to}
|
||
options={users.map(u => ({ value: u, label: u }))}
|
||
onChange={setTo}
|
||
/>
|
||
)}
|
||
<div className="mt-4 flex justify-end gap-2">
|
||
<button type="button" onClick={onCancel} className="rounded border border-[var(--vscode-panel-border)] px-3 py-1 text-xs hover:bg-white/10">
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!to}
|
||
onClick={() => onSubmit(issueNumber, to)}
|
||
className="rounded bg-[var(--vscode-button-background)] px-3 py-1 text-xs text-[var(--vscode-button-foreground)] disabled:opacity-50"
|
||
>
|
||
移交
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
`SelectMenu` 的 props 为 `{ value: string, options: SelectMenuOption[], onChange: (value: string) => void }`(`webview-ui/src/components/ui/select-menu.tsx:13-16`);`SelectMenuOption` 的第二个字段名以该文件 `:7-11` 为准(`label` 或 `name`),按实际写。
|
||
|
||
- [ ] **Step 3: IssueDetailPanel 按钮**
|
||
|
||
Props 加:
|
||
|
||
```ts
|
||
/** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */
|
||
me?: string
|
||
onStartHandoff: (issueNumber: number) => void
|
||
onAcceptHandoff: (issueNumber: number) => void
|
||
isHandoffRunning: (issueNumber: number) => boolean
|
||
```
|
||
|
||
解构处加三个回调与 `me`。头部 `issue.source !== 'youtrack'` 的 fragment 内、「重置为待办」按钮之前加:
|
||
|
||
```tsx
|
||
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||
<button
|
||
type="button"
|
||
disabled={isHandoffRunning(issue.number)}
|
||
onClick={() => onAcceptHandoff(issue.number)}
|
||
title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
|
||
aria-label="接管"
|
||
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||
>
|
||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||
接管
|
||
</button>
|
||
)}
|
||
{!issue.handoffAttachmentId && issue.column !== 'done' && (
|
||
<button
|
||
type="button"
|
||
disabled={isHandoffRunning(issue.number)}
|
||
onClick={() => onStartHandoff(issue.number)}
|
||
title="移交给同事"
|
||
aria-label="移交"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
||
>
|
||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
||
</button>
|
||
)}
|
||
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
||
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
|
||
待接管
|
||
</span>
|
||
)}
|
||
```
|
||
|
||
从 `lucide-react` 追加 import `ArrowRightLeft, Download, Loader2`(已 import 的不重复)。
|
||
|
||
- [ ] **Step 4: BottomTabs 透传**
|
||
|
||
Props 接口加 `me?: string`、`onStartHandoff`、`onAcceptHandoff`、`isHandoffRunning`(类型同上);渲染 `IssueDetailPanel` 处透传四个。
|
||
|
||
- [ ] **Step 5: App.tsx 接线 + 挂 modal**
|
||
|
||
从 `useIssues()` 解构出 `me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning`。加状态 `const [handoffIssue, setHandoffIssue] = useState<number | null>(null)`。
|
||
|
||
`BottomTabs` 加:
|
||
|
||
```tsx
|
||
me={me}
|
||
onStartHandoff={(n) => { setHandoffIssue(n); requestHandoffUsers(n) }}
|
||
onAcceptHandoff={acceptHandoff}
|
||
isHandoffRunning={isHandoffRunning}
|
||
```
|
||
|
||
在 `<NewIssueModal …/>` 旁挂:
|
||
|
||
```tsx
|
||
<HandoffModal
|
||
open={handoffIssue !== null}
|
||
issueNumber={handoffIssue}
|
||
users={handoffUsers && handoffUsers.issueNumber === handoffIssue ? handoffUsers.users : null}
|
||
onCancel={() => setHandoffIssue(null)}
|
||
onSubmit={(n, to) => { startHandoff(n, to); setHandoffIssue(null) }}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 6: IssueCard 角标**
|
||
|
||
`IssueCard.tsx` 在 `locked` 图标之后(同级)加:
|
||
|
||
```tsx
|
||
{issue.handoffAttachmentId
|
||
? (
|
||
<span
|
||
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
|
||
title={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
|
||
>
|
||
待接管
|
||
</span>
|
||
)
|
||
: null}
|
||
```
|
||
|
||
- [ ] **Step 7: typecheck + lint + 提交**
|
||
|
||
Run: `pnpm typecheck && pnpm lint`
|
||
Expected: PASS
|
||
|
||
```bash
|
||
git add webview-ui/src
|
||
git commit -m "✨ feat(vscode): webview 移交/接管按钮、选人弹窗与待接管角标"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: README、版本、打包、安装、spx 重装
|
||
|
||
**Files:**
|
||
- Modify: `README.md`(工作流章节、state JSON 字段表、面板「工单 tab」段)
|
||
- Modify: `package.json:5`(`0.2.95` → `0.2.96`)
|
||
|
||
- [ ] **Step 1: README**
|
||
|
||
「工作流」列表第 6 条「回退」后加第 7 条:
|
||
|
||
```
|
||
7. **移交**:工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。
|
||
```
|
||
|
||
state JSON 字段表加两行:
|
||
|
||
```
|
||
| `handoffAttachmentId` / `handoffFrom` | 移交中:会话包附件 id 与发起人;接管后清空 |
|
||
```
|
||
|
||
「工单 tab」段落的顶部按钮列表加「移交 / 接管」。
|
||
|
||
「环境要求」表 `git` 行说明末尾加「、tar(移交打包)」。
|
||
|
||
- [ ] **Step 2: 版本号与打包**
|
||
|
||
`package.json` 的 `"version": "0.2.95"` → `"0.2.96"`。
|
||
|
||
Run: `pnpm test && pnpm lint && pnpm typecheck && pnpm ext:package`
|
||
Expected: 生成 `superpowers-vscode-clurdra-0.2.96.vsix`。
|
||
|
||
- [ ] **Step 3: 安装本机 + 重装 spx**
|
||
|
||
Run:
|
||
```bash
|
||
code --install-extension superpowers-vscode-clurdra-0.2.96.vsix --force --profile augment
|
||
cd cli && make install && spx --help | head -3
|
||
```
|
||
Expected: 扩展安装成功;`~/.local/bin/spx` 为带新 schema 的版本。
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add README.md package.json
|
||
git commit -m "🔖 chore(vscode): bump 0.2.96,README 补移交/接管"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: 端到端验证(手动)
|
||
|
||
**Files:** 无代码改动;问题回到对应 Task 修。
|
||
|
||
- [ ] **Step 1: 双窗口准备**
|
||
|
||
同一台机器开两个 VS Code 窗口指向同一仓库的两个 clone(或用 `dse` / `m1max` 其中一台作为接管方),分别配置两个不同 Gitea 账号的 token(`git config user.email` 与 Gitea 邮箱一致)。两边都装 0.2.96 与新 spx。
|
||
|
||
- [ ] **Step 2: 移交**
|
||
|
||
在窗口 A:挑一个进行中的工单(有 worktree、实施会话),确保 `git status` 干净且已 push。工单 tab 点「移交」→ 选 B 账号 → 确认。
|
||
Expected:spinner toast → 成功 toast;A 的工单 tab 会话 id / 工作树行清空;Gitea 工单附件里出现 `spx-handoff-issue-N.tgz`;state 评论含 `handoffAttachmentId` / `handoffFrom`;assignee 变为 B;A 本机 worktree 目录消失、`git branch` 无该 feature 分支、`git ls-remote origin <branch>` 仍在。
|
||
|
||
- [ ] **Step 3: 接管**
|
||
|
||
在窗口 B:看板卡片出现「待接管」角标(webhook 指派事件触发刷新;否则点刷新)。工单 tab 点「接管」。
|
||
Expected:成功 toast 含 worktree 路径;工作树行可双击打开;会话 id 行双击能 `claude --resume` 起同事的实施会话且历史完整;`ls ~/.claude/projects/*/<sid>.jsonl` 只有一份;Gitea 附件已删除;state 评论两个 handoff 字段为空。
|
||
|
||
- [ ] **Step 4: 后续流程**
|
||
|
||
在窗口 B 把工单拖到「完成」:PR 合并(或制造冲突验证 `issue-N-冲突解决` 会话能在重建的 worktree 里启动)。
|
||
Expected:与本机实施的工单行为一致。
|
||
|
||
- [ ] **Step 5: 失败路径**
|
||
|
||
在 A 上对一个有未提交改动的 worktree 点「移交」。
|
||
Expected:toast「工作区有未提交改动…」,什么都没改(附件、assignee、本机状态均不变)。
|