From 6251ba0d2a37a2dcd9dd648797de1e4ce5918259 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 19 Aug 2026 19:24:57 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=93=BE=E6=8E=A5=E6=8C=89=E6=9C=AC=E6=9C=BA?= =?UTF-8?q?=20codex=20=E4=BC=9A=E8=AF=9D=E6=96=87=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E5=88=A4=E5=AE=9A,=E4=B8=8D=E5=86=8D=E7=BB=91=20workt?= =?UTF-8?q?ree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/package.json | 2 +- vscode/src/cc/codexSessionWatcher.ts | 2 +- vscode/src/gitea/types.ts | 4 + vscode/src/panel/KanbanPanel.ts | 8 +- vscode/src/panel/handlers/sessions.ts | 39 ++++++--- vscode/src/panel/messages.ts | 2 +- vscode/src/sessions/codexSessions.test.ts | 85 +++++++++++++++++++ vscode/src/sessions/codexSessions.ts | 55 ++++++++++++ vscode/src/webhook/coordinator.ts | 3 +- .../src/components/IssueDetailPanel.tsx | 6 +- vscode/webview-ui/src/lib/messages.ts | 2 +- vscode/webview-ui/src/types.ts | 3 + 12 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 vscode/src/sessions/codexSessions.test.ts create mode 100644 vscode/src/sessions/codexSessions.ts diff --git a/vscode/package.json b/vscode/package.json index b73ad40..37aaa60 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.81", + "version": "0.2.82", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/cc/codexSessionWatcher.ts b/vscode/src/cc/codexSessionWatcher.ts index ebe2032..45c6a17 100644 --- a/vscode/src/cc/codexSessionWatcher.ts +++ b/vscode/src/cc/codexSessionWatcher.ts @@ -24,7 +24,7 @@ import { promises as fsp } from 'node:fs' import * as path from 'node:path' /** UUID at the tail of `rollout--.jsonl`. v7 UUIDs are still 8-4-4-4-12. */ -const ROLLOUT_UUID_REGEX = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i +export const ROLLOUT_UUID_REGEX = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i /** * Try to extract a thread_id (UUID) from a rollout filename. diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index f377ba5..70328f6 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -68,6 +68,10 @@ export interface Issue { * the first time auto-review runs; reused for subsequent `synchronize` * webhook callbacks via `codex exec resume`. */ reviewSessionId?: string + /** Whether the codex session file (`~/.codex/sessions/YYYY/MM/DD/rollout-...-.jsonl`) + * for `reviewSessionId` exists on this machine; computed on load, never + * persisted. codex resume 只依赖这个文件,与 worktree 无关。 */ + reviewSessionFileExists?: boolean /** Session id of the test conversation (a Claude Code session id). Set the * first time the user manually starts a test session after the issue's PR * is merged; reused for resume from the detail panel. */ diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 70cf9a3..0bbbd63 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -21,6 +21,7 @@ import { loadIssues } from '../gitea/issueLoader' import { overlayLocalIssueState } from '../issues/localState' import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter' import { logger } from '../logging/logger' +import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { getEffectiveCommitProfilePath, getSettings } from '../settings/store' import { webhookCoordinator } from '../webhook/coordinator' import { loadYouTrackIssues } from '../youtrack/issueLoader' @@ -899,9 +900,12 @@ export class KanbanWebviewPanel { dismissOnTimer: 6000, }) } - // 本机记录(会话 id / worktree 等)叠加在共享状态之上,再补 live terminal 态。 + // 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex + // 会话文件存在性,再补 live terminal 态。 const issues = this.withLiveTerminalTabState( - overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot), + await annotateReviewSessionFileExists( + overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot), + ), ) this.issueRefs = new Map( issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const), diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index cf3dc29..a8caab4 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -238,8 +238,7 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi try { // Server-side prerequisite gate — consistent with handleResumeSession / // handleImplement. In practice review sessions imply the issue has - // moved past todo (a worktree must exist), but enforcing here keeps - // the contract uniform. + // moved past todo, but enforcing here keeps the contract uniform. const lockCheck = await panel.resolveLockedReason(issueNumber) if (lockCheck.locked) { logger.add({ @@ -257,15 +256,31 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi existing.show(false) return } + // codex resume 只依赖 ~/.codex/sessions 下的 rollout 文件,与 worktree + // 无关:worktree 在则用它当 cwd,否则退回工作区根目录(与 + // handleResumeSession 同款回退)。 const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!relCwd || !workspaceRoot) { - void window.showErrorMessage(`审查会话无法恢复 #${issueNumber}:worktree 路径未记录`) + if (!workspaceRoot) { + void window.showErrorMessage(`审查会话无法恢复 #${issueNumber}:未打开工作区`) return } - const worktreeAbs = resolveWorktreePath(relCwd, workspaceRoot) - if (!fs.existsSync(worktreeAbs)) { - void window.showErrorMessage(`审查会话无法恢复 #${issueNumber}:worktree 不存在 ${worktreeAbs}`) - return + let effectiveCwd = workspaceRoot + let cwdFallback = true + if (relCwd) { + const worktreeAbs = resolveWorktreePath(relCwd, workspaceRoot) + if (fs.existsSync(worktreeAbs)) { + effectiveCwd = worktreeAbs + cwdFallback = false + } + } + if (cwdFallback) { + panel.postMessage({ + type: 'toast/show', + id: makeNonce(), + level: 'info', + message: `工单 #${issueNumber} 的 worktree 不可用,审查会话将在工作区根目录恢复`, + dismissOnTimer: 5000, + }) } // Scan live terminals before creating a new one — survives panel // reload / webview rebuild where `this.reviewTerminals` got wiped but @@ -284,7 +299,7 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber) const terminal = window.createTerminal({ name: reviewTerminalName, - cwd: worktreeAbs, + cwd: effectiveCwd, location: panel.resolveTerminalLocation(false), iconPath: iconUri, color: themeColor, @@ -302,7 +317,7 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi logger.add({ level: 'info', source: 'terminal', - message: `已创建审查会话终端 #${issueNumber} cwd=${worktreeAbs}`, + message: `已创建审查会话终端 #${issueNumber} cwd=${effectiveCwd}`, }) } finally { @@ -590,10 +605,12 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { if (!token) return await panel.mergeIssueState(opts.issueNumber, { reviewSessionId: threadId }) + // reviewSessionFileExists 是计算字段不持久化;rollout 文件刚在本机 + // 落盘,patch 里直接置 true 让 resume 链接立即可点。 panel.postMessage({ type: 'issue/patch', issueNumber: opts.issueNumber, - patch: { reviewSessionId: threadId }, + patch: { reviewSessionId: threadId, reviewSessionFileExists: true }, }) } catch (err) { diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index b574dcc..87ba8ce 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -42,7 +42,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } diff --git a/vscode/src/sessions/codexSessions.test.ts b/vscode/src/sessions/codexSessions.test.ts new file mode 100644 index 0000000..31b3a05 --- /dev/null +++ b/vscode/src/sessions/codexSessions.test.ts @@ -0,0 +1,85 @@ +import type { Issue } from '../gitea/types' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { annotateReviewSessionFileExists, listCodexSessionIds } from './codexSessions' + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +const UUID_A = '019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a' +const UUID_B = '11111111-2222-4333-8444-555555555555' + +function writeRollout(baseDir: string, ymd: [string, string, string], uuid: string): void { + const dayDir = path.join(baseDir, ...ymd) + mkdirSync(dayDir, { recursive: true }) + writeFileSync(path.join(dayDir, `rollout-2026-04-27T02-57-26-${uuid}.jsonl`), '{}\n') +} + +describe('listCodexSessionIds', () => { + it('扫年/月/日三层目录,从文件名尾部提取全部会话 id', async () => { + const base = tempDir('codex-sessions-') + writeRollout(base, ['2026', '04', '27'], UUID_A) + writeRollout(base, ['2026', '05', '01'], UUID_B) + const ids = await listCodexSessionIds(base) + expect(ids).toEqual(new Set([UUID_A, UUID_B])) + }) + + it('忽略不符合 rollout-*-.jsonl 的文件', async () => { + const base = tempDir('codex-sessions-') + const dayDir = path.join(base, '2026', '04', '27') + mkdirSync(dayDir, { recursive: true }) + writeFileSync(path.join(dayDir, 'notes.txt'), 'x') + writeFileSync(path.join(dayDir, 'rollout-2026-04-27T02-57-26-not-a-uuid.jsonl'), '{}\n') + writeRollout(base, ['2026', '04', '27'], UUID_A) + const ids = await listCodexSessionIds(base) + expect(ids).toEqual(new Set([UUID_A])) + }) + + it('目录不存在返回空 Set', async () => { + const ids = await listCodexSessionIds(path.join(tempDir('codex-sessions-'), 'nope')) + expect(ids.size).toBe(0) + }) +}) + +describe('annotateReviewSessionFileExists', () => { + const baseIssue = { number: 1, title: 't', column: 'review', htmlUrl: '' } as unknown as Issue + + it('有会话文件的 issue 标 true,没有的标 false,无 reviewSessionId 的不加字段', async () => { + const base = tempDir('codex-sessions-') + writeRollout(base, ['2026', '04', '27'], UUID_A) + const issues: Issue[] = [ + { ...baseIssue, number: 1, reviewSessionId: UUID_A }, + { ...baseIssue, number: 2, reviewSessionId: UUID_B }, + { ...baseIssue, number: 3 }, + ] + const out = await annotateReviewSessionFileExists(issues, base) + expect(out[0].reviewSessionFileExists).toBe(true) + expect(out[1].reviewSessionFileExists).toBe(false) + expect('reviewSessionFileExists' in out[2]).toBe(false) + }) + + it('id 大小写不影响判定', async () => { + const base = tempDir('codex-sessions-') + writeRollout(base, ['2026', '04', '27'], UUID_A) + const out = await annotateReviewSessionFileExists( + [{ ...baseIssue, reviewSessionId: UUID_A.toUpperCase() }], + base, + ) + expect(out[0].reviewSessionFileExists).toBe(true) + }) +}) diff --git a/vscode/src/sessions/codexSessions.ts b/vscode/src/sessions/codexSessions.ts new file mode 100644 index 0000000..0f3da63 --- /dev/null +++ b/vscode/src/sessions/codexSessions.ts @@ -0,0 +1,55 @@ +/** + * 枚举本机 codex 会话文件,判定「审查会话id」能否 resume。 + * + * codex 把每个会话的 transcript 全局存在 + * `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`,resume 只依赖 + * 这个文件,与 worktree 无关——多人协作下 assignee 换机后,只要把会话文件 + * 拷到目标机器就能 resume。 + */ + +import type { Issue } from '../gitea/types' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { ROLLOUT_UUID_REGEX } from '../cc/codexSessionWatcher' + +/** codex 会话根目录(全局,不随工作区变化)。 */ +export function defaultCodexSessionsDir(): string { + return path.join(os.homedir(), '.codex', 'sessions') +} + +/** + * 扫一遍 sessions 根目录(年/月/日三层),从文件名尾部提取所有会话 id + * 收进 Set(统一小写)。目录不存在或读取失败返回空 Set——调用方按 + * 「本机没有会话文件」处理即可。 + */ +export async function listCodexSessionIds(baseDir: string = defaultCodexSessionsDir()): Promise> { + const ids = new Set() + let entries: string[] + try { + entries = await fsp.readdir(baseDir, { recursive: true }) + } + catch { + return ids + } + for (const entry of entries) { + const m = path.basename(entry).match(ROLLOUT_UUID_REGEX) + if (m) + ids.add(m[1].toLowerCase()) + } + return ids +} + +/** + * 给带 reviewSessionId 的工单附加计算字段 `reviewSessionFileExists` + * (不落任何持久化)。整批工单只扫一次 sessions 目录;没有任何工单带 + * id 时连扫都不扫。 + */ +export async function annotateReviewSessionFileExists(issues: Issue[], baseDir?: string): Promise { + if (!issues.some(i => typeof i.reviewSessionId === 'string' && i.reviewSessionId.length > 0)) + return issues + const ids = await listCodexSessionIds(baseDir) + return issues.map(i => (i.reviewSessionId + ? { ...i, reviewSessionFileExists: ids.has(i.reviewSessionId.toLowerCase()) } + : i)) +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 52b42b0..d2de008 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -24,6 +24,7 @@ import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComme import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { logger } from '../logging/logger' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' +import { annotateReviewSessionFileExists } from '../sessions/codexSessions' import { getSettings } from '../settings/store' import { decideEventOwnership } from './eventOwnership' import { WebhookServer } from './server' @@ -608,7 +609,7 @@ class WebhookCoordinator { workspaceRoot: ws, issueNumber: event.issueNumber, }) - const issue = loaded ? overlayLocalIssueState(this.ctx, [loaded], ws)[0] : null + const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null if (issue) { this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) if (pending) { diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 289bb3c..906433f 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -254,12 +254,12 @@ export function IssueDetailPanel({ onCloseSessionTab(issue.number, 'implement') }, }, - issue?.worktreeExists + issue?.reviewSessionFileExists ? { key: 'reviewSessionId', label: '审查会话id', type: 'action', - description: '双击在新终端运行 codex resume 查看审查会话(cwd 优先用 worktree)', + description: '双击在新终端运行 codex resume 查看审查会话(worktree 在则用之,否则工作区根目录)', actionIcon: , onAction: (v) => { if (typeof v !== 'string' || v.length === 0 || !issue) @@ -282,7 +282,7 @@ export function IssueDetailPanel({ label: '审查会话id', type: 'string', readOnly: true, - description: 'worktree 已清理,无法 resume;仅保留 id 文本', + description: '本机没有该 codex 会话文件,无法 resume;仅保留 id 文本', }, (() => { // 测试会话 id 行有三种状态(照搬头脑风暴行的三态): diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 052e505..518a53b 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -57,7 +57,7 @@ export type ExtensionToWebview = | { type: 'issues/loading' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean } | { type: 'issues/error', message: string } - | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } + | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/append', issue: Issue, select?: boolean } | { type: 'issue/select-by-number', issueNumber: number } diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 6bf9fd1..087f352 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -51,6 +51,9 @@ export interface Issue { implementSessionId?: string /** Backend-agnostic review session id (v1 stores a codex thread id). */ reviewSessionId?: string + /** 本机是否存在 reviewSessionId 对应的 codex 会话文件;扩展端加载时计算, + * 不持久化。codex resume 只依赖这个文件,与 worktree 无关。 */ + reviewSessionFileExists?: boolean /** Claude Code session id of the test conversation; manually started after * the PR is merged, resumable from the detail panel. */ testSessionId?: string