From 426f3706adaf8d9b2189d615a7fe8b903cc306a8 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 11:02:27 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor(ipc):=20IPC=20=E6=8B=86=E8=96=84?= =?UTF-8?q?=E4=B8=BA=20Service=20=E5=B1=82=20+=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=88=90=20@meebox/ipc=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ipc.ts 从 2449 行收薄至约 50 行,仅做上下文装配与各域 handler 注册; 业务实现下沉到 apps/desktop/src/main/services/: - common/ 公共工具:broadcast / pr-lookup(收口 adapter 查找)/ mirror / comments-cache / usage;context.ts 聚合依赖与公共工具 - 跨域 service:run-queue(pr-agent run 队列)、agent-orchestrator(编排 + AutoPilot) - 四域 handler:app / pr / config / agent,各自维护领域私有工具 IPC 契约从 packages/shared/src/ipc.ts 拆分迁入新内部包 @meebox/ipc,按业务领域 分文件(app/pr/config/agent/events/common);renderer/preload/main 三侧改从 @meebox/ipc 导入。SyncProgressEvent 因被 repo-mirror 消费,归位到 @meebox/shared。 纯结构性重构,不改运行行为;lint/typecheck/test/build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/electron.vite.config.ts | 1 + apps/desktop/package.json | 1 + apps/desktop/src/main/ipc.ts | 2457 +---------------- .../src/main/services/agent-orchestrator.ts | 434 +++ apps/desktop/src/main/services/agent/index.ts | 122 + apps/desktop/src/main/services/app/index.ts | 234 ++ .../src/main/services/common/broadcast.ts | 13 + .../main/services/common/comments-cache.ts | 20 + .../src/main/services/common/mirror.ts | 78 + .../src/main/services/common/pr-lookup.ts | 53 + .../desktop/src/main/services/common/usage.ts | 74 + .../desktop/src/main/services/config/index.ts | 189 ++ apps/desktop/src/main/services/context.ts | 65 + apps/desktop/src/main/services/pr/index.ts | 623 +++++ apps/desktop/src/main/services/run-queue.ts | 745 +++++ apps/desktop/src/preload/index.ts | 2 +- apps/desktop/src/renderer/src/App.tsx | 2 +- apps/desktop/src/renderer/src/api.ts | 2 +- .../src/renderer/src/components/ChatPane.tsx | 2 +- .../src/components/DiffSearchPanel.tsx | 2 +- .../src/renderer/src/components/DiffView.tsx | 4 +- .../src/renderer/src/components/FileTree.tsx | 2 +- .../src/renderer/src/components/StatusBar.tsx | 3 +- .../src/renderer/src/stores/chat-run-store.ts | 2 +- apps/desktop/typings/env.d.ts | 2 +- package-lock.json | 12 + packages/ipc/package.json | 25 + packages/ipc/src/agent.ts | 68 + packages/ipc/src/app.ts | 58 + packages/ipc/src/common.ts | 66 + packages/ipc/src/config.ts | 57 + packages/ipc/src/events.ts | 64 + packages/ipc/src/index.ts | 32 + packages/ipc/src/pr.ts | 263 ++ packages/ipc/tsconfig.json | 8 + packages/shared/src/index.ts | 2 +- packages/shared/src/ipc.ts | 589 ---- packages/shared/src/sync-progress.ts | 16 + 38 files changed, 3362 insertions(+), 3030 deletions(-) create mode 100644 apps/desktop/src/main/services/agent-orchestrator.ts create mode 100644 apps/desktop/src/main/services/agent/index.ts create mode 100644 apps/desktop/src/main/services/app/index.ts create mode 100644 apps/desktop/src/main/services/common/broadcast.ts create mode 100644 apps/desktop/src/main/services/common/comments-cache.ts create mode 100644 apps/desktop/src/main/services/common/mirror.ts create mode 100644 apps/desktop/src/main/services/common/pr-lookup.ts create mode 100644 apps/desktop/src/main/services/common/usage.ts create mode 100644 apps/desktop/src/main/services/config/index.ts create mode 100644 apps/desktop/src/main/services/context.ts create mode 100644 apps/desktop/src/main/services/pr/index.ts create mode 100644 apps/desktop/src/main/services/run-queue.ts create mode 100644 packages/ipc/package.json create mode 100644 packages/ipc/src/agent.ts create mode 100644 packages/ipc/src/app.ts create mode 100644 packages/ipc/src/common.ts create mode 100644 packages/ipc/src/config.ts create mode 100644 packages/ipc/src/events.ts create mode 100644 packages/ipc/src/index.ts create mode 100644 packages/ipc/src/pr.ts create mode 100644 packages/ipc/tsconfig.json delete mode 100644 packages/shared/src/ipc.ts create mode 100644 packages/shared/src/sync-progress.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index f880f9d..7e18dc3 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -6,6 +6,7 @@ import { resolve } from 'node:path'; // 外部第三方依赖(electron / pino / yaml / zod ...)继续 externalize 让 Node 在运行时解析。 const internalPackages = [ '@meebox/shared', + '@meebox/ipc', '@meebox/agent', '@meebox/config', '@meebox/logger', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 11c55a5..9b0534a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -64,6 +64,7 @@ "@iconify/react": "^5.2.1", "@meebox/agent": "*", "@meebox/config": "*", + "@meebox/ipc": "*", "@meebox/logger": "*", "@meebox/platform-bitbucket-server": "*", "@meebox/platform-github": "*", diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 7d47661..c770511 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,2239 +1,38 @@ -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; -import { execFile } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import type { Logger } from 'pino'; -import { - appendAgentNotes, - buildToolCatalog, - judgeAutopilotBatch, - loadAgentContext, - loadAgentRules, -} from '@meebox/agent'; -import type { AgentContext } from '@meebox/agent'; -import { writeConfig, type BootstrapResult } from '@meebox/config'; -import { PrAgentRunError, type PrAgentBridge } from '@meebox/pr-agent-bridge'; -import { - type Poller, - createDraft, - deleteDraft, - dropPendingFindingDrafts, - finishReviewRun, - getReviewRun, - isCommentsCacheStale, - listDrafts, - listReviewRunsForPr, - hasReviewOutput, - clearReviewRunsForPr, - listStoredPullRequests, - makeRunId, - parseReviewOutput, - readCommentsCache, - readDiffBaseCache, - writeDiffBaseCache, - setLocalStatus, - startReviewRun, - updateDraft, - writeCommentsCache, - writeAutopilotLedger, - getAutopilotLedger, - clearAutopilotLedger, - getAgentSession, - clearAgentSession, - getAgentConversation, - getAgentTranscript, - appendAgentMessage, -} from '@meebox/poller'; -import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import { pickMatchingRule } from '@meebox/rules'; -import type { - AgentRecommendationVerdict, - AgentSession, - AgentStep, - AppInfo, - ConnectionSummary, - IpcChannels, - PlatformAdapter, - PrAgentStatus, - PrComment, - PragentRunInfo, - ReviewRun, - ReviewRunStatus, - ReviewRunTool, - StoredPullRequest, - TokenUsage, -} from '@meebox/shared'; -import type { JsonFileStateStore } from '@meebox/state-store'; -import { buildDraftAdapter, type BuiltAdapter, type ConnectionRuntime } from './adapters.js'; -import { t, getMainLanguage, setMainLanguage } from './i18n/index.js'; -import { sniffImageContentType } from './utils/image.js'; -import { buildPragentEnv, resolveActiveLlmProfile } from './utils/agent.js'; -import { buildProxyEnv, testProxyConnectivity } from './utils/proxy.js'; -import { checkForUpdate } from './utils/update-check.js'; -import { getLastUpdateResult, publishUpdateResult } from './utils/update-state.js'; -import { buildPrContext } from './utils/pr-context.js'; -import { runAgentReview } from './agent-review.js'; -import { runAgentPlanning } from './agent-planning.js'; +import { createAgentOrchestratorService } from './services/agent-orchestrator.js'; +import { registerAgentHandlers } from './services/agent/index.js'; +import { registerAppHandlers } from './services/app/index.js'; +import { registerConfigHandlers } from './services/config/index.js'; +import { createIpcContext, type RegisterDeps } from './services/context.js'; +import { registerPrHandlers } from './services/pr/index.js'; +import { createRunQueueService } from './services/run-queue.js'; -interface RegisterDeps { - bootstrap: BootstrapResult; - logger: Logger; - /** 惰性取 pr-agent 探测状态:探测异步进行(不阻塞建窗),await 拿最终结果 */ - getPrAgentStatus: () => Promise; - /** 惰性取 bridge 实例;探测未完成 / 不可用 (embedded / CLI 都没有) 时为 null */ - getPrAgentBridge: () => PrAgentBridge | null; - /** 嵌入式运行时解释器路径(embedded 策略下执行期补 .secrets.toml 用),非 embedded 可空 */ - embeddedPythonPath?: string; - stateStore: JsonFileStateStore; - poller: Poller; - /** 可变连接运行时(全量 adapters + adapterByHost);设置页改连接后被 reconfigure 原地替换 */ - connectionRuntime: ConnectionRuntime; - /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ - reconfigureConnections: () => Promise; - repoMirror: RepoMirrorManager; -} +export type { RegisterDeps } from './services/context.js'; /** - * 注册全部 IPC handler。后续新增 channel 时只需扩 IpcChannels + 在此添加一个 handle。 - * 故意保持显式,每个 channel 一行映射,方便审计 main↔renderer 暴露面。 + * 注册全部 IPC handler。薄入口:构建共享上下文 → 建两个跨域 service(run 队列 / Agent 编排) + * → 按业务领域注册 handler(GUI 框架 / PR 操作 / 配置 / Agent 交互)→ 返回运行时控制句柄。 + * + * 各域业务实现见 `services/`:app·pr·config·agent 各域 handler,run-queue / agent-orchestrator + * 两个跨域 service,common/ 公共工具,context.ts 共享上下文。新增 channel 时先在 `@meebox/ipc` + * 对应域加类型,再到对应 service 加 handler。 */ -export function registerIpcHandlers({ - bootstrap, - logger, - getPrAgentStatus, - getPrAgentBridge, - embeddedPythonPath, - stateStore, - poller, - connectionRuntime, - reconfigureConnections, - repoMirror, -}: RegisterDeps): { +export function registerIpcHandlers(deps: RegisterDeps): { abortAllActiveRuns: () => number; runAutopilotIfDue: () => void; terminateAgentsForGonePrs: () => void; } { - // === pr-agent run 队列 === - // - // FIFO 队列,同时只有 1 条在跑 (避免撞 LLM rate limit / 抢 worktree), - // 其余在 waiting 排队。每次 active 完成 / 取消 → 自动开下一条。 - // - // 设计要点: - // - runId 在入队时就分配 (跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 - // active / waiting 两种状态都能精确定位 - // - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact - // - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent - // - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 - interface QueueItem { - info: PragentRunInfo; - req: { localId: string; tool: ReviewRunTool; question?: string }; - pr: StoredPullRequest; - resolve: (run: ReviewRun) => void; - reject: (err: Error) => void; - /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。见 §7 调度。 */ - priority: 'user' | 'agent'; - /** 仅 active 状态填;用于 cancel SIGKILL */ - ac?: AbortController; - } - const waiting: QueueItem[] = []; - // 并发运行中的 run(runId → item);上限 maxConcurrency。post-Docker 下每个 run - // 独立 worktree(路径带 nonce)+ 独立子进程,并发安全;串行不再是正确性要求。 - const active = new Map(); - const maxConcurrency = bootstrap.config.pr_agent.max_concurrency; - - const broadcastQueueChanged = (): void => { - const payload = { - active: [...active.values()].map((q) => q.info), - waiting: waiting.map((q) => q.info), - }; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('pragent:queueChanged', payload); - } - }; - - /** 草稿变更广播:drafts:* IPC 写盘后调用,告诉 renderer 重拉某 PR 的草稿列表 */ - const broadcastDraftsChanged = (localId: string): void => { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('drafts:changed', { localId }); - } - }; - - const findPrOrThrow = async (localId: string): Promise => { - const prs = await listStoredPullRequests(stateStore); - const pr = prs.find((p) => p.localId === localId); - if (!pr) throw new Error(`PR not found in local state: ${localId}`); - return pr; - }; - - /** 生效的 Agent 目录:用户配置优先,未配置则回落工作目录默认位置(~/.code-meeseeks/agent)。 */ - const effectiveAgentDir = (): string => bootstrap.config.agent.dir || bootstrap.paths.agentDir; - - // /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), - // 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 - const stripAskQuestionEcho = (md: string, ...echoed: string[]): string => { - const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); - if (!qs.size || !md) return md; - return md - .split('\n') - .filter((line) => !qs.has(line.trim())) - .join('\n'); - }; - - // embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 - // .secrets.toml(pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥 - // 不用 secrets.toml,写个空文件压掉告警)。 - // memo 化:只在首个 embedded run 解析一次 pr_agent 目录 + 写文件,后续直接复用。 - // importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 - const execFileP = promisify(execFile); - let embeddedSecretsEnsured: Promise | null = null; - const ensureEmbeddedSecrets = (pythonPath: string): Promise => { - embeddedSecretsEnsured ??= (async () => { - const { stdout } = await execFileP(pythonPath, [ - '-c', - "import importlib.util,os;print(os.path.dirname(importlib.util.find_spec('pr_agent').origin))", - ]); - const prAgentDir = stdout.trim(); - for (const sub of ['settings', 'settings_prod']) { - const dir = path.join(prAgentDir, sub); - await fs.mkdir(dir, { recursive: true }); - const f = path.join(dir, '.secrets.toml'); - try { - await fs.access(f); - } catch { - await fs.writeFile( - f, - '# meebox placeholder: silence pr-agent warning about a missing .secrets.toml\n', - ); - } - } - })().catch((err: unknown) => { - logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); - }); - return embeddedSecretsEnsured; - }; - - const repoIdentityFor = (pr: StoredPullRequest): RepoIdentity => { - const conn = bootstrap.config.connections.find((c) => c.id === pr.connectionId); - if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); - return { - host: new URL(conn.base_url).hostname, - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - }; - }; - - ipcMain.handle('app:info', (): IpcChannels['app:info']['response'] => buildAppInfo(bootstrap)); - ipcMain.handle('app:paths', (): IpcChannels['app:paths']['response'] => bootstrap.paths); - ipcMain.handle( - 'app:prAgentStatus', - (): Promise => getPrAgentStatus(), - ); - // 渲染层日志回传:落进同一份 meebox.log(scope=renderer),与 main 日志合流便于排查。 - const rendererLogger = logger.child({ scope: 'renderer' }); - ipcMain.handle('log:write', (_evt, req: IpcChannels['log:write']['request']): void => { - const obj = req.meta ?? {}; - switch (req.level) { - case 'error': - rendererLogger.error(obj, req.msg); - break; - case 'warn': - rendererLogger.warn(obj, req.msg); - break; - case 'info': - rendererLogger.info(obj, req.msg); - break; - case 'debug': - rendererLogger.debug(obj, req.msg); - break; - } - }); - ipcMain.handle('app:connections', (): IpcChannels['app:connections']['response'] => - buildConnectionSummaries(bootstrap, connectionRuntime.adapters), - ); - - // (connectionId, slug) → dataUrl 或 null。两级 cache: - // 1) avatarMem: 进程内 Map,本会话内瞬时返回(含 null 负缓存避免重试失败 slug) - // 2) 磁盘文件 /avatars/.bin,TTL 7 天,按 mtime 判定过期 - // 过期或不存在 → 重新打 Bitbucket → 写回磁盘 - // hash = sha256(connectionId|slug) 前 24 hex,纯字母数字文件名安全 - const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; - const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); - const avatarMem = new Map(); - - ipcMain.handle( - 'comments:reply', - async ( - _evt, - req: IpcChannels['comments:reply']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - const reply = await adapter.replyToComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.parentCommentId, - req.body, - ); - // 清掉 comments cache,下次 listComments 会 force 拉远端拿到最新评论树 - // (包括刚 post 的 reply 嵌入到正确父评论 .replies 数组)。同时广播事件让 - // CommentsPanel / DiffView 自动重拉 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - return reply; - }, - ); - - ipcMain.handle( - 'comments:delete', - async ( - _evt, - req: IpcChannels['comments:delete']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // Bitbucket 在以下情形 409/403: - // - version 跟远端不一致 (用户在别处已编辑) - // - 评论已有回复 (跟 web UI 同步规则) - // - 当前 PAT 不是作者本人 - // 错误体已经在 BitbucketClientError.message 里带,直接抛给 renderer 显示原文 - await adapter.deleteComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - ); - // 跟 reply 同套:清 cache + 广播让 UI 立刻看到评论消失 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - }, - ); - - ipcMain.handle( - 'comments:edit', - async ( - _evt, - req: IpcChannels['comments:edit']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // Bitbucket 409 (version 不一致) 时 BitbucketClientError.message 会带 "expected version X" - // 这种细节,原样抛给 renderer 显示让用户知道"远端有新版本" - const updated = await adapter.editComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - req.body, - ); - // 清 cache + 广播,UI 重拉刷新 (跟 delete 同套链路)。返回 updated 仅作 - // 调用方乐观参考 — 实际页面渲染走 cache→force-refresh 路径 - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - return updated; - }, - ); - - ipcMain.handle( - 'comments:fetchAttachment', - async ( - _evt, - req: IpcChannels['comments:fetchAttachment']['request'], - ): Promise => { - // 找 PR 对应的 connection adapter 拉 attachment。不缓存 — 评论图片重复 - // 加载概率低 (用户决策),每次进入 PR 走 IPC 跟头像走 cache 不同 - try { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) return null; - // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL - const res = await adapter.getAttachment(req.url, pr.repo); - if (!res) return null; - const base64 = Buffer.from(res.bytes).toString('base64'); - return { dataUrl: `data:${res.contentType};base64,${base64}` }; - } catch { - return null; - } - }, - ); - - ipcMain.handle( - 'app:userAvatar', - async ( - _evt, - req: IpcChannels['app:userAvatar']['request'], - ): Promise => { - const memKey = `${req.connectionId}|${req.slug}`; - if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; - - const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); - const filePath = path.join(avatarDir, `${hash}.bin`); - - // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) - try { - const stat = await fs.stat(filePath); - const age = Date.now() - stat.mtimeMs; - if (age < AVATAR_TTL_MS) { - const bytes = await fs.readFile(filePath); - const contentType = sniffImageContentType(bytes); - const result = { - dataUrl: `data:${contentType};base64,${bytes.toString('base64')}`, - }; - avatarMem.set(memKey, result); - return result; - } - // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) - await fs.unlink(filePath).catch(() => undefined); - } catch { - // 文件不存在 / 读失败 → 走 fetch - } - - // 2) 没缓存 / 已过期:去 Bitbucket 拉 - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === req.connectionId, - )?.adapter; - if (!adapter) { - avatarMem.set(memKey, null); - return null; - } - try { - const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); - if (!img) { - logger.debug( - { connectionId: req.connectionId, slug: req.slug }, - 'avatar fetch returned null', - ); - avatarMem.set(memKey, null); - return null; - } - // 落盘:best-effort,写失败不影响响应 - try { - await fs.mkdir(avatarDir, { recursive: true }); - await fs.writeFile(filePath, img.bytes); - } catch (writeErr) { - logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); - } - const base64 = Buffer.from(img.bytes).toString('base64'); - const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; - avatarMem.set(memKey, result); - logger.debug( - { - hash, - slug: req.slug, - bytes: img.bytes.length, - contentType: img.contentType, - }, - 'avatar fetched + cached to disk', - ); - return result; - } catch (err) { - logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); - avatarMem.set(memKey, null); - return null; - } - }, - ); - ipcMain.handle('config:read', (): IpcChannels['config:read']['response'] => bootstrap.config); - ipcMain.handle('app:openConfigFile', async (): Promise => { - const err = await shell.openPath(bootstrap.paths.configFile); - if (err) throw new Error(`failed to open config.yaml: ${err}`); - }); - ipcMain.handle('app:openAgentDir', async (): Promise => { - // 当前生效的 Agent 目录(用户配置优先,否则默认 ~/.code-meeseeks/agent);先确保存在再打开。 - const dir = effectiveAgentDir(); - await fs.mkdir(dir, { recursive: true }).catch(() => undefined); - const err = await shell.openPath(dir); - if (err) throw new Error(`failed to open agent dir: ${err}`); - }); - ipcMain.handle('app:openDevTools', (evt) => { - evt.sender.openDevTools({ mode: 'detach' }); - }); - ipcMain.handle( - 'app:checkUpdate', - async (): Promise => { - // 与启动检测一致受 check_enabled 控制:关闭时不发起请求,直接返回禁用结果。 - if (!bootstrap.config.update.check_enabled) { - return { - ok: false, - hasUpdate: false, - currentVersion: app.getVersion(), - error: 'update check disabled by config', - }; - } - const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); - // 交给单一真相源:缓存 + 有新版则广播到所有窗口(状态栏据此同步,不再只回设置页本地)。 - publishUpdateResult(result); - return result; - }, - ); - ipcMain.handle( - 'app:getUpdateStatus', - (): IpcChannels['app:getUpdateStatus']['response'] => getLastUpdateResult(), - ); - ipcMain.handle( - 'app:openExternal', - async (_evt, req: IpcChannels['app:openExternal']['request']): Promise => { - // 白名单:仅放行 http(s),防止 file:// / javascript: 等被恶意 markdown 注入触发 - if (!/^https?:\/\//.test(req.url)) return; - await shell.openExternal(req.url); - }, - ); - - ipcMain.handle( - 'dialog:pickDirectory', - async ( - evt, - req: IpcChannels['dialog:pickDirectory']['request'], - ): Promise => { - const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; - const result = win - ? await dialog.showOpenDialog(win, { - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }) - : await dialog.showOpenDialog({ - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }); - if (result.canceled || result.filePaths.length === 0) { - return { path: null }; - } - return { path: result.filePaths[0]! }; - }, - ); - - ipcMain.handle('prs:list', async (): Promise => { - // 单活动连接模型:只展示当前活动连接的 PR。状态库可能仍存着切换前其他连接的 - // 历史 PR(poller 只轮询活动连接,不会清理旧的),故在出口按 connectionId 过滤。 - const activeId = bootstrap.config.active_connection_id; - const all = await listStoredPullRequests(stateStore); - return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; - }); - ipcMain.handle( - 'prs:refresh', - async (): Promise => poller.tick(), - ); - ipcMain.handle('prs:lastSync', (): IpcChannels['prs:lastSync']['response'] => ({ - at: poller.getLastPollAt(), - })); - ipcMain.handle( - 'prs:setLocalStatus', - async ( - _evt, - req: IpcChannels['prs:setLocalStatus']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // 先写远端:本地 status → Bitbucket reviewer.status;失败抛出,前端不会看到本地变更 - const remoteStatus = - req.status === 'approved' - ? 'approved' - : req.status === 'needs_work' - ? 'needsWork' - : 'unapproved'; - await adapter.setPullRequestReviewStatus( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - remoteStatus, - ); - // 远端 OK 后落本地,UI 立即反映;下一轮 poll 会取回相同值 - return setLocalStatus(stateStore, req.localId, req.status); - }, - ); - - ipcMain.handle( - 'prs:merge', - async (_evt, req: IpcChannels['prs:merge']['request']): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // 合并远端;失败 (冲突 / veto / 权限) 抛出,renderer 提示,本地不变。 - // 成功后不在此落本地:PR 转 MERGED 会从 pending 消失,靠 renderer 触发的 - // refresh → poll 软删收尾,避免本地状态与远端各执一词 - await adapter.mergePullRequest( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - /** - * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha - * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR - * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 - * - * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 - * - * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 - * 快速路径命中率应该很高。 - */ - const ensureMirrorReadyForPr = async ( - pr: StoredPullRequest, - ): Promise<{ mirrorPath: string; freshClone: boolean }> => { - const id = repoIdentityFor(pr); - const [hasHead, hasBase] = await Promise.all([ - repoMirror.hasCommit(id, pr.sourceRef.sha), - repoMirror.hasCommit(id, pr.targetRef.sha), - ]); - if (hasHead && hasBase) { - // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log - return { mirrorPath: repoMirror.mirrorPath(id), freshClone: false }; - } - const r = await repoMirror.syncMirror(id); - return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; - }; - - /** - * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 - * - * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, - * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, - * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: - * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; - * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 - * - * 失效重算:固化 base 不再是当前 head 的祖先(源分支被 rebase)→ 重算。head 正常 push(仅前进) - * 不失效。算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 - * - * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 - */ - const resolveDiffBaseSha = async (pr: StoredPullRequest): Promise => { - const id = repoIdentityFor(pr); - const head = pr.sourceRef.sha; - const cached = await readDiffBaseCache(stateStore, pr.localId); - if (cached?.base_sha && (await repoMirror.isAncestor(id, cached.base_sha, head))) { - return cached.base_sha; - } - const mb = await repoMirror.mergeBase(id, pr.targetRef.sha, head); - if (!mb) return pr.targetRef.sha; - await writeDiffBaseCache(stateStore, pr.localId, { - base_sha: mb, - head_sha: head, - computed_at: new Date().toISOString(), - }); - return mb; - }; - - ipcMain.handle( - 'repo:sync', - async ( - _evt, - req: IpcChannels['repo:sync']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - return ensureMirrorReadyForPr(pr); - }, - ); - - ipcMain.handle( - 'diff:listChangedFiles', - async ( - _evt, - req: IpcChannels['diff:listChangedFiles']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 自动确保 mirror 含 head + base sha (快速路径命中即 noop);再算 diff - await ensureMirrorReadyForPr(pr); - // base 锚到固定 merge-base(非漂移的 targetRef.sha),三点 diff 对目标分支前移稳定 - const base = await resolveDiffBaseSha(pr); - return repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); - }, - ); - - ipcMain.handle( - 'diff:getFileContent', - async ( - _evt, - req: IpcChannels['diff:getFileContent']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // base 侧读固定 merge-base 的内容(与三点 diff 一致),head 侧读源 tip - const sha = req.side === 'base' ? await resolveDiffBaseSha(pr) : pr.sourceRef.sha; - return repoMirror.getFileContent(id, sha, req.path); - }, - ); - - ipcMain.handle( - 'diff:commentCountCached', - async ( - _evt, - req: IpcChannels['diff:commentCountCached']['request'], - ): Promise => { - const cache = await readCommentsCache(stateStore, req.localId); - if (!cache) return null; - return { count: cache.comments.length }; - }, - ); - - // In-flight dedup: 打开 PR 时 MainPane / DiffView / CommentsPanel 三个组件 - // 并行调 listComments(force:true),没去重的话会打 3 次 Bitbucket API。同一 localId - // 的 concurrent 调用合并到同一个 Promise,远端只打一次 - const listCommentsInFlight = new Map>(); - ipcMain.handle( - 'diff:listComments', - async ( - _evt, - req: IpcChannels['diff:listComments']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - // 缓存命中条件:pr_updated_at 跟当前 PR meta updatedAt 一致 → 直接回缓存, - // 不打远端。PR 任何变更 (新评论 / 状态等) Bitbucket 都会更新 updatedAt,跳变即重拉。 - // - // **req.force=true** 跳过 cache 直接打远端 — 本地 PR.updatedAt 来自 poller - // 周期拉,可能滞后,stale 比对会误判命中。打开 PR 时 renderer 传 force=true - // 强制刷新,确保拿到最新评论 - const cache = await readCommentsCache(stateStore, pr.localId); - if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { - return cache.comments; - } - // dedup:同 localId 的 in-flight Promise 直接复用 - const existing = listCommentsInFlight.get(pr.localId); - if (existing) return existing; - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - const fetchPromise = adapter - .listPullRequestComments( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ) - .then((raw) => annotateOwnership(raw, adapter)) - .then(async (fresh) => { - await writeCommentsCache(stateStore, pr.localId, { - comments: fresh, - pr_updated_at: pr.updatedAt, - fetched_at: new Date().toISOString(), - }); - return fresh; - }) - .finally(() => { - listCommentsInFlight.delete(pr.localId); - }); - listCommentsInFlight.set(pr.localId, fetchPromise); - return fetchPromise; - }, - ); - - ipcMain.handle( - 'diff:listCommits', - async ( - _evt, - req: IpcChannels['diff:listCommits']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - // commits 不缓存(量少 + UI 进 commits 标签页才拉,频率低);后续如发现频繁拉 - // 再补 prs//commits.json 缓存层 (走 pr_updated_at 失效,跟 comments 同模式) - return adapter.listPullRequestCommits( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - ipcMain.handle( - 'diff:commitCount', - async ( - _evt, - req: IpcChannels['diff:commitCount']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 本地 git 算提交数;不打远端、不主动触发 sync。镜像还没拉齐就返回 null, - // UI 角标暂不显示,等下次 poll 触发 syncMirror 完成后自然命中。 - // 口径 = PR 自身提交(源分支「不在目标分支上」的非 merge 提交),对齐平台 /commits 列表。 - // **基准用目标分支 sha(head ^target)而非固定 merge-base**:源分支把目标分支(如 dev)合入自己后, - // merge-base 之后被带进来的目标提交也可达 head、不可达 merge-base → 用 merge-base 会把它们误计 - // (标 31 实则 2)。以 targetRef.sha 排除这些合入提交;merge 提交本身由 countCommits 的 --no-merges 略去。 - // (diff 仍用固定 merge-base 保稳定,与本计数口径各司其职。) - const n = await repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); - return n === null ? null : { count: n }; - }, - ); - - ipcMain.handle( - 'diff:getBlame', - async ( - _evt, - req: IpcChannels['diff:getBlame']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 只对 base 已有部分展示 blame;PR 引入的行单独返给 renderer, - // 由 BlameColumn 画色带占位(对应 Monaco diff 添加/修改区的视觉)。 - const base = await resolveDiffBaseSha(pr); - const [allBlame, changedSet] = await Promise.all([ - repoMirror.getBlame(id, pr.sourceRef.sha, req.path), - repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path), - ]); - return { - lines: allBlame.filter((b) => !changedSet.has(b.line)), - changedLines: Array.from(changedSet).sort((a, b) => a - b), - }; - }, - ); - - ipcMain.handle('repo:getTotalSize', async (): Promise<{ totalBytes: number }> => { - const prs = await listStoredPullRequests(stateStore); - const seen = new Set(); - let total = 0; - for (const pr of prs) { - let id: RepoIdentity; - try { - id = repoIdentityFor(pr); - } catch { - continue; - } - const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; - if (seen.has(key)) continue; - seen.add(key); - const r = await repoMirror.getSize(id); - total += r.totalBytes; - } - return { totalBytes: total }; - }); - - /** - * 真正执行一个 queue item:startReviewRun → worktree → bridge.run → finishWith。 - * 由 runNext() 调用,签名稳定后跟 queue 主体解耦;任何抛错都被 runNext 兜成 - * Promise reject,外层 pragent:run 调用方收到。 - */ - const executeRun = async (item: QueueItem): Promise => { - const prAgentBridge = getPrAgentBridge(); - if (!prAgentBridge) throw new Error(t('prAgent.notReady')); - const { req, pr } = item; - // 提前 resolve active LLM profile — model 字段要随 startReviewRun 一起落 - // 盘,让 UI 在 meta 行展示"这次 run 用的什么模型"。后面 buildPragentEnv - // 同样会用到,这里 resolve 一次复用 - const activeLlmForRecord = resolveActiveLlmProfile(bootstrap.config.llm); - // 用入队预分配的 runId 覆盖 startReviewRun 的自生 id,让 cancel(runId) 在 active - // 状态也能精确定位 (跟入队时给的 runId 一致) - const run = await startReviewRun(stateStore, { - id: item.info.runId, - prLocalId: pr.localId, - tool: req.tool, - question: req.tool === 'ask' ? req.question : undefined, - prAgentVersion: prAgentBridge.version, - strategy: prAgentBridge.strategy, - // 持久化用 profile.model 原文,不做 normalizeModel 前缀处理 — 跟用户 - // Settings 里看到的名字一致更直观 - model: activeLlmForRecord?.model || undefined, - }); - // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播 - item.info = { ...item.info, startedAt: run.startedAt }; - broadcastQueueChanged(); - logger.info( - { runId: run.id, localId: pr.localId, tool: req.tool, strategy: prAgentBridge.strategy }, - 'pragent run start', - ); - const t0 = Date.now(); - // 真实 token 用量累加器:sitecustomize 的 litellm callback 把每次调用的 usage 以 - // `@@MEEBOX_USAGE@@ {json}` 哨兵行打到 stderr,下面 onLine 拦截累加(无需临时文件 / env)。 - const usageAcc = { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; - const onLine = (line: string, stream: 'stdout' | 'stderr'): void => { - // 拦截 usage 哨兵行:累加后不转发给 renderer(避免污染实时日志)。 - if (stream === 'stderr' && accumulateUsageSentinel(line, usageAcc)) return; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('pragent:runProgress', { runId: run.id, line, stream }); - } - }; - - const finishWith = async (patch: Parameters[3]): Promise => { - const updated = await finishReviewRun(stateStore, pr.localId, run.id, patch); - return updated ?? { ...run, ...patch }; - }; - - const repoId = repoIdentityFor(pr); - await repoMirror.syncMirror(repoId); - // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base(与 UI diff 同源):让 AI 评审基于 - // 「PR 自分叉后引入的改动」,而非 targetRef.sha 漂移后混入别的 PR 的两点对比 - const diffBase = await resolveDiffBaseSha(pr); - const wt = await repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); - const ac = item.ac!; - try { - const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); - // LLM env + 全局 pr-agent 配置 (响应语言)。语言配置一期写死在 config 里, - // UI 还不暴露切换;后续多语言时改成 Settings 入口 - const env: Record = { - // 代理 env 先铺底,LLM/语言配置在后(互不冲突,仅 HTTP(S)_PROXY 类)。 - // 开关开时让嵌入式 python(litellm/httpx) 经代理出网调 LLM。 - ...buildProxyEnv(bootstrap.config.proxy), - ...(activeLlm ? buildPragentEnv(activeLlm) : {}), - CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), - }; - if (req.tool === 'improve') { - // /improve 在 local provider 下只有「汇总建议 → publish_comment」一条可用路径 - // (shim 已强制 gfm_markdown=True)。committable/inline 模式会走 - // publish_code_suggestions → local provider 直接 NotImplementedError,显式关死兜底 - // (pr-agent 默认即 false,此处防上游翻默认值)。 - env['PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS'] = 'false'; - // persistent_comment(默认 true)会走 publish_persistent_comment_with_history → - // get_issue_comments() 翻历史评论做增量更新 → local provider 不实现,每次 improve - // 都刷一段 NotImplementedError traceback(被上游捕获后兜底 publish_comment,正文 - // 不丢但日志吵)。local 每次都是全新 worktree、无历史可翻,直接关掉走 publish_comment。 - env['PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT'] = 'false'; - // 输出与 /review /ask 的 review.md 分流:pr-agent 原生支持 local.review_path 覆盖 - // publish_comment 的落盘路径;相对路径按子进程 cwd(= worktree 根)解析。 - env['LOCAL__REVIEW_PATH'] = 'improve.md'; - } - - // 注给 pr-agent 的 EXTRA_INSTRUCTIONS 由三部分按顺序拼接: - // 1. 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 - // /ask 走 [pr_questions] 配置段不那么严格遵守,必须显式 prompt 强化 - // 2. PR 上下文 (title / description / 已有评论):local provider 自己不会 - // 去 Bitbucket 拉这些,必须我们这边喂;让 /describe /review 不只是看 diff - // 3. 规则正文 (rules.dir 命中):项目编码规约 - // /ask 只取 1 (语言),跳 2/3 (用户问题往往跟历史评论 / 规约无关) - const langDirective = languageDirectiveFor(getMainLanguage()); - let prContext = ''; - let matchedRuleInstructions = ''; - let matchedRuleId: string | undefined; - if (req.tool !== 'ask') { - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (adapter) { - try { - prContext = await buildPrContext({ pr, adapter, logger }); - } catch (err) { - logger.warn( - { err, runId: run.id, localId: pr.localId }, - 'buildPrContext threw; proceeding without PR context', - ); - } - } - - const rules = await loadAgentRules(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), - }); - const matched = pickMatchingRule(rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: req.tool, - }); - if (matched) { - matchedRuleInstructions = matched.instructions; - matchedRuleId = matched.id; - } - } - - // anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 - // [file: , lines: -] - // - // 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 - // `[**header**](meebox:///#L-L)`,parse-output 取结构化 anchor(path 来自 - // provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/ - // end_line YAML 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。 - // 故这条 marker 作为**行号兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker - // 的行号补(resolveIssueAnchor)。两路信号都用上,最大化 anchor 覆盖。 - // - // 两种工具措辞不同: - // - /review: 每条 key_issue 末尾 **必加** marker - // - /ask: 仅当回答涉及具体文件 / 代码位置时 **才加** (自由问答可能完全跟代码 - // 无关 e.g. "PR 概述"),强制会产出假阳性 - // - // /describe / /improve 不注入:前者不出 issue,后者走 marker 行 - // `[file [start-end]](url)` 自己有 anchor - const reviewAnchorDirective = - req.tool === 'review' - ? [ - 'When writing each item under `key_issues_to_review`, append on its OWN LAST LINE', - 'a machine-readable anchor marker in this EXACT format:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - '', - 'Use the exact relevant_file path and start_line/end_line you already', - 'identified in the YAML output. Do NOT wrap the path in backticks. If you', - 'truly cannot identify a file/line for an issue, omit the marker for that', - 'item only.', - ].join('\n') - : req.tool === 'ask' - ? [ - 'CRITICAL: This answer is consumed by a code review GUI that converts your', - 'per-paragraph recommendations into INLINE COMMENTS pinned to specific code', - 'lines. For that to work, EVERY paragraph that names a code symbol (function,', - 'method, class, variable, identifier) from this PR MUST end with a', - 'machine-readable anchor marker on its OWN LAST LINE:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - ' [file: pkg/store.ts] (path-only fallback; only when you', - ' truly cannot infer any line number)', - '', - 'How to derive line numbers from the diff:', - '- Every hunk in the diff begins with a header:', - ' @@ -, +, @@', - ' The number after `+` is the FIRST head-side line of that hunk. Count down', - ' through `+` (added) and ` ` (context) lines — DO NOT count `-` (removed)', - ' lines — to locate the line where the symbol appears. Prefer head-side', - ' line numbers. For code that ONLY exists on the base side (purely removed),', - ' use the base-side `-` line number instead.', - '', - 'Rules — read carefully:', - '- The marker is REQUIRED. Do not skip it when your paragraph references a', - ' real code symbol from the diff. A paragraph without a marker becomes', - ' un-pinnable feedback the user cannot turn into a comment.', - '- Append exactly ONE marker per paragraph, at the very end of that paragraph,', - ' on its own line (blank line above it optional but recommended).', - '- If a paragraph discusses multiple locations, pick the most important one', - ' (the line where the recommended change should be made).', - '- Paragraphs that are purely general / conceptual / meta (e.g., overall', - ' praise, no specific symbol named) MAY omit the marker.', - '- Use the exact file path from the diff. Do NOT wrap the path in backticks', - ' or quotes inside the marker.', - '- If you really cannot pin a line, fall back to path-only `[file: ]`', - ' rather than omitting the marker entirely.', - ].join('\n') - : ''; - - // 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 - // pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文; - // 渲染层 (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立

。 - // 关键是「保持简洁」——只在现象/影响/建议的语义边界换行,不得借分段扩写内容。 - // 须与上面的 anchor marker 指令协同:分段在正文内部,marker 仍独占最末行。 - const reviewLayoutDirective = - req.tool === 'review' - ? [ - 'FORMATTING ONLY: Keep each `key_issues_to_review` item as concise as you', - 'already would — do NOT add length, padding, or extra explanation. The only', - 'change is line breaks: instead of one dense run-on paragraph, insert a BLANK', - 'LINE at the natural boundaries (e.g. problem → impact → suggested fix) so the', - 'text reads as a few short paragraphs. Same words, better layout.', - '', - 'This applies to the issue PROSE only. The machine-readable anchor marker', - 'described above still goes on its OWN LAST LINE, after the final paragraph', - '(a blank line may precede it).', - ].join('\n') - : ''; - - const extraParts = [ - langDirective, - reviewAnchorDirective, - reviewLayoutDirective, - prContext, - matchedRuleInstructions, - ].filter((s) => s.trim()); - if (extraParts.length > 0) { - const envKey = - req.tool === 'describe' - ? 'PR_DESCRIPTION__EXTRA_INSTRUCTIONS' - : req.tool === 'review' - ? 'PR_REVIEWER__EXTRA_INSTRUCTIONS' - : req.tool === 'improve' - ? 'PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS' - : 'PR_QUESTIONS__EXTRA_INSTRUCTIONS'; - env[envKey] = extraParts.join('\n\n---\n\n'); - } - if (matchedRuleId) { - logger.info( - { runId: run.id, ruleId: matchedRuleId, tool: req.tool }, - 'pragent run: matched rule', - ); - } - if (prContext) { - logger.debug( - { runId: run.id, tool: req.tool, contextChars: prContext.length }, - 'pragent run: pr context injected', - ); - } - - // ask 工具:问题作为位置参数(user turn,spawn args 单元素,含空格也是一个 arg 不切分), - // 并在问题**末尾**硬性追加语言要求。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对 - // 自由问答常被大量英文 diff(full diff 数万 token)盖过 → 模型用英文作答;在 user turn 末尾 - // (近因位置、用目标语言书写)再要求一次,显著提升按 UI 语言作答的遵循度。en-US 返回空、不追加。 - const askLangSuffix = req.tool === 'ask' ? askLanguageSuffixFor(getMainLanguage()) : ''; - const askQuestion = - req.tool === 'ask' && req.question - ? askLangSuffix - ? `${req.question}\n\n${askLangSuffix}` - : req.question - : undefined; - const extraArgs = askQuestion ? [askQuestion] : undefined; - - // embedded 策略:执行期在嵌入式安装目录补空 .secrets.toml 压掉启动告警 - // (直接写安装目录;memo 化只首次做)。local-cli 不需要 (pipx 装的 pr-agent - // 路径不同,告警也不出) - if (prAgentBridge.strategy === 'embedded' && embeddedPythonPath) { - await ensureEmbeddedSecrets(embeddedPythonPath); - } - - const result = await prAgentBridge.run({ - prUrl: pr.url, - tool: req.tool, - env, - onLine, - cwd: wt.path, - targetBranch: wt.targetBranchName, - extraArgs, - signal: ac.signal, - }); - // 真实 token 用量(onLine 累加的 stderr 哨兵行),落到 succeeded / llm-failed 收尾。 - const tokenUsage = finalizeUsage(usageAcc); - // pr-agent 的 local provider 把生成结果**写到工作树根的 markdown 文件**: - // /describe → /description.md (走 publish_description) - // /review → /review.md (走 publish_comment) - // /ask → /review.md ← 共用同一文件 (publish_comment 会覆盖) - // /improve → /improve.md ← 汇总建议走 publish_comment,经 LOCAL__REVIEW_PATH - // 重定向与 review.md 分流(见上方 env 注入) - // 走 worktree 路径,cleanup 前必须先把文件读出来。 - const outFile = - req.tool === 'describe' - ? 'description.md' - : req.tool === 'improve' - ? 'improve.md' - : 'review.md'; - let fileContent = ''; - try { - fileContent = await fs.readFile(path.join(wt.path, outFile), 'utf8'); - } catch (readErr) { - logger.warn( - { err: readErr, wtPath: wt.path, outFile, runId: run.id }, - 'pr-agent local provider output file missing; fall back to stdout', - ); - } - // /ask 输出里 pr-agent 把问题原样回显在 answer body 顶部 (跟 chat 输入气泡完全 - // 重复)。在解析前把跟用户问题逐字匹配的整行删掉,避免渲染时出现两次问题 - const cleanedContent = - req.tool === 'ask' && req.question?.trim() - ? stripAskQuestionEcho(fileContent, req.question, askLangSuffix) - : fileContent; - const parsed = parseReviewOutput(cleanedContent || result.stdout, req.tool); - // M4 草稿再摄入:/review 成功完成时丢掉 pending+finding 旧草稿, - // 让本轮 ChatPane 上的 finding 列表成为新的候选源。edited/posted/rejected/ - // manual 保留不动。失败的 /review 不触发清理 (没建设性数据)。 - if (req.tool === 'review') { - try { - const dropped = await dropPendingFindingDrafts(stateStore, pr.localId); - if (dropped > 0) { - logger.info( - { runId: run.id, localId: pr.localId, dropped }, - 'pragent /review: dropped stale pending drafts', - ); - broadcastDraftsChanged(pr.localId); - } - } catch (err) { - logger.warn({ err, runId: run.id }, 'dropPendingFindingDrafts failed'); - } - } - // pr-agent CLI 可能 exit 0 但 stdout 里其实是 LLM 调用全失败 (litellm - // AuthenticationError / "Failed to generate prediction with any model" 等 - // marker)。parseReviewOutput 会在 ParsedReviewOutput.llmFailure 标出 — - // 此时不算 succeeded,落盘为 failed + reason='llm-error',UI 用红色失败 - // chip 渲染而不是"完成" - if (parsed.llmFailure) { - logger.warn( - { runId: run.id, reason: parsed.llmFailure.message }, - 'pragent exit 0 but LLM call failed; marking run as failed', - ); - return await finishWith({ - status: 'failed', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: result.exitCode, - errorReason: 'llm-error', - errorMessage: parsed.llmFailure.message, - stdout: fileContent - ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` - : result.stdout, - stderr: stripUsageSentinels(result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } - return await finishWith({ - status: 'succeeded', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: result.exitCode, - // 持久化「LLM 真实产出」(文件内容);stdout 留作日志在折叠区供排障 - stdout: fileContent - ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` - : result.stdout, - stderr: stripUsageSentinels(result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } catch (err) { - if (err instanceof PrAgentRunError) { - // 用户主动取消 → status='cancelled',其它 reason → 'failed'。 - // 二者都仍走 finishReviewRun 落盘,让 UI 能从历史 run 里看到这次取消事件 - const status: ReviewRunStatus = err.reason === 'cancelled' ? 'cancelled' : 'failed'; - logger.warn( - { runId: run.id, reason: err.reason, exitCode: err.result.exitCode }, - `pragent run ${status}`, - ); - // 失败 / 取消时也尽量解析已收集的 stdout:很多情况 pr-agent 已写了一部分输出 - const partialStdout = err.result.stdout ?? ''; - const parsed = partialStdout - ? parseReviewOutput(partialStdout, req.tool) - : { findings: [], summary: undefined }; - // 失败 / 取消前可能已有若干次 LLM 调用,尽量把已产生的 token 用量也记上 - const tokenUsage = finalizeUsage(usageAcc); - return await finishWith({ - status, - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - exitCode: err.result.exitCode, - errorReason: err.reason, - errorMessage: err.message, - stdout: err.result.stdout, - stderr: stripUsageSentinels(err.result.stderr), - findings: parsed.findings, - summary: parsed.summary, - tokenUsage, - }); - } - // 非预期异常:仍记一笔 failed,避免 run 永远卡在 running,再把异常往上抛 - await finishWith({ - status: 'failed', - finishedAt: new Date().toISOString(), - durationMs: Date.now() - t0, - errorMessage: err instanceof Error ? err.message : String(err), - }); - throw err; - } finally { - await wt.cleanup(); - } - }; - - /** - * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 - * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 - */ - const pump = (): void => { - while (active.size < maxConcurrency && waiting.length > 0) { - const item = waiting.shift()!; - active.set(item.info.runId, item); - item.ac = new AbortController(); - void executeRun(item) - .then((finished) => item.resolve(finished)) - .catch((err: unknown) => { - item.reject(err instanceof Error ? err : new Error(String(err))); - }) - .finally(() => { - active.delete(item.info.runId); - broadcastQueueChanged(); - // 放微任务里再泵,避免递归栈累积 - queueMicrotask(pump); - }); - } - broadcastQueueChanged(); - }; - - /** - * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR - * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 - * `pragent:run` handler 与 Agent 编排器(runTool)都走它。 - */ - const enqueuePragentRun = ( - pr: StoredPullRequest, - tool: ReviewRunTool, - question?: string, - priority: 'user' | 'agent' = 'user', - ): Promise => { - if (tool !== 'ask') { - const sameTask = (q: QueueItem): boolean => - q.info.prLocalId === pr.localId && q.info.tool === tool; - if ([...active.values()].some(sameTask) || waiting.some(sameTask)) { - throw new Error(t('prAgent.duplicateTask', { tool })); - } - } - // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 - const runId = makeRunId(new Date()); - return new Promise((resolve, reject) => { - const item: QueueItem = { - info: { - runId, - prLocalId: pr.localId, - repoSlug: pr.repo.repoSlug, - prNumber: pr.remoteId, - tool, - question: tool === 'ask' ? question : undefined, - enqueuedAt: new Date().toISOString(), - startedAt: null, - }, - req: { localId: pr.localId, tool, question }, - pr, - priority, - resolve, - reject, - }; - // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 - if (priority === 'user') { - const firstAgentIdx = waiting.findIndex((q) => q.priority === 'agent'); - if (firstAgentIdx >= 0) waiting.splice(firstAgentIdx, 0, item); - else waiting.push(item); - } else { - waiting.push(item); - } - logger.info( - { runId, localId: pr.localId, tool, priority, queueLen: waiting.length }, - 'pragent run enqueued', - ); - pump(); - }); - }; - - ipcMain.handle( - 'pragent:run', - async ( - _evt, - req: IpcChannels['pragent:run']['request'], - ): Promise => { - if (!getPrAgentBridge()) { - throw new Error(t('prAgent.notReadyDetail')); - } - // 早期校验:/ask 必须带 question,避免排队后才报错 - if (req.tool === 'ask' && !req.question?.trim()) { - throw new Error(t('prAgent.askNeedsQuestion')); - } - const pr = await findPrOrThrow(req.localId); - return enqueuePragentRun(pr, req.tool, req.question); - }, - ); - - // ── Agent 评审编排:共享 chat 通道 + 单 PR 微流程,agent:run 与 AutoPilot 都用 ── - type AgentChat = (input: { - system: string; - user: string; - }) => Promise<{ text: string; usage?: TokenUsage }>; - - /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 - * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ - const withAgentChat = async ( - fn: (chat: AgentChat) => Promise, - signal?: AbortSignal, - ): Promise => { - const bridge = getPrAgentBridge(); - if (!bridge) throw new Error(t('prAgent.notReadyDetail')); - // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。 - const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); - const env: Record = { - ...buildProxyEnv(bootstrap.config.proxy), - ...(activeLlm ? buildPragentEnv(activeLlm) : {}), - CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), - // Agent 编排通道(规划 / 判读 / 收尾 / 对话)是路由 + 轻量综合,非深度代码分析(那在 - // pr-agent /review 里)。本机 CLI 模式下调低推理档(codex: model_reasoning_effort=minimal) - // 提速;仅作用于本 chat spawn,pr-agent 工具 run 的 env 不含此项 → /review 仍满档推理。 - // 非 CLI 模式(API)由 CLI handler 之外的路径处理,该 env 无副作用。 - MEEBOX_CLI_REASONING: 'low', - }; - // chat 子进程落到中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 - const chatCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'meebox-agent-chat-')); - try { - const chat: AgentChat = async ({ system, user }) => { - const r = await bridge.chat({ system, user, env, cwd: chatCwd, signal }); - const acc: UsageAcc = { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; - for (const line of (r.stderr ?? '').split('\n')) accumulateUsageSentinel(line, acc); - return { text: r.stdout.trim(), usage: finalizeUsage(acc) }; - }; - return await fn(chat); - } finally { - await fs.rm(chatCwd, { recursive: true, force: true }); - } - }; - - /** - * 每个编排步骤的统一出口:① 后台日志(工具选择 / 判读 / 收尾各落一条,便于排障与离线回看); - * ② 广播给渲染层(agent:stepProgress)做过程化展示。thought / result 截断避免刷屏。 - */ - // 后台日志只留骨架(kind / tool / 用时):thought 与 result(含用户输入 / 总结正文)不入日志, - // 避免刷屏 + 泄漏内容;完整步骤已落 transcript.json,需要时从那里回看。 - const emitAgentStep = (pr: StoredPullRequest, sessionId: string, step: AgentStep): void => { - logger.info( - { - prLocalId: pr.localId, - sessionId, - kind: step.kind, - tool: step.toolCall?.tool, - thinkMs: step.thinkMs, - }, - 'agent step', - ); - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); - } - }; - - // 编排 Agent(手动评审 agent:run + 自由规划 agent:ask)每 PR 至多一个在跑,AbortController 供 - // agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 - const agentControllers = new Map(); - - // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。区别于 - // agentControllers(仅手动可停会话):这里**手动 run/ask 与 AutoPilot 后台评审一并计入**, - // 让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 - const runningAgentPrs = new Set(); - const broadcastAgentRunning = (): void => { - const prLocalIds = [...runningAgentPrs]; - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:runningChanged', { prLocalIds }); - } - }; - const markAgentRunning = (localId: string): void => { - runningAgentPrs.add(localId); - broadcastAgentRunning(); - }; - const unmarkAgentRunning = (localId: string): void => { - if (runningAgentPrs.delete(localId)) broadcastAgentRunning(); - }; - - /** 取消某 PR 的全部 pr-agent run:active 的 SIGKILL,waiting 的出队 + reject。 */ - const cancelRunsForPr = (localId: string): void => { - for (const item of active.values()) if (item.req.localId === localId) item.ac?.abort(); - let removed = false; - for (let i = waiting.length - 1; i >= 0; i--) { - if (waiting[i]!.req.localId === localId) { - const [q] = waiting.splice(i, 1); - q!.reject(new Error('pr removed')); - removed = true; - } - } - if (removed) broadcastQueueChanged(); - }; - - /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ - const terminateAgentForPr = (localId: string): void => { - agentControllers.get(localId)?.abort(); - cancelRunsForPr(localId); - }; - - /** - * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 - * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 - */ - const terminateAgentsForGonePrs = async (): Promise => { - const opPrIds = new Set(); - for (const id of agentControllers.keys()) opPrIds.add(id); - for (const item of active.values()) opPrIds.add(item.req.localId); - for (const item of waiting) opPrIds.add(item.req.localId); - if (opPrIds.size === 0) return; - const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); - for (const id of opPrIds) { - if (!live.has(id)) { - logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); - terminateAgentForPr(id); - } - } - }; - - /** 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。 */ - const runReviewForPr = ( - pr: StoredPullRequest, - agentContext: AgentContext, - chat: AgentChat, - signal?: AbortSignal, - autopilot = false, - ): Promise => { - const agentCfg = bootstrap.config.agent; - const matchedRule = pickMatchingRule(agentContext.rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: 'review', - }); - return runAgentReview(pr, { - stateStore, - // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。 - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), - chat, - agentContext, - matchedRule, - language: getMainLanguage(), - // 工具目录注入:修改类工具按 grants 门控(默认全禁,红线见 buildToolCatalog)。 - toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), - maxFollowupAsks: agentCfg.autopilot.max_followup_asks, - summaryMaxChars: agentCfg.summary_max_chars, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), - signal, - autopilot, - }); - }; - - /** - * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— - * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 - * updatedAt)。台账既给 PR 列表的建议徽标(★,手动 / 自动一视同仁),也供 AutoPilot 同版本去重。 - * 失败 / 用户停止(paused)不落,便于后续重试。 - */ - const recordReviewSummaryMessage = async ( - pr: StoredPullRequest, - session: AgentSession, - ): Promise => { - if (session.status !== 'done' || !session.summary) return; - await appendAgentMessage(stateStore, pr.localId, { - role: 'assistant', - content: session.summary, - recommendation: session.recommendation, - }); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'review', - recommendation: session.recommendation?.verdict, - at: new Date().toISOString(), - }); - // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:conversationChanged', { prLocalId: pr.localId }); - } - }; - - ipcMain.handle( - 'agent:run', - async ( - _evt, - req: IpcChannels['agent:run']['request'], - ): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - const pr = await findPrOrThrow(req.localId); - // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); - try { - const session = await withAgentChat( - (chat) => runReviewForPr(pr, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, - 'agent review done', - ); - // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 - await recordReviewSummaryMessage(pr, session); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }, - ); - - const runPlanningForPr = ( - pr: StoredPullRequest, - userRequest: string, - agentContext: AgentContext, - chat: AgentChat, - signal: AbortSignal, - ): Promise => { - const agentCfg = bootstrap.config.agent; - const matchedRule = pickMatchingRule(agentContext.rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: 'review', - }); - return runAgentPlanning(pr, userRequest, { - stateStore, - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), - chat, - agentContext, - toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), - matchedRule, - language: getMainLanguage(), - maxSteps: agentCfg.max_steps, - signal, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), - // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); - // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 - recordMemory: async (notes) => { - const dir = effectiveAgentDir(); - for (const kind of ['user', 'memory', 'agents'] as const) { - const added = await appendAgentNotes(dir, kind, notes[kind]).catch((err: unknown) => { - logger.warn({ err, kind }, 'record agent memory failed'); - return [] as string[]; - }); - if (added.length) logger.info({ kind, added }, 'agent memory recorded'); - } - }, - }); - }; - - ipcMain.handle( - 'agent:ask', - async ( - _evt, - req: IpcChannels['agent:ask']['request'], - ): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - const pr = await findPrOrThrow(req.localId); - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 - logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); - try { - const session = await withAgentChat( - (chat) => runPlanningForPr(pr, req.question, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { - prLocalId: pr.localId, - status: session.status, - steps: session.stepCount, - terminationReason: session.terminationReason, - }, - 'agent chat done', - ); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }, - ); - - ipcMain.handle( - 'agent:stop', - (_evt, req: IpcChannels['agent:stop']['request']): IpcChannels['agent:stop']['response'] => { - const ac = agentControllers.get(req.localId); - if (!ac) return { ok: false }; - ac.abort(); - return { ok: true }; - }, - ); - - ipcMain.handle( - 'agent:getSession', - async ( - _evt, - req: IpcChannels['agent:getSession']['request'], - ): Promise => - getAgentSession(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getConversation', - async ( - _evt, - req: IpcChannels['agent:getConversation']['request'], - ): Promise => - getAgentConversation(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getTranscript', - async ( - _evt, - req: IpcChannels['agent:getTranscript']['request'], - ): Promise => - getAgentTranscript(stateStore, req.localId), - ); - - // === AutoPilot 调度(见 docs/arch/06-agent.md「AutoPilot」)=== - // Agent 编排层全局单并发:一次只跑一遍 pass(busy 锁);其派发的工具 run 在共享队列并行。 - // 触发节奏对齐轮询:每个 poller onTick(间隔 = poller.interval_seconds)评估一遍,不再另设独立的最小 - // 间隔守卫——准入门控 + 台账去重已防止重复评审 / 打爆 LLM;busy 锁防止上一遍未完又叠跑。 - let autopilotBusy = false; - const runAutopilotIfDue = (): void => { - const ap = bootstrap.config.agent.autopilot; - if (!ap.enabled || autopilotBusy || !getPrAgentBridge()) { - return; - } - autopilotBusy = true; - void (async () => { - try { - // 候选准入(硬性门控,自上而下): - // 1. 仅「待我评审」分类(discoveryFilters 含 review-requested)下、「待处理」状态(localStatus - // === 'pending')的 PR —— 已通过 / 标记需修改、或非待我评审的一律不自动评审。 - // (不支持发现分类的平台 discoveryFilters 为空 → 不命中,自然不自动触发。) - // 2. 会话中已有 /describe 或 /review 产出(成功 / 正在跑,手动或自动)→ 判定已评审过 / 评审中, - // 不再自动触发(评审失败无产出 → 不算,下轮可重试)。 - // 3. 仅排除「本版本已被判定跳过」的 PR(台账 decision='skipped')——避免对判过 skip 的 PR 反复 - // 重判;无产出又未被 skip 的待评审 PR 一律放行(不再因台账有任意记录就拦下)。 - // 再按 batch_size 截断。 - const prs = await listStoredPullRequests(stateStore); - const candidates: StoredPullRequest[] = []; - // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 - let reviewReqPending = 0; // 命中「待我评审 + 待处理」 - let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 - let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 - for (const pr of prs) { - if (candidates.length >= ap.batch_size) break; - if (!pr.discoveryFilters.includes('review-requested')) continue; - if (pr.localStatus !== 'pending') continue; - reviewReqPending++; - if (await hasReviewOutput(stateStore, pr.localId)) { - alreadyReviewed++; - continue; - } - const ledger = await getAutopilotLedger(stateStore, pr.localId); - if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { - skipDeduped++; - continue; - } - candidates.push(pr); - } - if (candidates.length === 0) { - // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 - logger.info( - { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, - 'autopilot pass: no eligible candidates', - ); - return; - } - - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - await withAgentChat(async (chat) => { - // 批量判定(例外规则来自 AGENTS.md)。 - const { decisions } = await judgeAutopilotBatch(chat, { - candidates: candidates.map((p) => ({ - prLocalId: p.localId, - title: p.title, - description: p.description, - })), - agentsRules: agentContext.files.agents, - }); - const byId = new Map(candidates.map((p) => [p.localId, p] as const)); - // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策待并行编排。 - const toReview: StoredPullRequest[] = []; - for (const d of decisions) { - const pr = byId.get(d.prLocalId); - if (!pr) continue; - if (!d.review) { - // 输出判定 skip 的原因(候选都已过准入闸、非「已评审」,故这里的原因都是 LLM 的领域判定, - // 如分支合并 / 纯依赖升级 — 打出来便于核对「为何没评审这个 PR」)。 - logger.info({ prLocalId: pr.localId, reason: d.reason }, 'autopilot judge skip'); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'skipped', - reason: d.reason, - at: new Date().toISOString(), - }); - continue; - } - toReview.push(pr); - } - // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让工具的并发队列 - // (run-queue maxConcurrency)尽量被填满,而非逐 PR 串行空等。各 PR 写各自的文件,无竞争。 - await Promise.all( - toReview.map(async (pr) => { - // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 - markAgentRunning(pr.localId); - try { - const session = await runReviewForPr(pr, agentContext, chat, undefined, true); - // done:落「评审总结」消息 + 台账(含 verdict)+ 广播会话变更(与手动评审一致)。 - // 失败 / 暂停不落台账 → 无产出,下轮可重试(准入闸 2 用 hasReviewOutput 判,不再靠台账拦)。 - await recordReviewSummaryMessage(pr, session); - } finally { - unmarkAgentRunning(pr.localId); - } - }), - ); - }); - logger.info({ candidates: candidates.length }, 'autopilot pass done'); - } catch (err) { - logger.warn({ err }, 'autopilot pass failed (ignored)'); - } finally { - autopilotBusy = false; - } - })(); - }; - - ipcMain.handle( - 'pragent:cancel', - async ( - _evt, - req: IpcChannels['pragent:cancel']['request'], - ): Promise => { - // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) - const running = active.get(req.runId); - if (running) { - logger.info({ runId: req.runId }, 'pragent run cancel: active'); - running.ac?.abort(); - return { ok: true }; - } - // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) - const idx = waiting.findIndex((q) => q.info.runId === req.runId); - if (idx >= 0) { - const [removed] = waiting.splice(idx, 1); - logger.info({ runId: req.runId, queueLen: waiting.length }, 'pragent run cancel: queued'); - removed!.reject(new Error('queued run cancelled')); - broadcastQueueChanged(); - return { ok: true }; - } - return { ok: false }; - }, - ); - - ipcMain.handle('pragent:queue', (): IpcChannels['pragent:queue']['response'] => ({ - active: [...active.values()].map((q) => q.info), - waiting: waiting.map((q) => q.info), - })); - - ipcMain.handle( - 'pragent:listRuns', - async ( - _evt, - req: IpcChannels['pragent:listRuns']['request'], - ): Promise => - listReviewRunsForPr(stateStore, req.localId, { - limit: req.limit, - beforeId: req.beforeId, - }), - ); - - ipcMain.handle( - 'pragent:getRun', - async ( - _evt, - req: IpcChannels['pragent:getRun']['request'], - ): Promise => - getReviewRun(stateStore, req.localId, req.runId), - ); - ipcMain.handle( - 'pragent:clearRuns', - async ( - _evt, - req: IpcChannels['pragent:clearRuns']['request'], - ): Promise => { - // 清执行历史时一并清掉 Agent 会话(含收尾 summary / 步骤 transcript),否则清空后 - // 重开 PR 仍会从落盘会话恢复出「评审总结」卡片。 - await clearAgentSession(stateStore, req.localId); - // 一并清掉 AutoPilot 台账(评审建议 verdict),并广播 → PR 列表该 PR 的 ★ 徽标即时消失, - // 不残留陈旧评审状态、也不必等下个 poll 重取台账。 - await clearAutopilotLedger(stateStore, req.localId); - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('agent:reviewStatusCleared', { prLocalId: req.localId }); - } - return { cleared: await clearReviewRunsForPr(stateStore, req.localId) }; - }, - ); - - // === M4 草稿 IPC === - // 所有 mutator (create / update / delete) 写盘成功后立刻广播 drafts:changed, - // renderer drafts-store 据此重拉刷新 - - ipcMain.handle( - 'drafts:list', - async ( - _evt, - req: IpcChannels['drafts:list']['request'], - ): Promise => listDrafts(stateStore, req.localId), - ); - - ipcMain.handle( - 'drafts:create', - async ( - _evt, - req: IpcChannels['drafts:create']['request'], - ): Promise => { - // 防御:origin='finding' 必须带 source;origin='manual' 不要 source。 - // 上层 UI 已校验,但 IPC 边界再挡一道避免脏数据进盘 - const { draft, localId } = req; - if (draft.origin === 'finding' && !draft.source) { - throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); - } - if (draft.origin === 'manual' && draft.source) { - throw new Error('drafts:create: origin=manual 不应该传 source'); - } - const created = await createDraft(stateStore, localId, draft); - broadcastDraftsChanged(localId); - return created; - }, - ); - - ipcMain.handle( - 'drafts:update', - async ( - _evt, - req: IpcChannels['drafts:update']['request'], - ): Promise => { - const updated = await updateDraft(stateStore, req.localId, req.draftId, req.patch); - if (updated) broadcastDraftsChanged(req.localId); - return updated; - }, - ); + const ctx = createIpcContext(deps); + // run 队列:pragent:run(PR 域)、Agent 编排、AutoPilot 三方共用。 + const runQueue = createRunQueueService(ctx); + // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 + const orchestrator = createAgentOrchestratorService(ctx, runQueue); - ipcMain.handle( - 'drafts:delete', - async ( - _evt, - req: IpcChannels['drafts:delete']['request'], - ): Promise => { - await deleteDraft(stateStore, req.localId, req.draftId); - broadcastDraftsChanged(req.localId); - }, - ); + registerAppHandlers(ctx); + registerPrHandlers(ctx, runQueue); + registerConfigHandlers(ctx); + registerAgentHandlers(ctx, orchestrator); - ipcMain.handle( - 'drafts:publishBatch', - async ( - _evt, - req: IpcChannels['drafts:publishBatch']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === pr.connectionId, - )?.adapter; - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - - // 拉一次当前草稿池:localId → id → draft,下面遍历 draftIds 时按 id 查。 - // 不在循环里反复 listDrafts,避免 PR 草稿量大时 O(N²) IO - const allDrafts = await listDrafts(stateStore, req.localId); - const draftById = new Map(allDrafts.map((d) => [d.id, d])); - - const results: IpcChannels['drafts:publishBatch']['response']['results'] = []; - let anyPublished = false; - for (const draftId of req.draftIds) { - const draft = draftById.get(draftId); - if (!draft) { - results.push({ draftId, ok: false, error: t('drafts.notFound') }); - continue; - } - // 状态守卫:rejected 不发 (用户决断不发)。 - // posted 不再守卫 — 发布成功后本地草稿直接删除,不存 'posted' 历史状态, - // 调用方传过来的 draftId 在 listDrafts 找不到时已经被前面 `if (!draft)` 兜住 - if (draft.status === 'rejected') { - results.push({ draftId, ok: false, error: t('drafts.rejected') }); - continue; - } - try { - // ReviewDraftAnchor → PrCommentAnchor 转换: - // - draft.anchor 没有 lineType (草稿创建时不知道这一行的 diff 角色), - // 按 side 做保守映射:new→added / old→removed。meebox 的草稿大多锚到 - // 变更行 (finding 来自 /review 的 issue + DraftZone hover '+' 也只对 - // 变更行可见),context 行评论场景极少。命中 context 时 Bitbucket 回 400, - // 错误会被 catch 收到 results 里给用户看 - // - 多行 (endLine > startLine) 在 Bitbucket REST 里无法表达 (anchor.line 是单 - // 行)。落到 endLine 而不是 startLine:评论会出现在标注范围**下方**, - // 不打断用户从上往下阅读时已经看过的代码上下文。renderer 端 DraftZone - // 仍按 startLine 渲染 (跟 finding/AI 建议触发位置一致),发布完远端 - // 评论会自然显示在 endLine —— 这两种位置都不影响"阅读上下文" 的初衷 - const posted = await adapter.publishInlineComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - { - path: draft.anchor.path, - line: draft.anchor.endLine, - side: draft.anchor.side, - lineType: draft.anchor.side === 'old' ? 'removed' : 'added', - }, - draft.body, - ); - // 发布成功 = 本地草稿使命完成,直接删掉保持草稿池干净。远端 Bitbucket 评论 - // 会通过下面的 force-refresh comments 拉回,UI 上由 CommentZone 承接显示, - // 不需要本地再留一份 'posted' 副本造成重复 (跟远端评论 zone 视觉打架) - await deleteDraft(stateStore, req.localId, draftId); - anyPublished = true; - results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - logger.warn( - { localId: req.localId, draftId, err: msg }, - 'drafts:publishBatch: single draft failed', - ); - results.push({ draftId, ok: false, error: msg }); - } - } - - // 整批跑完统一广播 — drafts 列表更新刷 DraftZone status chip + FindingCard - broadcastDraftsChanged(req.localId); - - // 至少有一条发成功 → force-refresh Bitbucket 评论:清缓存 + 广播 comments:changed - // 让 CommentsPanel / DiffView 内嵌评论立即看到自己刚发的,不用等下一轮 poller - if (anyPublished) { - try { - await stateStore.delete(`prs/${pr.localId}/comments`); - } catch { - /* cache miss 无所谓 */ - } - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send('comments:changed', { localId: pr.localId }); - } - } - return { results }; - }, - ); - - ipcMain.handle( - 'config:setReposDir', - async (_evt, req: IpcChannels['config:setReposDir']['request']): Promise => { - const next = { - ...bootstrap.config, - workspace: { - ...bootstrap.config.workspace, - repos_dir: req.reposDir, - }, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); - }, - ); - - ipcMain.handle( - 'config:setLanguage', - async (_evt, req: IpcChannels['config:setLanguage']['request']): Promise => { - const next = { ...bootstrap.config, language: req.language }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 主进程 i18n 即时切换(新 dialog/错误文案与下次 pragent:run 的响应语言随之)。 - bootstrap.config.language = req.language; - setMainLanguage(req.language); - logger.info({ language: req.language }, 'language config updated'); - }, - ); - - ipcMain.handle( - 'config:setAgent', - async (_evt, req: IpcChannels['config:setAgent']['request']): Promise => { - const next = { ...bootstrap.config, agent: req.agent }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.agent = req.agent; - logger.info({ agent: req.agent }, 'agent config updated'); - }, - ); - - ipcMain.handle( - 'agent:setAutopilotEnabled', - async (_evt, req: IpcChannels['agent:setAutopilotEnabled']['request']): Promise => { - const was = bootstrap.config.agent.autopilot.enabled; - const agent = { - ...bootstrap.config.agent, - autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, - }; - await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); - bootstrap.config.agent = agent; - logger.info({ enabled: req.enabled }, 'autopilot toggled'); - // 关 → 开:立即触发一次 poll(刷新 PR 列表 / 状态),其 onTick 即按准入规则评估并按需开评审, - // 不必等下个轮询周期。 - if (req.enabled && !was) { - void poller.tick(); - } - }, - ); - - ipcMain.handle( - 'agent:autopilotLedgers', - async ( - _evt, - req: IpcChannels['agent:autopilotLedgers']['request'], - ): Promise => { - const out: Record = {}; - for (const id of req.localIds) { - const ledger = await getAutopilotLedger(stateStore, id); - if (ledger?.decision === 'review' && ledger.recommendation) { - out[id] = ledger.recommendation; - } - } - return out; - }, - ); - - ipcMain.handle( - 'rules:matchForPr', - async ( - _evt, - req: IpcChannels['rules:matchForPr']['request'], - ): Promise => { - // ask 工具不接规则 (问答自由形式,没什么"规约"可应用) - if (req.tool === 'ask') return null; - const pr = await findPrOrThrow(req.localId); - const rules = await loadAgentRules(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), - }); - const matched = pickMatchingRule(rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: req.tool, - }); - if (!matched) return null; - return { - id: matched.id, - filePath: matched.filePath, - priority: matched.priority, - tools: [...matched.tools], - instructions: matched.instructions, - }; - }, - ); - - ipcMain.handle( - 'config:setLlm', - async (_evt, req: IpcChannels['config:setLlm']['request']): Promise => { - const next = { ...bootstrap.config, llm: req.llm }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存中 config 同步更新,下一次 pragent:run 立刻用新值(不等重启) - bootstrap.config.llm = req.llm; - logger.info( - { - profileCount: req.llm.profiles.length, - activeId: req.llm.active_id, - }, - 'llm config updated', - ); - }, - ); - - ipcMain.handle( - 'config:setConnections', - async (_evt, req: IpcChannels['config:setConnections']['request']): Promise => { - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存 config 同步 + 热重建 adapter/poller,连接变更即时生效(不等重启) - bootstrap.config.connections = req.connections; - bootstrap.config.active_connection_id = req.active_connection_id; - await reconfigureConnections(); - // 立刻 poll 一轮,让启用 / 切换的连接 PR 马上出现(active 为空则空操作) - void poller.tick(); - logger.info( - { count: req.connections.length, activeId: req.active_connection_id }, - 'connections config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:setProxy', - async (_evt, req: IpcChannels['config:setProxy']['request']): Promise => { - const next = { ...bootstrap.config, proxy: req.proxy }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 热重建 adapter(REST fetch 用上新代理);git/pr-agent 出口读最新配置无需重建 - bootstrap.config.proxy = req.proxy; - await reconfigureConnections(); - logger.info( - { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, - 'proxy config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:testProxy', - async ( - _evt, - req: IpcChannels['config:testProxy']['request'], - ): Promise => { - return testProxyConnectivity(req.proxy); - }, - ); - - ipcMain.handle( - 'config:testConnection', - async ( - _evt, - req: IpcChannels['config:testConnection']['request'], - ): Promise => { - // 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason - try { - return await buildDraftAdapter( - req.base_url, - req.token, - bootstrap.config.proxy, - req.kind, - ).ping(); - } catch (e) { - return { ok: false, reason: e instanceof Error ? e.message : String(e) }; - } - }, - ); - - ipcMain.handle( - 'config:autosaveDraft', - async (_evt, req: IpcChannels['config:autosaveDraft']['request']): Promise => { - // 只写 config.yaml(含 base 非编辑字段),**不更新内存 config、不 reconfigure**: - // 持久化防丢失但不生效。重启读文件 或 点底栏「保存」走 config:setConnections/setLlm 才应用。 - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - llm: req.llm, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info( - { connections: req.connections.length, profiles: req.llm.profiles.length }, - 'connections/llm draft autosaved to config.yaml (not applied)', - ); - }, - ); - - ipcMain.handle( - 'config:setPoller', - async (_evt, req: IpcChannels['config:setPoller']['request']): Promise => { - // 防御性 clamp 到 60~900 整数(UI 已限制,这里兜底) - const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); - const next = { - ...bootstrap.config, - poller: { ...bootstrap.config.poller, interval_seconds: seconds }, - }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.poller.interval_seconds = seconds; - poller.setIntervalSeconds(seconds); // 热替换定时器,无需重启 - logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); - }, - ); - - logger.debug('IPC handlers registered'); + ctx.logger.debug('IPC handlers registered'); return { /** @@ -2241,208 +40,10 @@ export function registerIpcHandlers({ * onAbort → killTree(进程树级杀),连带终止 python 及其 litellm 等孙进程,避免孤儿进程锁住 * 安装目录导致升级安装失败。返回被中止的 run 数,供调用方决定是否需要短暂等待 taskkill 跑完。 */ - abortAllActiveRuns: () => { - let n = 0; - for (const item of active.values()) { - item.ac?.abort(); - n++; - } - return n; - }, - /** 每次 poll tick 由 index.ts 调用:满足开关 + 最小间隔 + 候选时跑一遍 AutoPilot pass。 */ - runAutopilotIfDue, + abortAllActiveRuns: () => runQueue.abortAllActiveRuns(), + /** 每次 poll tick 由 index.ts 调用:满足开关 + 候选时跑一遍 AutoPilot pass。 */ + runAutopilotIfDue: () => orchestrator.runAutopilotIfDue(), /** 每次 poll tick 由 index.ts 调用:终止已被移除 / purge 的 PR 上仍在执行的 agent 操作。 */ - terminateAgentsForGonePrs: () => void terminateAgentsForGonePrs(), + terminateAgentsForGonePrs: () => void orchestrator.terminateAgentsForGonePrs(), }; } - -/** - * 把 config.language (ISO locale) 翻成自然语言 prompt directive,注入到 pr-agent - * 各 tool 的 EXTRA_INSTRUCTIONS。 - * - * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt - * template),但 /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格 - * 类输出的标题 / 列名 / 段落标记。 - * - * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 - * pr-agent 原行为。 - */ -// litellm usage 哨兵行前缀(与 sitecustomize.py 的 _emit 保持一致)。 -const USAGE_SENTINEL = '@@MEEBOX_USAGE@@'; - -interface UsageAcc { - prompt: number; - completion: number; - total: number; - calls: number; - any: boolean; -} - -/** - * 解析一行 stderr:若含 usage 哨兵(`@@MEEBOX_USAGE@@ {json}`,sitecustomize 注入)则累加到 - * acc 并返回 true(调用方据此吞掉该行、不转发给 renderer / 不入日志)。普通行返回 false。 - * 坏 JSON 也返回 true(仍吞掉,避免漏进实时日志),只是不计数。容错优先。 - */ -function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { - const i = line.indexOf(USAGE_SENTINEL); - if (i < 0) return false; - try { - const r = JSON.parse(line.slice(i + USAGE_SENTINEL.length).trim()) as { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; - acc.calls += 1; - if (typeof r.prompt_tokens === 'number') { - acc.prompt += r.prompt_tokens; - acc.any = true; - } - if (typeof r.completion_tokens === 'number') { - acc.completion += r.completion_tokens; - acc.any = true; - } - if (typeof r.total_tokens === 'number') { - acc.total += r.total_tokens; - acc.any = true; - } - } catch { - // 坏哨兵行:仍吞掉,不计数 - } - return true; -} - -/** 累加器 → TokenUsage;无任何有效数据返回 undefined(未捕获到,如非 embedded / 流式 / 未调 LLM)。 */ -function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { - if (!acc.any) return undefined; - return { - promptTokens: acc.prompt, - completionTokens: acc.completion, - // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 - totalTokens: acc.total || acc.prompt + acc.completion, - calls: acc.calls, - }; -} - -/** - * 持久化前从 stderr 去掉 usage 哨兵行:onLine 实时已拦截不转发,但 exec 内部把全量 stderr - * 累加进 result.stderr(含哨兵),落盘前清掉这些噪声行。 - */ -function stripUsageSentinels(stderr: string | undefined): string | undefined { - if (!stderr) return stderr; - return stderr - .split('\n') - .filter((l) => !l.includes(USAGE_SENTINEL)) - .join('\n'); -} - -function languageDirectiveFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return 'Respond in Simplified Chinese (简体中文). All section labels, table headers, column names, headings, and content MUST be in Chinese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return 'Respond in Traditional Chinese (繁體中文). All section labels, table headers, column names, headings, and content MUST be in Chinese.'; - } - if (norm.startsWith('ja')) { - return 'Respond in Japanese (日本語). All section labels, table headers, column names, headings, and content MUST be in Japanese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('de')) { - return 'Respond in German (Deutsch). All section labels, table headers, column names, headings, and content MUST be in German — do not leave any English template strings untranslated.'; - } - return ''; -} - -/** - * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 - * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff - * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 - */ -function askLanguageSuffixFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return '请用简体中文回答整个回复(包括所有解释、说明与结论)。代码、标识符、文件路径保留原样,但所有叙述文字必须是简体中文,不要用英文作答。'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return '請用繁體中文回答整個回覆(包括所有解釋、說明與結論)。程式碼、識別符、檔案路徑保留原樣,但所有敘述文字必須是繁體中文,不要用英文作答。'; - } - if (norm.startsWith('ja')) { - return '回答全体を日本語で記述してください(説明・結論を含む)。コード・識別子・ファイルパスはそのまま残し、説明文はすべて日本語にしてください。英語で回答しないでください。'; - } - if (norm.startsWith('de')) { - return 'Bitte antworte vollständig auf Deutsch (einschließlich aller Erklärungen und Schlussfolgerungen). Code, Bezeichner und Dateipfade bleiben unverändert, aber der gesamte erläuternde Text muss auf Deutsch sein. Antworte nicht auf Englisch.'; - } - return ''; -} - -/** - * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。 - * - * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version - * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) - * - canEdit: author.name === 当前 PAT 用户 && 有 version - * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) - * - * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 - * 自己比对 author / version / replies,链路最短最稳。 - */ -function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { - const me = adapter.getCurrentUser(); - if (!me) { - return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); - } - // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 - // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 - const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; - return setOwnershipRecursive(comments, (c) => { - const isMine = c.author.name === me.name; - const hasVersion = typeof c.version === 'number'; - return { - canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), - canEdit: isMine && hasVersion, - }; - }); -} - -function setOwnershipRecursive( - comments: PrComment[], - judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, -): PrComment[] { - return comments.map((c) => { - const flags = judge(c); - return { - ...c, - canDelete: flags.canDelete, - canEdit: flags.canEdit, - replies: setOwnershipRecursive(c.replies, judge), - }; - }); -} - -function buildAppInfo(bootstrap: BootstrapResult): AppInfo { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - nodeVersion: process.versions.node, - platform: process.platform, - firstRun: bootstrap.firstRun, - }; -} - -function buildConnectionSummaries( - bootstrap: BootstrapResult, - adapters: readonly BuiltAdapter[], -): ConnectionSummary[] { - // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 - const activeId = bootstrap.config.active_connection_id; - return adapters - .filter(({ connectionId }) => connectionId === activeId) - .map(({ connectionId, adapter }) => { - const conn = bootstrap.config.connections.find((c) => c.id === connectionId); - return { - connectionId, - displayName: conn?.display_name ?? connectionId, - user: adapter.getCurrentUser(), - capabilities: adapter.capabilities(), - }; - }); -} diff --git a/apps/desktop/src/main/services/agent-orchestrator.ts b/apps/desktop/src/main/services/agent-orchestrator.ts new file mode 100644 index 0000000..a061195 --- /dev/null +++ b/apps/desktop/src/main/services/agent-orchestrator.ts @@ -0,0 +1,434 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + appendAgentNotes, + buildToolCatalog, + judgeAutopilotBatch, + loadAgentContext, +} from '@meebox/agent'; +import type { AgentContext } from '@meebox/agent'; +import { + appendAgentMessage, + getAutopilotLedger, + hasReviewOutput, + listStoredPullRequests, + writeAutopilotLedger, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import type { AgentSession, AgentStep, StoredPullRequest, TokenUsage } from '@meebox/shared'; +import { runAgentPlanning } from '../agent-planning.js'; +import { runAgentReview } from '../agent-review.js'; +import { getMainLanguage, t } from '../i18n/index.js'; +import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; +import { buildProxyEnv } from '../utils/proxy.js'; +import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from './common/usage.js'; +import type { IpcContext } from './context.js'; +import type { RunQueueService } from './run-queue.js'; + +// 共享 chat 通道:system + user → 文本 + usage。agent:run 评审与 AutoPilot 都用。 +type AgentChat = (input: { + system: string; + user: string; +}) => Promise<{ text: string; usage?: TokenUsage }>; + +export interface AgentOrchestratorService { + /** 对指定 PR 跑评审微流程(agent:run):装配上下文 + 注册中止 + 收尾落总结。 */ + runReview(pr: StoredPullRequest): Promise; + /** 对指定 PR 跑自由规划 Agent(agent:ask)。 */ + runPlanning(pr: StoredPullRequest, question: string): Promise; + /** 暂停某 PR 的 Agent 运行(agent:stop)。 */ + stop(localId: string): { ok: boolean }; + /** poll tick:满足开关 + 候选时跑一遍 AutoPilot pass(内部门控)。 */ + runAutopilotIfDue(): void; + /** poll tick:终止已被移除 / purge 的 PR 上仍在执行的 agent 操作。 */ + terminateAgentsForGonePrs(): Promise; +} + +export function createAgentOrchestratorService( + ctx: IpcContext, + runQueue: RunQueueService, +): AgentOrchestratorService { + const { bootstrap, logger, stateStore, getPrAgentBridge, broadcast, effectiveAgentDir } = ctx; + const { enqueuePragentRun, cancelRunsForPr, queuedPrLocalIds } = runQueue; + + /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 + * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ + const withAgentChat = async ( + fn: (chat: AgentChat) => Promise, + signal?: AbortSignal, + ): Promise => { + const bridge = getPrAgentBridge(); + if (!bridge) throw new Error(t('prAgent.notReadyDetail')); + // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。 + const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); + const env: Record = { + ...buildProxyEnv(bootstrap.config.proxy), + ...(activeLlm ? buildPragentEnv(activeLlm) : {}), + CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), + // Agent 编排通道(规划 / 判读 / 收尾 / 对话)是路由 + 轻量综合,非深度代码分析(那在 + // pr-agent /review 里)。本机 CLI 模式下调低推理档(codex: model_reasoning_effort=minimal) + // 提速;仅作用于本 chat spawn,pr-agent 工具 run 的 env 不含此项 → /review 仍满档推理。 + // 非 CLI 模式(API)由 CLI handler 之外的路径处理,该 env 无副作用。 + MEEBOX_CLI_REASONING: 'low', + }; + // chat 子进程落到中性临时目录(cli 模式避免吃到被评审仓库的 CLAUDE.md)。 + const chatCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'meebox-agent-chat-')); + try { + const chat: AgentChat = async ({ system, user }) => { + const r = await bridge.chat({ system, user, env, cwd: chatCwd, signal }); + const acc = newUsageAcc(); + for (const line of (r.stderr ?? '').split('\n')) accumulateUsageSentinel(line, acc); + return { text: r.stdout.trim(), usage: finalizeUsage(acc) }; + }; + return await fn(chat); + } finally { + await fs.rm(chatCwd, { recursive: true, force: true }); + } + }; + + /** + * 每个编排步骤的统一出口:① 后台日志(工具选择 / 判读 / 收尾各落一条,便于排障与离线回看); + * ② 广播给渲染层(agent:stepProgress)做过程化展示。thought / result 截断避免刷屏。 + */ + // 后台日志只留骨架(kind / tool / 用时):thought 与 result(含用户输入 / 总结正文)不入日志, + // 避免刷屏 + 泄漏内容;完整步骤已落 transcript.json,需要时从那里回看。 + const emitAgentStep = (pr: StoredPullRequest, sessionId: string, step: AgentStep): void => { + logger.info( + { + prLocalId: pr.localId, + sessionId, + kind: step.kind, + tool: step.toolCall?.tool, + thinkMs: step.thinkMs, + }, + 'agent step', + ); + broadcast('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); + }; + + // 编排 Agent(手动评审 agent:run + 自由规划 agent:ask)每 PR 至多一个在跑,AbortController 供 + // agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 + const agentControllers = new Map(); + + // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。区别于 + // agentControllers(仅手动可停会话):这里**手动 run/ask 与 AutoPilot 后台评审一并计入**, + // 让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 + const runningAgentPrs = new Set(); + const broadcastAgentRunning = (): void => { + broadcast('agent:runningChanged', { prLocalIds: [...runningAgentPrs] }); + }; + const markAgentRunning = (localId: string): void => { + runningAgentPrs.add(localId); + broadcastAgentRunning(); + }; + const unmarkAgentRunning = (localId: string): void => { + if (runningAgentPrs.delete(localId)) broadcastAgentRunning(); + }; + + /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ + const terminateAgentForPr = (localId: string): void => { + agentControllers.get(localId)?.abort(); + cancelRunsForPr(localId); + }; + + /** + * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 + * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 + */ + const terminateAgentsForGonePrs = async (): Promise => { + const opPrIds = new Set(); + for (const id of agentControllers.keys()) opPrIds.add(id); + for (const id of queuedPrLocalIds()) opPrIds.add(id); + if (opPrIds.size === 0) return; + const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); + for (const id of opPrIds) { + if (!live.has(id)) { + logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); + terminateAgentForPr(id); + } + } + }; + + /** 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。 */ + const runReviewForPr = ( + pr: StoredPullRequest, + agentContext: AgentContext, + chat: AgentChat, + signal?: AbortSignal, + autopilot = false, + ): Promise => { + const agentCfg = bootstrap.config.agent; + const matchedRule = pickMatchingRule(agentContext.rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: 'review', + }); + return runAgentReview(pr, { + stateStore, + // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。 + enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), + chat, + agentContext, + matchedRule, + language: getMainLanguage(), + // 工具目录注入:修改类工具按 grants 门控(默认全禁,红线见 buildToolCatalog)。 + toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), + maxFollowupAsks: agentCfg.autopilot.max_followup_asks, + summaryMaxChars: agentCfg.summary_max_chars, + onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), + signal, + autopilot, + }); + }; + + /** + * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— + * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 + * updatedAt)。台账既给 PR 列表的建议徽标(★,手动 / 自动一视同仁),也供 AutoPilot 同版本去重。 + * 失败 / 用户停止(paused)不落,便于后续重试。 + */ + const recordReviewSummaryMessage = async ( + pr: StoredPullRequest, + session: AgentSession, + ): Promise => { + if (session.status !== 'done' || !session.summary) return; + await appendAgentMessage(stateStore, pr.localId, { + role: 'assistant', + content: session.summary, + recommendation: session.recommendation, + }); + await writeAutopilotLedger(stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'review', + recommendation: session.recommendation?.verdict, + at: new Date().toISOString(), + }); + // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 + broadcast('agent:conversationChanged', { prLocalId: pr.localId }); + }; + + const runReview = async (pr: StoredPullRequest): Promise => { + if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); + // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 + const ac = new AbortController(); + agentControllers.set(pr.localId, ac); + markAgentRunning(pr.localId); + logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); + try { + const session = await withAgentChat( + (chat) => runReviewForPr(pr, agentContext, chat, ac.signal), + ac.signal, + ); + logger.info( + { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, + 'agent review done', + ); + // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 + await recordReviewSummaryMessage(pr, session); + return session; + } finally { + agentControllers.delete(pr.localId); + unmarkAgentRunning(pr.localId); + } + }; + + const runPlanningForPr = ( + pr: StoredPullRequest, + userRequest: string, + agentContext: AgentContext, + chat: AgentChat, + signal: AbortSignal, + ): Promise => { + const agentCfg = bootstrap.config.agent; + const matchedRule = pickMatchingRule(agentContext.rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: 'review', + }); + return runAgentPlanning(pr, userRequest, { + stateStore, + enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), + chat, + agentContext, + toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), + matchedRule, + language: getMainLanguage(), + maxSteps: agentCfg.max_steps, + signal, + onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), + // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); + // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 + recordMemory: async (notes) => { + const dir = effectiveAgentDir(); + for (const kind of ['user', 'memory', 'agents'] as const) { + const added = await appendAgentNotes(dir, kind, notes[kind]).catch((err: unknown) => { + logger.warn({ err, kind }, 'record agent memory failed'); + return [] as string[]; + }); + if (added.length) logger.info({ kind, added }, 'agent memory recorded'); + } + }, + }); + }; + + const runPlanning = async (pr: StoredPullRequest, question: string): Promise => { + if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + const ac = new AbortController(); + agentControllers.set(pr.localId, ac); + markAgentRunning(pr.localId); + // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 + logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); + try { + const session = await withAgentChat( + (chat) => runPlanningForPr(pr, question, agentContext, chat, ac.signal), + ac.signal, + ); + logger.info( + { + prLocalId: pr.localId, + status: session.status, + steps: session.stepCount, + terminationReason: session.terminationReason, + }, + 'agent chat done', + ); + return session; + } finally { + agentControllers.delete(pr.localId); + unmarkAgentRunning(pr.localId); + } + }; + + const stop = (localId: string): { ok: boolean } => { + const ac = agentControllers.get(localId); + if (!ac) return { ok: false }; + ac.abort(); + return { ok: true }; + }; + + // === AutoPilot 调度(见 docs/arch/06-agent.md「AutoPilot」)=== + // Agent 编排层全局单并发:一次只跑一遍 pass(busy 锁);其派发的工具 run 在共享队列并行。 + // 触发节奏对齐轮询:每个 poller onTick(间隔 = poller.interval_seconds)评估一遍,不再另设独立的最小 + // 间隔守卫——准入门控 + 台账去重已防止重复评审 / 打爆 LLM;busy 锁防止上一遍未完又叠跑。 + let autopilotBusy = false; + const runAutopilotIfDue = (): void => { + const ap = bootstrap.config.agent.autopilot; + if (!ap.enabled || autopilotBusy || !getPrAgentBridge()) { + return; + } + autopilotBusy = true; + void (async () => { + try { + // 候选准入(硬性门控,自上而下): + // 1. 仅「待我评审」分类(discoveryFilters 含 review-requested)下、「待处理」状态(localStatus + // === 'pending')的 PR —— 已通过 / 标记需修改、或非待我评审的一律不自动评审。 + // (不支持发现分类的平台 discoveryFilters 为空 → 不命中,自然不自动触发。) + // 2. 会话中已有 /describe 或 /review 产出(成功 / 正在跑,手动或自动)→ 判定已评审过 / 评审中, + // 不再自动触发(评审失败无产出 → 不算,下轮可重试)。 + // 3. 仅排除「本版本已被判定跳过」的 PR(台账 decision='skipped')——避免对判过 skip 的 PR 反复 + // 重判;无产出又未被 skip 的待评审 PR 一律放行(不再因台账有任意记录就拦下)。 + // 再按 batch_size 截断。 + const prs = await listStoredPullRequests(stateStore); + const candidates: StoredPullRequest[] = []; + // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 + let reviewReqPending = 0; // 命中「待我评审 + 待处理」 + let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 + let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 + for (const pr of prs) { + if (candidates.length >= ap.batch_size) break; + if (!pr.discoveryFilters.includes('review-requested')) continue; + if (pr.localStatus !== 'pending') continue; + reviewReqPending++; + if (await hasReviewOutput(stateStore, pr.localId)) { + alreadyReviewed++; + continue; + } + const ledger = await getAutopilotLedger(stateStore, pr.localId); + if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { + skipDeduped++; + continue; + } + candidates.push(pr); + } + if (candidates.length === 0) { + // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 + logger.info( + { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, + 'autopilot pass: no eligible candidates', + ); + return; + } + + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + await withAgentChat(async (chat) => { + // 批量判定(例外规则来自 AGENTS.md)。 + const { decisions } = await judgeAutopilotBatch(chat, { + candidates: candidates.map((p) => ({ + prLocalId: p.localId, + title: p.title, + description: p.description, + })), + agentsRules: agentContext.files.agents, + }); + const byId = new Map(candidates.map((p) => [p.localId, p] as const)); + // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策待并行编排。 + const toReview: StoredPullRequest[] = []; + for (const d of decisions) { + const pr = byId.get(d.prLocalId); + if (!pr) continue; + if (!d.review) { + // 输出判定 skip 的原因(候选都已过准入闸、非「已评审」,故这里的原因都是 LLM 的领域判定, + // 如分支合并 / 纯依赖升级 — 打出来便于核对「为何没评审这个 PR」)。 + logger.info({ prLocalId: pr.localId, reason: d.reason }, 'autopilot judge skip'); + await writeAutopilotLedger(stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'skipped', + reason: d.reason, + at: new Date().toISOString(), + }); + continue; + } + toReview.push(pr); + } + // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让工具的并发队列 + // (run-queue maxConcurrency)尽量被填满,而非逐 PR 串行空等。各 PR 写各自的文件,无竞争。 + await Promise.all( + toReview.map(async (pr) => { + // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 + markAgentRunning(pr.localId); + try { + const session = await runReviewForPr(pr, agentContext, chat, undefined, true); + // done:落「评审总结」消息 + 台账(含 verdict)+ 广播会话变更(与手动评审一致)。 + // 失败 / 暂停不落台账 → 无产出,下轮可重试(准入闸 2 用 hasReviewOutput 判,不再靠台账拦)。 + await recordReviewSummaryMessage(pr, session); + } finally { + unmarkAgentRunning(pr.localId); + } + }), + ); + }); + logger.info({ candidates: candidates.length }, 'autopilot pass done'); + } catch (err) { + logger.warn({ err }, 'autopilot pass failed (ignored)'); + } finally { + autopilotBusy = false; + } + })(); + }; + + return { runReview, runPlanning, stop, runAutopilotIfDue, terminateAgentsForGonePrs }; +} diff --git a/apps/desktop/src/main/services/agent/index.ts b/apps/desktop/src/main/services/agent/index.ts new file mode 100644 index 0000000..c1f8fb0 --- /dev/null +++ b/apps/desktop/src/main/services/agent/index.ts @@ -0,0 +1,122 @@ +import { ipcMain } from 'electron'; +import { loadAgentRules } from '@meebox/agent'; +import type { IpcChannels } from '@meebox/ipc'; +import { + getAgentConversation, + getAgentSession, + getAgentTranscript, + getAutopilotLedger, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import type { AgentRecommendationVerdict } from '@meebox/shared'; +import type { AgentOrchestratorService } from '../agent-orchestrator.js'; +import type { IpcContext } from '../context.js'; + +/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取。 */ +export function registerAgentHandlers( + ctx: IpcContext, + orchestrator: AgentOrchestratorService, +): void { + const { logger, stateStore, findPrOrThrow, effectiveAgentDir } = ctx; + + ipcMain.handle( + 'rules:matchForPr', + async ( + _evt, + req: IpcChannels['rules:matchForPr']['request'], + ): Promise => { + // ask 工具不接规则 (问答自由形式,没什么"规约"可应用) + if (req.tool === 'ask') return null; + const pr = await findPrOrThrow(req.localId); + const rules = await loadAgentRules(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), + }); + const matched = pickMatchingRule(rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: req.tool, + }); + if (!matched) return null; + return { + id: matched.id, + filePath: matched.filePath, + priority: matched.priority, + tools: [...matched.tools], + instructions: matched.instructions, + }; + }, + ); + + ipcMain.handle( + 'agent:run', + async ( + _evt, + req: IpcChannels['agent:run']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + return orchestrator.runReview(pr); + }, + ); + + ipcMain.handle( + 'agent:ask', + async ( + _evt, + req: IpcChannels['agent:ask']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + return orchestrator.runPlanning(pr, req.question); + }, + ); + + ipcMain.handle( + 'agent:stop', + (_evt, req: IpcChannels['agent:stop']['request']): IpcChannels['agent:stop']['response'] => + orchestrator.stop(req.localId), + ); + + ipcMain.handle( + 'agent:getSession', + async ( + _evt, + req: IpcChannels['agent:getSession']['request'], + ): Promise => + getAgentSession(stateStore, req.localId), + ); + + ipcMain.handle( + 'agent:getConversation', + async ( + _evt, + req: IpcChannels['agent:getConversation']['request'], + ): Promise => + getAgentConversation(stateStore, req.localId), + ); + + ipcMain.handle( + 'agent:getTranscript', + async ( + _evt, + req: IpcChannels['agent:getTranscript']['request'], + ): Promise => + getAgentTranscript(stateStore, req.localId), + ); + + ipcMain.handle( + 'agent:autopilotLedgers', + async ( + _evt, + req: IpcChannels['agent:autopilotLedgers']['request'], + ): Promise => { + const out: Record = {}; + for (const id of req.localIds) { + const ledger = await getAutopilotLedger(stateStore, id); + if (ledger?.decision === 'review' && ledger.recommendation) { + out[id] = ledger.recommendation; + } + } + return out; + }, + ); +} diff --git a/apps/desktop/src/main/services/app/index.ts b/apps/desktop/src/main/services/app/index.ts new file mode 100644 index 0000000..f521bea --- /dev/null +++ b/apps/desktop/src/main/services/app/index.ts @@ -0,0 +1,234 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; +import type { BootstrapResult } from '@meebox/config'; +import type { ConnectionSummary, IpcChannels } from '@meebox/ipc'; +import type { AppInfo } from '@meebox/shared'; +import type { BuiltAdapter } from '../../adapters.js'; +import { t } from '../../i18n/index.js'; +import { sniffImageContentType } from '../../utils/image.js'; +import { checkForUpdate } from '../../utils/update-check.js'; +import { getLastUpdateResult, publishUpdateResult } from '../../utils/update-state.js'; +import type { IpcContext } from '../context.js'; + +/** GUI 框架交互域:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像。 */ +export function registerAppHandlers(ctx: IpcContext): void { + const { bootstrap, logger, getPrAgentStatus, connectionRuntime, effectiveAgentDir } = ctx; + + ipcMain.handle('app:info', (): IpcChannels['app:info']['response'] => buildAppInfo(bootstrap)); + ipcMain.handle('app:paths', (): IpcChannels['app:paths']['response'] => bootstrap.paths); + ipcMain.handle( + 'app:prAgentStatus', + (): Promise => getPrAgentStatus(), + ); + + // 渲染层日志回传:落进同一份 meebox.log(scope=renderer),与 main 日志合流便于排查。 + const rendererLogger = logger.child({ scope: 'renderer' }); + ipcMain.handle('log:write', (_evt, req: IpcChannels['log:write']['request']): void => { + const obj = req.meta ?? {}; + switch (req.level) { + case 'error': + rendererLogger.error(obj, req.msg); + break; + case 'warn': + rendererLogger.warn(obj, req.msg); + break; + case 'info': + rendererLogger.info(obj, req.msg); + break; + case 'debug': + rendererLogger.debug(obj, req.msg); + break; + } + }); + + ipcMain.handle('app:connections', (): IpcChannels['app:connections']['response'] => + buildConnectionSummaries(bootstrap, connectionRuntime.adapters), + ); + + // (connectionId, slug) → dataUrl 或 null。两级 cache: + // 1) avatarMem: 进程内 Map,本会话内瞬时返回(含 null 负缓存避免重试失败 slug) + // 2) 磁盘文件 /avatars/.bin,TTL 7 天,按 mtime 判定过期 + // 过期或不存在 → 重新打 Bitbucket → 写回磁盘 + // hash = sha256(connectionId|slug) 前 24 hex,纯字母数字文件名安全 + const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; + const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); + const avatarMem = new Map(); + + ipcMain.handle( + 'app:userAvatar', + async ( + _evt, + req: IpcChannels['app:userAvatar']['request'], + ): Promise => { + const memKey = `${req.connectionId}|${req.slug}`; + if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; + + const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); + const filePath = path.join(avatarDir, `${hash}.bin`); + + // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) + try { + const stat = await fs.stat(filePath); + const age = Date.now() - stat.mtimeMs; + if (age < AVATAR_TTL_MS) { + const bytes = await fs.readFile(filePath); + const contentType = sniffImageContentType(bytes); + const result = { + dataUrl: `data:${contentType};base64,${bytes.toString('base64')}`, + }; + avatarMem.set(memKey, result); + return result; + } + // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) + await fs.unlink(filePath).catch(() => undefined); + } catch { + // 文件不存在 / 读失败 → 走 fetch + } + + // 2) 没缓存 / 已过期:去 Bitbucket 拉 + const adapter = connectionRuntime.adapters.find( + (a) => a.connectionId === req.connectionId, + )?.adapter; + if (!adapter) { + avatarMem.set(memKey, null); + return null; + } + try { + const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); + if (!img) { + logger.debug( + { connectionId: req.connectionId, slug: req.slug }, + 'avatar fetch returned null', + ); + avatarMem.set(memKey, null); + return null; + } + // 落盘:best-effort,写失败不影响响应 + try { + await fs.mkdir(avatarDir, { recursive: true }); + await fs.writeFile(filePath, img.bytes); + } catch (writeErr) { + logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); + } + const base64 = Buffer.from(img.bytes).toString('base64'); + const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; + avatarMem.set(memKey, result); + logger.debug( + { + hash, + slug: req.slug, + bytes: img.bytes.length, + contentType: img.contentType, + }, + 'avatar fetched + cached to disk', + ); + return result; + } catch (err) { + logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); + avatarMem.set(memKey, null); + return null; + } + }, + ); + + ipcMain.handle('app:openConfigFile', async (): Promise => { + const err = await shell.openPath(bootstrap.paths.configFile); + if (err) throw new Error(`failed to open config.yaml: ${err}`); + }); + ipcMain.handle('app:openAgentDir', async (): Promise => { + // 当前生效的 Agent 目录(用户配置优先,否则默认 ~/.code-meeseeks/agent);先确保存在再打开。 + const dir = effectiveAgentDir(); + await fs.mkdir(dir, { recursive: true }).catch(() => undefined); + const err = await shell.openPath(dir); + if (err) throw new Error(`failed to open agent dir: ${err}`); + }); + ipcMain.handle('app:openDevTools', (evt) => { + evt.sender.openDevTools({ mode: 'detach' }); + }); + ipcMain.handle( + 'app:checkUpdate', + async (): Promise => { + // 与启动检测一致受 check_enabled 控制:关闭时不发起请求,直接返回禁用结果。 + if (!bootstrap.config.update.check_enabled) { + return { + ok: false, + hasUpdate: false, + currentVersion: app.getVersion(), + error: 'update check disabled by config', + }; + } + const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); + // 交给单一真相源:缓存 + 有新版则广播到所有窗口(状态栏据此同步,不再只回设置页本地)。 + publishUpdateResult(result); + return result; + }, + ); + ipcMain.handle( + 'app:getUpdateStatus', + (): IpcChannels['app:getUpdateStatus']['response'] => getLastUpdateResult(), + ); + ipcMain.handle( + 'app:openExternal', + async (_evt, req: IpcChannels['app:openExternal']['request']): Promise => { + // 白名单:仅放行 http(s),防止 file:// / javascript: 等被恶意 markdown 注入触发 + if (!/^https?:\/\//.test(req.url)) return; + await shell.openExternal(req.url); + }, + ); + + ipcMain.handle( + 'dialog:pickDirectory', + async ( + evt, + req: IpcChannels['dialog:pickDirectory']['request'], + ): Promise => { + const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; + const result = win + ? await dialog.showOpenDialog(win, { + title: req.title ?? t('dialog.selectDirectory'), + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }) + : await dialog.showOpenDialog({ + title: req.title ?? t('dialog.selectDirectory'), + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null }; + } + return { path: result.filePaths[0]! }; + }, + ); +} + +function buildAppInfo(bootstrap: BootstrapResult): AppInfo { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + nodeVersion: process.versions.node, + platform: process.platform, + firstRun: bootstrap.firstRun, + }; +} + +function buildConnectionSummaries( + bootstrap: BootstrapResult, + adapters: readonly BuiltAdapter[], +): ConnectionSummary[] { + // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 + const activeId = bootstrap.config.active_connection_id; + return adapters + .filter(({ connectionId }) => connectionId === activeId) + .map(({ connectionId, adapter }) => { + const conn = bootstrap.config.connections.find((c) => c.id === connectionId); + return { + connectionId, + displayName: conn?.display_name ?? connectionId, + user: adapter.getCurrentUser(), + capabilities: adapter.capabilities(), + }; + }); +} diff --git a/apps/desktop/src/main/services/common/broadcast.ts b/apps/desktop/src/main/services/common/broadcast.ts new file mode 100644 index 0000000..7e74b80 --- /dev/null +++ b/apps/desktop/src/main/services/common/broadcast.ts @@ -0,0 +1,13 @@ +import { BrowserWindow } from 'electron'; +import type { IpcEvents } from '@meebox/ipc'; + +/** + * 向所有窗口广播一条 main → renderer 推送事件。收口原先散落各处的 + * `for (const win of BrowserWindow.getAllWindows()) win.webContents.send(...)`, + * 并按 IpcEvents 强类型约束 event ↔ payload。 + */ +export function broadcast(event: E, payload: IpcEvents[E]): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(event, payload); + } +} diff --git a/apps/desktop/src/main/services/common/comments-cache.ts b/apps/desktop/src/main/services/common/comments-cache.ts new file mode 100644 index 0000000..afaeef3 --- /dev/null +++ b/apps/desktop/src/main/services/common/comments-cache.ts @@ -0,0 +1,20 @@ +import type { JsonFileStateStore } from '@meebox/state-store'; +import { broadcast } from './broadcast.js'; + +/** + * 清掉某 PR 的评论缓存并广播 `comments:changed`,让 CommentsPanel / DiffView 内嵌评论 + * 重拉刷新。收口 comments reply/delete/edit 与 drafts:publishBatch 共用的同一套链路 + * (清 `prs//comments` 缓存 → 下次 listComments force 拉远端 → 广播触发重拉)。 + * cache miss 无所谓,吞掉异常。 + */ +export async function invalidateCommentsCache( + stateStore: JsonFileStateStore, + localId: string, +): Promise { + try { + await stateStore.delete(`prs/${localId}/comments`); + } catch { + /* cache miss 也无所谓 */ + } + broadcast('comments:changed', { localId }); +} diff --git a/apps/desktop/src/main/services/common/mirror.ts b/apps/desktop/src/main/services/common/mirror.ts new file mode 100644 index 0000000..af67c7c --- /dev/null +++ b/apps/desktop/src/main/services/common/mirror.ts @@ -0,0 +1,78 @@ +import { readDiffBaseCache, writeDiffBaseCache } from '@meebox/poller'; +import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; +import type { StoredPullRequest } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; + +export interface MirrorHelpers { + ensureMirrorReadyForPr( + pr: StoredPullRequest, + ): Promise<{ mirrorPath: string; freshClone: boolean }>; + resolveDiffBaseSha(pr: StoredPullRequest): Promise; +} + +export function createMirrorHelpers(deps: { + repoMirror: RepoMirrorManager; + stateStore: JsonFileStateStore; + repoIdentityFor: (pr: StoredPullRequest) => RepoIdentity; +}): MirrorHelpers { + const { repoMirror, stateStore, repoIdentityFor } = deps; + + /** + * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha + * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR + * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 + * + * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 + * + * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 + * 快速路径命中率应该很高。 + */ + const ensureMirrorReadyForPr = async ( + pr: StoredPullRequest, + ): Promise<{ mirrorPath: string; freshClone: boolean }> => { + const id = repoIdentityFor(pr); + const [hasHead, hasBase] = await Promise.all([ + repoMirror.hasCommit(id, pr.sourceRef.sha), + repoMirror.hasCommit(id, pr.targetRef.sha), + ]); + if (hasHead && hasBase) { + // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log + return { mirrorPath: repoMirror.mirrorPath(id), freshClone: false }; + } + const r = await repoMirror.syncMirror(id); + return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; + }; + + /** + * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 + * + * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, + * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, + * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: + * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; + * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 + * + * 失效重算:固化 base 不再是当前 head 的祖先(源分支被 rebase)→ 重算。head 正常 push(仅前进) + * 不失效。算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 + * + * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 + */ + const resolveDiffBaseSha = async (pr: StoredPullRequest): Promise => { + const id = repoIdentityFor(pr); + const head = pr.sourceRef.sha; + const cached = await readDiffBaseCache(stateStore, pr.localId); + if (cached?.base_sha && (await repoMirror.isAncestor(id, cached.base_sha, head))) { + return cached.base_sha; + } + const mb = await repoMirror.mergeBase(id, pr.targetRef.sha, head); + if (!mb) return pr.targetRef.sha; + await writeDiffBaseCache(stateStore, pr.localId, { + base_sha: mb, + head_sha: head, + computed_at: new Date().toISOString(), + }); + return mb; + }; + + return { ensureMirrorReadyForPr, resolveDiffBaseSha }; +} diff --git a/apps/desktop/src/main/services/common/pr-lookup.ts b/apps/desktop/src/main/services/common/pr-lookup.ts new file mode 100644 index 0000000..387e34c --- /dev/null +++ b/apps/desktop/src/main/services/common/pr-lookup.ts @@ -0,0 +1,53 @@ +import type { BootstrapResult } from '@meebox/config'; +import { listStoredPullRequests } from '@meebox/poller'; +import type { RepoIdentity } from '@meebox/repo-mirror'; +import type { PlatformAdapter, StoredPullRequest } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { ConnectionRuntime } from '../../adapters.js'; + +export interface PrLookup { + /** 按 localId 在状态库定位 PR,找不到抛错(统一错误文案)。 */ + findPrOrThrow(localId: string): Promise; + /** PR → RepoIdentity(host/projectKey/repoSlug),connection 缺失抛错。 */ + repoIdentityFor(pr: StoredPullRequest): RepoIdentity; + /** PR 对应连接的 adapter;连接无 adapter 时返回 undefined。 */ + adapterFor(pr: StoredPullRequest): PlatformAdapter | undefined; + /** 同 adapterFor,但无 adapter 时抛错(绝大多数 handler 走它)。 */ + adapterForOrThrow(pr: StoredPullRequest): PlatformAdapter; +} + +export function createPrLookup(deps: { + bootstrap: BootstrapResult; + stateStore: JsonFileStateStore; + connectionRuntime: ConnectionRuntime; +}): PrLookup { + const { bootstrap, stateStore, connectionRuntime } = deps; + + const findPrOrThrow = async (localId: string): Promise => { + const prs = await listStoredPullRequests(stateStore); + const pr = prs.find((p) => p.localId === localId); + if (!pr) throw new Error(`PR not found in local state: ${localId}`); + return pr; + }; + + const repoIdentityFor = (pr: StoredPullRequest): RepoIdentity => { + const conn = bootstrap.config.connections.find((c) => c.id === pr.connectionId); + if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); + return { + host: new URL(conn.base_url).hostname, + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + }; + }; + + const adapterFor = (pr: StoredPullRequest): PlatformAdapter | undefined => + connectionRuntime.adapters.find((a) => a.connectionId === pr.connectionId)?.adapter; + + const adapterForOrThrow = (pr: StoredPullRequest): PlatformAdapter => { + const adapter = adapterFor(pr); + if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); + return adapter; + }; + + return { findPrOrThrow, repoIdentityFor, adapterFor, adapterForOrThrow }; +} diff --git a/apps/desktop/src/main/services/common/usage.ts b/apps/desktop/src/main/services/common/usage.ts new file mode 100644 index 0000000..5fc6123 --- /dev/null +++ b/apps/desktop/src/main/services/common/usage.ts @@ -0,0 +1,74 @@ +import type { TokenUsage } from '@meebox/shared'; + +// litellm usage 哨兵行前缀(与 sitecustomize.py 的 _emit 保持一致)。 +export const USAGE_SENTINEL = '@@MEEBOX_USAGE@@'; + +export interface UsageAcc { + prompt: number; + completion: number; + total: number; + calls: number; + any: boolean; +} + +/** 新建一个空 usage 累加器。 */ +export function newUsageAcc(): UsageAcc { + return { prompt: 0, completion: 0, total: 0, calls: 0, any: false }; +} + +/** + * 解析一行 stderr:若含 usage 哨兵(`@@MEEBOX_USAGE@@ {json}`,sitecustomize 注入)则累加到 + * acc 并返回 true(调用方据此吞掉该行、不转发给 renderer / 不入日志)。普通行返回 false。 + * 坏 JSON 也返回 true(仍吞掉,避免漏进实时日志),只是不计数。容错优先。 + */ +export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean { + const i = line.indexOf(USAGE_SENTINEL); + if (i < 0) return false; + try { + const r = JSON.parse(line.slice(i + USAGE_SENTINEL.length).trim()) as { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + acc.calls += 1; + if (typeof r.prompt_tokens === 'number') { + acc.prompt += r.prompt_tokens; + acc.any = true; + } + if (typeof r.completion_tokens === 'number') { + acc.completion += r.completion_tokens; + acc.any = true; + } + if (typeof r.total_tokens === 'number') { + acc.total += r.total_tokens; + acc.any = true; + } + } catch { + // 坏哨兵行:仍吞掉,不计数 + } + return true; +} + +/** 累加器 → TokenUsage;无任何有效数据返回 undefined(未捕获到,如非 embedded / 流式 / 未调 LLM)。 */ +export function finalizeUsage(acc: UsageAcc): TokenUsage | undefined { + if (!acc.any) return undefined; + return { + promptTokens: acc.prompt, + completionTokens: acc.completion, + // 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底 + totalTokens: acc.total || acc.prompt + acc.completion, + calls: acc.calls, + }; +} + +/** + * 持久化前从 stderr 去掉 usage 哨兵行:onLine 实时已拦截不转发,但 exec 内部把全量 stderr + * 累加进 result.stderr(含哨兵),落盘前清掉这些噪声行。 + */ +export function stripUsageSentinels(stderr: string | undefined): string | undefined { + if (!stderr) return stderr; + return stderr + .split('\n') + .filter((l) => !l.includes(USAGE_SENTINEL)) + .join('\n'); +} diff --git a/apps/desktop/src/main/services/config/index.ts b/apps/desktop/src/main/services/config/index.ts new file mode 100644 index 0000000..b258cde --- /dev/null +++ b/apps/desktop/src/main/services/config/index.ts @@ -0,0 +1,189 @@ +import { ipcMain } from 'electron'; +import { writeConfig } from '@meebox/config'; +import type { IpcChannels } from '@meebox/ipc'; +import { buildDraftAdapter } from '../../adapters.js'; +import { setMainLanguage } from '../../i18n/index.js'; +import { testProxyConnectivity } from '../../utils/proxy.js'; +import type { IpcContext } from '../context.js'; + +/** 配置操作域:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连。 */ +export function registerConfigHandlers(ctx: IpcContext): void { + const { bootstrap, logger, poller, reconfigureConnections } = ctx; + + ipcMain.handle('config:read', (): IpcChannels['config:read']['response'] => bootstrap.config); + + ipcMain.handle( + 'config:setReposDir', + async (_evt, req: IpcChannels['config:setReposDir']['request']): Promise => { + const next = { + ...bootstrap.config, + workspace: { + ...bootstrap.config.workspace, + repos_dir: req.reposDir, + }, + }; + await writeConfig(bootstrap.paths.configFile, next); + logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); + }, + ); + + ipcMain.handle( + 'config:setLanguage', + async (_evt, req: IpcChannels['config:setLanguage']['request']): Promise => { + const next = { ...bootstrap.config, language: req.language }; + await writeConfig(bootstrap.paths.configFile, next); + // 内存同步 + 主进程 i18n 即时切换(新 dialog/错误文案与下次 pragent:run 的响应语言随之)。 + bootstrap.config.language = req.language; + setMainLanguage(req.language); + logger.info({ language: req.language }, 'language config updated'); + }, + ); + + ipcMain.handle( + 'config:setLlm', + async (_evt, req: IpcChannels['config:setLlm']['request']): Promise => { + const next = { ...bootstrap.config, llm: req.llm }; + await writeConfig(bootstrap.paths.configFile, next); + // 内存中 config 同步更新,下一次 pragent:run 立刻用新值(不等重启) + bootstrap.config.llm = req.llm; + logger.info( + { + profileCount: req.llm.profiles.length, + activeId: req.llm.active_id, + }, + 'llm config updated', + ); + }, + ); + + ipcMain.handle( + 'config:setAgent', + async (_evt, req: IpcChannels['config:setAgent']['request']): Promise => { + const next = { ...bootstrap.config, agent: req.agent }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.agent = req.agent; + logger.info({ agent: req.agent }, 'agent config updated'); + }, + ); + + ipcMain.handle( + 'agent:setAutopilotEnabled', + async (_evt, req: IpcChannels['agent:setAutopilotEnabled']['request']): Promise => { + const was = bootstrap.config.agent.autopilot.enabled; + const agent = { + ...bootstrap.config.agent, + autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, + }; + await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); + bootstrap.config.agent = agent; + logger.info({ enabled: req.enabled }, 'autopilot toggled'); + // 关 → 开:立即触发一次 poll(刷新 PR 列表 / 状态),其 onTick 即按准入规则评估并按需开评审, + // 不必等下个轮询周期。 + if (req.enabled && !was) { + void poller.tick(); + } + }, + ); + + ipcMain.handle( + 'config:setConnections', + async (_evt, req: IpcChannels['config:setConnections']['request']): Promise => { + const next = { + ...bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + }; + await writeConfig(bootstrap.paths.configFile, next); + // 内存 config 同步 + 热重建 adapter/poller,连接变更即时生效(不等重启) + bootstrap.config.connections = req.connections; + bootstrap.config.active_connection_id = req.active_connection_id; + await reconfigureConnections(); + // 立刻 poll 一轮,让启用 / 切换的连接 PR 马上出现(active 为空则空操作) + void poller.tick(); + logger.info( + { count: req.connections.length, activeId: req.active_connection_id }, + 'connections config updated (hot-reloaded)', + ); + }, + ); + + ipcMain.handle( + 'config:setProxy', + async (_evt, req: IpcChannels['config:setProxy']['request']): Promise => { + const next = { ...bootstrap.config, proxy: req.proxy }; + await writeConfig(bootstrap.paths.configFile, next); + // 内存同步 + 热重建 adapter(REST fetch 用上新代理);git/pr-agent 出口读最新配置无需重建 + bootstrap.config.proxy = req.proxy; + await reconfigureConnections(); + logger.info( + { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, + 'proxy config updated (hot-reloaded)', + ); + }, + ); + + ipcMain.handle( + 'config:testProxy', + async ( + _evt, + req: IpcChannels['config:testProxy']['request'], + ): Promise => { + return testProxyConnectivity(req.proxy); + }, + ); + + ipcMain.handle( + 'config:testConnection', + async ( + _evt, + req: IpcChannels['config:testConnection']['request'], + ): Promise => { + // 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason + try { + return await buildDraftAdapter( + req.base_url, + req.token, + bootstrap.config.proxy, + req.kind, + ).ping(); + } catch (e) { + return { ok: false, reason: e instanceof Error ? e.message : String(e) }; + } + }, + ); + + ipcMain.handle( + 'config:autosaveDraft', + async (_evt, req: IpcChannels['config:autosaveDraft']['request']): Promise => { + // 只写 config.yaml(含 base 非编辑字段),**不更新内存 config、不 reconfigure**: + // 持久化防丢失但不生效。重启读文件 或 点底栏「保存」走 config:setConnections/setLlm 才应用。 + const next = { + ...bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + llm: req.llm, + }; + await writeConfig(bootstrap.paths.configFile, next); + logger.info( + { connections: req.connections.length, profiles: req.llm.profiles.length }, + 'connections/llm draft autosaved to config.yaml (not applied)', + ); + }, + ); + + ipcMain.handle( + 'config:setPoller', + async (_evt, req: IpcChannels['config:setPoller']['request']): Promise => { + // 防御性 clamp 到 60~900 整数(UI 已限制,这里兜底) + const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); + const next = { + ...bootstrap.config, + poller: { ...bootstrap.config.poller, interval_seconds: seconds }, + }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.poller.interval_seconds = seconds; + poller.setIntervalSeconds(seconds); // 热替换定时器,无需重启 + logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); + }, + ); +} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts new file mode 100644 index 0000000..de1389f --- /dev/null +++ b/apps/desktop/src/main/services/context.ts @@ -0,0 +1,65 @@ +import type { Logger } from 'pino'; +import type { BootstrapResult } from '@meebox/config'; +import type { PrAgentBridge } from '@meebox/pr-agent-bridge'; +import type { Poller } from '@meebox/poller'; +import type { RepoMirrorManager } from '@meebox/repo-mirror'; +import type { PrAgentStatus } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { ConnectionRuntime } from '../adapters.js'; +import { broadcast } from './common/broadcast.js'; +import { invalidateCommentsCache } from './common/comments-cache.js'; +import { createMirrorHelpers, type MirrorHelpers } from './common/mirror.js'; +import { createPrLookup, type PrLookup } from './common/pr-lookup.js'; + +/** registerIpcHandlers 的外部依赖(由 main/index.ts 注入)。 */ +export interface RegisterDeps { + bootstrap: BootstrapResult; + logger: Logger; + /** 惰性取 pr-agent 探测状态:探测异步进行(不阻塞建窗),await 拿最终结果 */ + getPrAgentStatus: () => Promise; + /** 惰性取 bridge 实例;探测未完成 / 不可用 (embedded / CLI 都没有) 时为 null */ + getPrAgentBridge: () => PrAgentBridge | null; + /** 嵌入式运行时解释器路径(embedded 策略下执行期补 .secrets.toml 用),非 embedded 可空 */ + embeddedPythonPath?: string; + stateStore: JsonFileStateStore; + poller: Poller; + /** 可变连接运行时(全量 adapters + adapterByHost);设置页改连接后被 reconfigure 原地替换 */ + connectionRuntime: ConnectionRuntime; + /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ + reconfigureConnections: () => Promise; + repoMirror: RepoMirrorManager; +} + +/** + * 各 service 共享的运行时上下文:外部依赖 + 收口好的公共工具(广播 / PR 定位 / 镜像 / + * 评论缓存 / Agent 目录)。各域 handler 接收 ctx 即可,避免逐个透传裸 deps。 + */ +export interface IpcContext extends RegisterDeps, PrLookup, MirrorHelpers { + /** 向所有窗口广播 main → renderer 事件(按 IpcEvents 强类型)。 */ + broadcast: typeof broadcast; + /** 清 PR 评论缓存 + 广播 comments:changed。 */ + invalidateCommentsCache(localId: string): Promise; + /** 生效的 Agent 目录:用户配置优先,未配置则回落默认位置(~/.code-meeseeks/agent)。 */ + effectiveAgentDir(): string; +} + +export function createIpcContext(deps: RegisterDeps): IpcContext { + const prLookup = createPrLookup({ + bootstrap: deps.bootstrap, + stateStore: deps.stateStore, + connectionRuntime: deps.connectionRuntime, + }); + const mirror = createMirrorHelpers({ + repoMirror: deps.repoMirror, + stateStore: deps.stateStore, + repoIdentityFor: prLookup.repoIdentityFor, + }); + return { + ...deps, + ...prLookup, + ...mirror, + broadcast, + invalidateCommentsCache: (localId) => invalidateCommentsCache(deps.stateStore, localId), + effectiveAgentDir: () => deps.bootstrap.config.agent.dir || deps.bootstrap.paths.agentDir, + }; +} diff --git a/apps/desktop/src/main/services/pr/index.ts b/apps/desktop/src/main/services/pr/index.ts new file mode 100644 index 0000000..74a3289 --- /dev/null +++ b/apps/desktop/src/main/services/pr/index.ts @@ -0,0 +1,623 @@ +import { ipcMain } from 'electron'; +import type { IpcChannels } from '@meebox/ipc'; +import { + clearAgentSession, + clearAutopilotLedger, + clearReviewRunsForPr, + createDraft, + deleteDraft, + getReviewRun, + isCommentsCacheStale, + listDrafts, + listReviewRunsForPr, + listStoredPullRequests, + readCommentsCache, + setLocalStatus, + updateDraft, + writeCommentsCache, +} from '@meebox/poller'; +import type { RepoIdentity } from '@meebox/repo-mirror'; +import type { PlatformAdapter, PrComment } from '@meebox/shared'; +import { t } from '../../i18n/index.js'; +import type { IpcContext } from '../context.js'; +import type { RunQueueService } from '../run-queue.js'; + +/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列。 */ +export function registerPrHandlers(ctx: IpcContext, runQueue: RunQueueService): void { + const { + bootstrap, + logger, + stateStore, + poller, + repoMirror, + getPrAgentBridge, + broadcast, + findPrOrThrow, + repoIdentityFor, + adapterFor, + adapterForOrThrow, + ensureMirrorReadyForPr, + resolveDiffBaseSha, + invalidateCommentsCache, + } = ctx; + + const broadcastDraftsChanged = (localId: string): void => broadcast('drafts:changed', { localId }); + + // ── 评论 ── + + ipcMain.handle( + 'comments:reply', + async ( + _evt, + req: IpcChannels['comments:reply']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + const reply = await adapter.replyToComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.parentCommentId, + req.body, + ); + // 清掉 comments cache,下次 listComments 会 force 拉远端拿到最新评论树 + // (包括刚 post 的 reply 嵌入到正确父评论 .replies 数组)。同时广播事件让 + // CommentsPanel / DiffView 自动重拉 + await invalidateCommentsCache(pr.localId); + return reply; + }, + ); + + ipcMain.handle( + 'comments:delete', + async ( + _evt, + req: IpcChannels['comments:delete']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + // Bitbucket 在以下情形 409/403: + // - version 跟远端不一致 (用户在别处已编辑) + // - 评论已有回复 (跟 web UI 同步规则) + // - 当前 PAT 不是作者本人 + // 错误体已经在 BitbucketClientError.message 里带,直接抛给 renderer 显示原文 + await adapter.deleteComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + ); + // 跟 reply 同套:清 cache + 广播让 UI 立刻看到评论消失 + await invalidateCommentsCache(pr.localId); + }, + ); + + ipcMain.handle( + 'comments:edit', + async ( + _evt, + req: IpcChannels['comments:edit']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + // Bitbucket 409 (version 不一致) 时 BitbucketClientError.message 会带 "expected version X" + // 这种细节,原样抛给 renderer 显示让用户知道"远端有新版本" + const updated = await adapter.editComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + req.body, + ); + // 清 cache + 广播,UI 重拉刷新 (跟 delete 同套链路)。返回 updated 仅作 + // 调用方乐观参考 — 实际页面渲染走 cache→force-refresh 路径 + await invalidateCommentsCache(pr.localId); + return updated; + }, + ); + + ipcMain.handle( + 'comments:fetchAttachment', + async ( + _evt, + req: IpcChannels['comments:fetchAttachment']['request'], + ): Promise => { + // 找 PR 对应的 connection adapter 拉 attachment。不缓存 — 评论图片重复 + // 加载概率低 (用户决策),每次进入 PR 走 IPC 跟头像走 cache 不同 + try { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterFor(pr); + if (!adapter) return null; + // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL + const res = await adapter.getAttachment(req.url, pr.repo); + if (!res) return null; + const base64 = Buffer.from(res.bytes).toString('base64'); + return { dataUrl: `data:${res.contentType};base64,${base64}` }; + } catch { + return null; + } + }, + ); + + // ── PR 列表 / 状态 / 合并 ── + + ipcMain.handle('prs:list', async (): Promise => { + // 单活动连接模型:只展示当前活动连接的 PR。状态库可能仍存着切换前其他连接的 + // 历史 PR(poller 只轮询活动连接,不会清理旧的),故在出口按 connectionId 过滤。 + const activeId = bootstrap.config.active_connection_id; + const all = await listStoredPullRequests(stateStore); + return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; + }); + ipcMain.handle( + 'prs:refresh', + async (): Promise => poller.tick(), + ); + ipcMain.handle('prs:lastSync', (): IpcChannels['prs:lastSync']['response'] => ({ + at: poller.getLastPollAt(), + })); + ipcMain.handle( + 'prs:setLocalStatus', + async ( + _evt, + req: IpcChannels['prs:setLocalStatus']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + // 先写远端:本地 status → Bitbucket reviewer.status;失败抛出,前端不会看到本地变更 + const remoteStatus = + req.status === 'approved' + ? 'approved' + : req.status === 'needs_work' + ? 'needsWork' + : 'unapproved'; + await adapter.setPullRequestReviewStatus( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + remoteStatus, + ); + // 远端 OK 后落本地,UI 立即反映;下一轮 poll 会取回相同值 + return setLocalStatus(stateStore, req.localId, req.status); + }, + ); + + ipcMain.handle( + 'prs:merge', + async (_evt, req: IpcChannels['prs:merge']['request']): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + // 合并远端;失败 (冲突 / veto / 权限) 抛出,renderer 提示,本地不变。 + // 成功后不在此落本地:PR 转 MERGED 会从 pending 消失,靠 renderer 触发的 + // refresh → poll 软删收尾,避免本地状态与远端各执一词 + await adapter.mergePullRequest( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); + }, + ); + + // ── 镜像 / diff ── + + ipcMain.handle( + 'repo:sync', + async ( + _evt, + req: IpcChannels['repo:sync']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + return ensureMirrorReadyForPr(pr); + }, + ); + + ipcMain.handle( + 'diff:listChangedFiles', + async ( + _evt, + req: IpcChannels['diff:listChangedFiles']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const id = repoIdentityFor(pr); + // 自动确保 mirror 含 head + base sha (快速路径命中即 noop);再算 diff + await ensureMirrorReadyForPr(pr); + // base 锚到固定 merge-base(非漂移的 targetRef.sha),三点 diff 对目标分支前移稳定 + const base = await resolveDiffBaseSha(pr); + return repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); + }, + ); + + ipcMain.handle( + 'diff:getFileContent', + async ( + _evt, + req: IpcChannels['diff:getFileContent']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const id = repoIdentityFor(pr); + // base 侧读固定 merge-base 的内容(与三点 diff 一致),head 侧读源 tip + const sha = req.side === 'base' ? await resolveDiffBaseSha(pr) : pr.sourceRef.sha; + return repoMirror.getFileContent(id, sha, req.path); + }, + ); + + ipcMain.handle( + 'diff:commentCountCached', + async ( + _evt, + req: IpcChannels['diff:commentCountCached']['request'], + ): Promise => { + const cache = await readCommentsCache(stateStore, req.localId); + if (!cache) return null; + return { count: cache.comments.length }; + }, + ); + + // In-flight dedup: 打开 PR 时 MainPane / DiffView / CommentsPanel 三个组件 + // 并行调 listComments(force:true),没去重的话会打 3 次 Bitbucket API。同一 localId + // 的 concurrent 调用合并到同一个 Promise,远端只打一次 + const listCommentsInFlight = new Map>(); + ipcMain.handle( + 'diff:listComments', + async ( + _evt, + req: IpcChannels['diff:listComments']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + // 缓存命中条件:pr_updated_at 跟当前 PR meta updatedAt 一致 → 直接回缓存, + // 不打远端。PR 任何变更 (新评论 / 状态等) Bitbucket 都会更新 updatedAt,跳变即重拉。 + // + // **req.force=true** 跳过 cache 直接打远端 — 本地 PR.updatedAt 来自 poller + // 周期拉,可能滞后,stale 比对会误判命中。打开 PR 时 renderer 传 force=true + // 强制刷新,确保拿到最新评论 + const cache = await readCommentsCache(stateStore, pr.localId); + if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { + return cache.comments; + } + // dedup:同 localId 的 in-flight Promise 直接复用 + const existing = listCommentsInFlight.get(pr.localId); + if (existing) return existing; + const adapter = adapterForOrThrow(pr); + const fetchPromise = adapter + .listPullRequestComments( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ) + .then((raw) => annotateOwnership(raw, adapter)) + .then(async (fresh) => { + await writeCommentsCache(stateStore, pr.localId, { + comments: fresh, + pr_updated_at: pr.updatedAt, + fetched_at: new Date().toISOString(), + }); + return fresh; + }) + .finally(() => { + listCommentsInFlight.delete(pr.localId); + }); + listCommentsInFlight.set(pr.localId, fetchPromise); + return fetchPromise; + }, + ); + + ipcMain.handle( + 'diff:listCommits', + async ( + _evt, + req: IpcChannels['diff:listCommits']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + // commits 不缓存(量少 + UI 进 commits 标签页才拉,频率低);后续如发现频繁拉 + // 再补 prs//commits.json 缓存层 (走 pr_updated_at 失效,跟 comments 同模式) + return adapter.listPullRequestCommits( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); + }, + ); + + ipcMain.handle( + 'diff:commitCount', + async ( + _evt, + req: IpcChannels['diff:commitCount']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const id = repoIdentityFor(pr); + // 本地 git 算提交数;不打远端、不主动触发 sync。镜像还没拉齐就返回 null, + // UI 角标暂不显示,等下次 poll 触发 syncMirror 完成后自然命中。 + // 口径 = PR 自身提交(源分支「不在目标分支上」的非 merge 提交),对齐平台 /commits 列表。 + // **基准用目标分支 sha(head ^target)而非固定 merge-base**:源分支把目标分支(如 dev)合入自己后, + // merge-base 之后被带进来的目标提交也可达 head、不可达 merge-base → 用 merge-base 会把它们误计 + // (标 31 实则 2)。以 targetRef.sha 排除这些合入提交;merge 提交本身由 countCommits 的 --no-merges 略去。 + // (diff 仍用固定 merge-base 保稳定,与本计数口径各司其职。) + const n = await repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); + return n === null ? null : { count: n }; + }, + ); + + ipcMain.handle( + 'diff:getBlame', + async ( + _evt, + req: IpcChannels['diff:getBlame']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const id = repoIdentityFor(pr); + // 只对 base 已有部分展示 blame;PR 引入的行单独返给 renderer, + // 由 BlameColumn 画色带占位(对应 Monaco diff 添加/修改区的视觉)。 + const base = await resolveDiffBaseSha(pr); + const [allBlame, changedSet] = await Promise.all([ + repoMirror.getBlame(id, pr.sourceRef.sha, req.path), + repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path), + ]); + return { + lines: allBlame.filter((b) => !changedSet.has(b.line)), + changedLines: Array.from(changedSet).sort((a, b) => a - b), + }; + }, + ); + + ipcMain.handle('repo:getTotalSize', async (): Promise<{ totalBytes: number }> => { + const prs = await listStoredPullRequests(stateStore); + const seen = new Set(); + let total = 0; + for (const pr of prs) { + let id: RepoIdentity; + try { + id = repoIdentityFor(pr); + } catch { + continue; + } + const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; + if (seen.has(key)) continue; + seen.add(key); + const r = await repoMirror.getSize(id); + total += r.totalBytes; + } + return { totalBytes: total }; + }); + + // ── pr-agent run 队列 ── + + ipcMain.handle( + 'pragent:run', + async ( + _evt, + req: IpcChannels['pragent:run']['request'], + ): Promise => { + if (!getPrAgentBridge()) { + throw new Error(t('prAgent.notReadyDetail')); + } + // 早期校验:/ask 必须带 question,避免排队后才报错 + if (req.tool === 'ask' && !req.question?.trim()) { + throw new Error(t('prAgent.askNeedsQuestion')); + } + const pr = await findPrOrThrow(req.localId); + return runQueue.enqueuePragentRun(pr, req.tool, req.question); + }, + ); + + ipcMain.handle( + 'pragent:cancel', + (_evt, req: IpcChannels['pragent:cancel']['request']): IpcChannels['pragent:cancel']['response'] => + runQueue.cancel(req.runId), + ); + + ipcMain.handle('pragent:queue', (): IpcChannels['pragent:queue']['response'] => runQueue.snapshot()); + + ipcMain.handle( + 'pragent:listRuns', + async ( + _evt, + req: IpcChannels['pragent:listRuns']['request'], + ): Promise => + listReviewRunsForPr(stateStore, req.localId, { + limit: req.limit, + beforeId: req.beforeId, + }), + ); + + ipcMain.handle( + 'pragent:getRun', + async ( + _evt, + req: IpcChannels['pragent:getRun']['request'], + ): Promise => + getReviewRun(stateStore, req.localId, req.runId), + ); + + ipcMain.handle( + 'pragent:clearRuns', + async ( + _evt, + req: IpcChannels['pragent:clearRuns']['request'], + ): Promise => { + // 清执行历史时一并清掉 Agent 会话(含收尾 summary / 步骤 transcript),否则清空后 + // 重开 PR 仍会从落盘会话恢复出「评审总结」卡片。 + await clearAgentSession(stateStore, req.localId); + // 一并清掉 AutoPilot 台账(评审建议 verdict),并广播 → PR 列表该 PR 的 ★ 徽标即时消失, + // 不残留陈旧评审状态、也不必等下个 poll 重取台账。 + await clearAutopilotLedger(stateStore, req.localId); + broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); + return { cleared: await clearReviewRunsForPr(stateStore, req.localId) }; + }, + ); + + // ── M4 草稿 ── + // 所有 mutator (create / update / delete) 写盘成功后立刻广播 drafts:changed, + // renderer drafts-store 据此重拉刷新 + + ipcMain.handle( + 'drafts:list', + async ( + _evt, + req: IpcChannels['drafts:list']['request'], + ): Promise => listDrafts(stateStore, req.localId), + ); + + ipcMain.handle( + 'drafts:create', + async ( + _evt, + req: IpcChannels['drafts:create']['request'], + ): Promise => { + // 防御:origin='finding' 必须带 source;origin='manual' 不要 source。 + // 上层 UI 已校验,但 IPC 边界再挡一道避免脏数据进盘 + const { draft, localId } = req; + if (draft.origin === 'finding' && !draft.source) { + throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); + } + if (draft.origin === 'manual' && draft.source) { + throw new Error('drafts:create: origin=manual 不应该传 source'); + } + const created = await createDraft(stateStore, localId, draft); + broadcastDraftsChanged(localId); + return created; + }, + ); + + ipcMain.handle( + 'drafts:update', + async ( + _evt, + req: IpcChannels['drafts:update']['request'], + ): Promise => { + const updated = await updateDraft(stateStore, req.localId, req.draftId, req.patch); + if (updated) broadcastDraftsChanged(req.localId); + return updated; + }, + ); + + ipcMain.handle( + 'drafts:delete', + async ( + _evt, + req: IpcChannels['drafts:delete']['request'], + ): Promise => { + await deleteDraft(stateStore, req.localId, req.draftId); + broadcastDraftsChanged(req.localId); + }, + ); + + ipcMain.handle( + 'drafts:publishBatch', + async ( + _evt, + req: IpcChannels['drafts:publishBatch']['request'], + ): Promise => { + const pr = await findPrOrThrow(req.localId); + const adapter = adapterForOrThrow(pr); + + // 拉一次当前草稿池:localId → id → draft,下面遍历 draftIds 时按 id 查。 + // 不在循环里反复 listDrafts,避免 PR 草稿量大时 O(N²) IO + const allDrafts = await listDrafts(stateStore, req.localId); + const draftById = new Map(allDrafts.map((d) => [d.id, d])); + + const results: IpcChannels['drafts:publishBatch']['response']['results'] = []; + let anyPublished = false; + for (const draftId of req.draftIds) { + const draft = draftById.get(draftId); + if (!draft) { + results.push({ draftId, ok: false, error: t('drafts.notFound') }); + continue; + } + // 状态守卫:rejected 不发 (用户决断不发)。 + // posted 不再守卫 — 发布成功后本地草稿直接删除,不存 'posted' 历史状态, + // 调用方传过来的 draftId 在 listDrafts 找不到时已经被前面 `if (!draft)` 兜住 + if (draft.status === 'rejected') { + results.push({ draftId, ok: false, error: t('drafts.rejected') }); + continue; + } + try { + // ReviewDraftAnchor → PrCommentAnchor 转换: + // - draft.anchor 没有 lineType (草稿创建时不知道这一行的 diff 角色), + // 按 side 做保守映射:new→added / old→removed。meebox 的草稿大多锚到 + // 变更行 (finding 来自 /review 的 issue + DraftZone hover '+' 也只对 + // 变更行可见),context 行评论场景极少。命中 context 时 Bitbucket 回 400, + // 错误会被 catch 收到 results 里给用户看 + // - 多行 (endLine > startLine) 在 Bitbucket REST 里无法表达 (anchor.line 是单 + // 行)。落到 endLine 而不是 startLine:评论会出现在标注范围**下方**, + // 不打断用户从上往下阅读时已经看过的代码上下文。renderer 端 DraftZone + // 仍按 startLine 渲染 (跟 finding/AI 建议触发位置一致),发布完远端 + // 评论会自然显示在 endLine —— 这两种位置都不影响"阅读上下文" 的初衷 + const posted = await adapter.publishInlineComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + { + path: draft.anchor.path, + line: draft.anchor.endLine, + side: draft.anchor.side, + lineType: draft.anchor.side === 'old' ? 'removed' : 'added', + }, + draft.body, + ); + // 发布成功 = 本地草稿使命完成,直接删掉保持草稿池干净。远端 Bitbucket 评论 + // 会通过下面的 force-refresh comments 拉回,UI 上由 CommentZone 承接显示, + // 不需要本地再留一份 'posted' 副本造成重复 (跟远端评论 zone 视觉打架) + await deleteDraft(stateStore, req.localId, draftId); + anyPublished = true; + results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logger.warn( + { localId: req.localId, draftId, err: msg }, + 'drafts:publishBatch: single draft failed', + ); + results.push({ draftId, ok: false, error: msg }); + } + } + + // 整批跑完统一广播 — drafts 列表更新刷 DraftZone status chip + FindingCard + broadcastDraftsChanged(req.localId); + + // 至少有一条发成功 → force-refresh Bitbucket 评论:清缓存 + 广播 comments:changed + // 让 CommentsPanel / DiffView 内嵌评论立即看到自己刚发的,不用等下一轮 poller + if (anyPublished) { + await invalidateCommentsCache(pr.localId); + } + return { results }; + }, + ); +} + +/** + * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。 + * + * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version + * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) + * - canEdit: author.name === 当前 PAT 用户 && 有 version + * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) + * + * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 + * 自己比对 author / version / replies,链路最短最稳。 + */ +function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { + const me = adapter.getCurrentUser(); + if (!me) { + return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); + } + // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 + // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 + const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; + return setOwnershipRecursive(comments, (c) => { + const isMine = c.author.name === me.name; + const hasVersion = typeof c.version === 'number'; + return { + canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), + canEdit: isMine && hasVersion, + }; + }); +} + +function setOwnershipRecursive( + comments: PrComment[], + judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, +): PrComment[] { + return comments.map((c) => { + const flags = judge(c); + return { + ...c, + canDelete: flags.canDelete, + canEdit: flags.canEdit, + replies: setOwnershipRecursive(c.replies, judge), + }; + }); +} diff --git a/apps/desktop/src/main/services/run-queue.ts b/apps/desktop/src/main/services/run-queue.ts new file mode 100644 index 0000000..76614d3 --- /dev/null +++ b/apps/desktop/src/main/services/run-queue.ts @@ -0,0 +1,745 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { loadAgentRules } from '@meebox/agent'; +import type { PragentRunInfo } from '@meebox/ipc'; +import { PrAgentRunError } from '@meebox/pr-agent-bridge'; +import { + dropPendingFindingDrafts, + finishReviewRun, + makeRunId, + parseReviewOutput, + startReviewRun, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import type { + ReviewRun, + ReviewRunStatus, + ReviewRunTool, + StoredPullRequest, +} from '@meebox/shared'; +import { getMainLanguage, t } from '../i18n/index.js'; +import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; +import { buildPrContext } from '../utils/pr-context.js'; +import { buildProxyEnv } from '../utils/proxy.js'; +import type { IpcContext } from './context.js'; +import { + accumulateUsageSentinel, + finalizeUsage, + newUsageAcc, + stripUsageSentinels, +} from './common/usage.js'; + +/** pr-agent run 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ +export type RunPriority = 'user' | 'agent'; + +export interface RunQueueService { + /** + * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR + * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 + */ + enqueuePragentRun( + pr: StoredPullRequest, + tool: ReviewRunTool, + question?: string, + priority?: RunPriority, + ): Promise; + /** 取消一个 run(pragent:cancel):active→SIGKILL;waiting→出队 + reject;都不匹配→ok:false。 */ + cancel(runId: string): { ok: boolean }; + /** 当前队列快照(pragent:queue / 广播用)。 */ + snapshot(): { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; + /** 取消某 PR 的全部 run:active 的 SIGKILL,waiting 的出队 + reject。 */ + cancelRunsForPr(localId: string): void; + /** active + waiting 涉及的 PR localId 集合(terminateAgentsForGonePrs 用)。 */ + queuedPrLocalIds(): string[]; + /** 应用退出时中止所有进行中的 run,返回被中止的 run 数。 */ + abortAllActiveRuns(): number; +} + +export function createRunQueueService(ctx: IpcContext): RunQueueService { + const { + bootstrap, + logger, + getPrAgentBridge, + embeddedPythonPath, + stateStore, + repoMirror, + broadcast, + adapterFor, + repoIdentityFor, + resolveDiffBaseSha, + effectiveAgentDir, + } = ctx; + + // === pr-agent run 队列 === + // + // FIFO 队列,同时只有 1 条在跑 (避免撞 LLM rate limit / 抢 worktree), + // 其余在 waiting 排队。每次 active 完成 / 取消 → 自动开下一条。 + // + // 设计要点: + // - runId 在入队时就分配 (跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 + // active / waiting 两种状态都能精确定位 + // - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact + // - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent + // - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 + interface QueueItem { + info: PragentRunInfo; + req: { localId: string; tool: ReviewRunTool; question?: string }; + pr: StoredPullRequest; + resolve: (run: ReviewRun) => void; + reject: (err: Error) => void; + /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。见 §7 调度。 */ + priority: RunPriority; + /** 仅 active 状态填;用于 cancel SIGKILL */ + ac?: AbortController; + } + const waiting: QueueItem[] = []; + // 并发运行中的 run(runId → item);上限 maxConcurrency。post-Docker 下每个 run + // 独立 worktree(路径带 nonce)+ 独立子进程,并发安全;串行不再是正确性要求。 + const active = new Map(); + const maxConcurrency = bootstrap.config.pr_agent.max_concurrency; + + const snapshot = (): { active: PragentRunInfo[]; waiting: PragentRunInfo[] } => ({ + active: [...active.values()].map((q) => q.info), + waiting: waiting.map((q) => q.info), + }); + + const broadcastQueueChanged = (): void => { + broadcast('pragent:queueChanged', snapshot()); + }; + + // /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), + // 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 + const stripAskQuestionEcho = (md: string, ...echoed: string[]): string => { + const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); + if (!qs.size || !md) return md; + return md + .split('\n') + .filter((line) => !qs.has(line.trim())) + .join('\n'); + }; + + // embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 + // .secrets.toml(pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥 + // 不用 secrets.toml,写个空文件压掉告警)。 + // memo 化:只在首个 embedded run 解析一次 pr_agent 目录 + 写文件,后续直接复用。 + // importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 + const execFileP = promisify(execFile); + let embeddedSecretsEnsured: Promise | null = null; + const ensureEmbeddedSecrets = (pythonPath: string): Promise => { + embeddedSecretsEnsured ??= (async () => { + const { stdout } = await execFileP(pythonPath, [ + '-c', + "import importlib.util,os;print(os.path.dirname(importlib.util.find_spec('pr_agent').origin))", + ]); + const prAgentDir = stdout.trim(); + for (const sub of ['settings', 'settings_prod']) { + const dir = path.join(prAgentDir, sub); + await fs.mkdir(dir, { recursive: true }); + const f = path.join(dir, '.secrets.toml'); + try { + await fs.access(f); + } catch { + await fs.writeFile( + f, + '# meebox placeholder: silence pr-agent warning about a missing .secrets.toml\n', + ); + } + } + })().catch((err: unknown) => { + logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); + }); + return embeddedSecretsEnsured; + }; + + /** + * 真正执行一个 queue item:startReviewRun → worktree → bridge.run → finishWith。 + * 由 pump() 调用,签名稳定后跟 queue 主体解耦;任何抛错都被 pump 兜成 + * Promise reject,外层 pragent:run 调用方收到。 + */ + const executeRun = async (item: QueueItem): Promise => { + const prAgentBridge = getPrAgentBridge(); + if (!prAgentBridge) throw new Error(t('prAgent.notReady')); + const { req, pr } = item; + // 提前 resolve active LLM profile — model 字段要随 startReviewRun 一起落 + // 盘,让 UI 在 meta 行展示"这次 run 用的什么模型"。后面 buildPragentEnv + // 同样会用到,这里 resolve 一次复用 + const activeLlmForRecord = resolveActiveLlmProfile(bootstrap.config.llm); + // 用入队预分配的 runId 覆盖 startReviewRun 的自生 id,让 cancel(runId) 在 active + // 状态也能精确定位 (跟入队时给的 runId 一致) + const run = await startReviewRun(stateStore, { + id: item.info.runId, + prLocalId: pr.localId, + tool: req.tool, + question: req.tool === 'ask' ? req.question : undefined, + prAgentVersion: prAgentBridge.version, + strategy: prAgentBridge.strategy, + // 持久化用 profile.model 原文,不做 normalizeModel 前缀处理 — 跟用户 + // Settings 里看到的名字一致更直观 + model: activeLlmForRecord?.model || undefined, + }); + // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播 + item.info = { ...item.info, startedAt: run.startedAt }; + broadcastQueueChanged(); + logger.info( + { runId: run.id, localId: pr.localId, tool: req.tool, strategy: prAgentBridge.strategy }, + 'pragent run start', + ); + const t0 = Date.now(); + // 真实 token 用量累加器:sitecustomize 的 litellm callback 把每次调用的 usage 以 + // `@@MEEBOX_USAGE@@ {json}` 哨兵行打到 stderr,下面 onLine 拦截累加(无需临时文件 / env)。 + const usageAcc = newUsageAcc(); + const onLine = (line: string, stream: 'stdout' | 'stderr'): void => { + // 拦截 usage 哨兵行:累加后不转发给 renderer(避免污染实时日志)。 + if (stream === 'stderr' && accumulateUsageSentinel(line, usageAcc)) return; + broadcast('pragent:runProgress', { runId: run.id, line, stream }); + }; + + const finishWith = async (patch: Parameters[3]): Promise => { + const updated = await finishReviewRun(stateStore, pr.localId, run.id, patch); + return updated ?? { ...run, ...patch }; + }; + + const repoId = repoIdentityFor(pr); + await repoMirror.syncMirror(repoId); + // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base(与 UI diff 同源):让 AI 评审基于 + // 「PR 自分叉后引入的改动」,而非 targetRef.sha 漂移后混入别的 PR 的两点对比 + const diffBase = await resolveDiffBaseSha(pr); + const wt = await repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); + const ac = item.ac!; + try { + const activeLlm = resolveActiveLlmProfile(bootstrap.config.llm); + // LLM env + 全局 pr-agent 配置 (响应语言)。语言配置一期写死在 config 里, + // UI 还不暴露切换;后续多语言时改成 Settings 入口 + const env: Record = { + // 代理 env 先铺底,LLM/语言配置在后(互不冲突,仅 HTTP(S)_PROXY 类)。 + // 开关开时让嵌入式 python(litellm/httpx) 经代理出网调 LLM。 + ...buildProxyEnv(bootstrap.config.proxy), + ...(activeLlm ? buildPragentEnv(activeLlm) : {}), + CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), + }; + if (req.tool === 'improve') { + // /improve 在 local provider 下只有「汇总建议 → publish_comment」一条可用路径 + // (shim 已强制 gfm_markdown=True)。committable/inline 模式会走 + // publish_code_suggestions → local provider 直接 NotImplementedError,显式关死兜底 + // (pr-agent 默认即 false,此处防上游翻默认值)。 + env['PR_CODE_SUGGESTIONS__COMMITABLE_CODE_SUGGESTIONS'] = 'false'; + // persistent_comment(默认 true)会走 publish_persistent_comment_with_history → + // get_issue_comments() 翻历史评论做增量更新 → local provider 不实现,每次 improve + // 都刷一段 NotImplementedError traceback(被上游捕获后兜底 publish_comment,正文 + // 不丢但日志吵)。local 每次都是全新 worktree、无历史可翻,直接关掉走 publish_comment。 + env['PR_CODE_SUGGESTIONS__PERSISTENT_COMMENT'] = 'false'; + // 输出与 /review /ask 的 review.md 分流:pr-agent 原生支持 local.review_path 覆盖 + // publish_comment 的落盘路径;相对路径按子进程 cwd(= worktree 根)解析。 + env['LOCAL__REVIEW_PATH'] = 'improve.md'; + } + + // 注给 pr-agent 的 EXTRA_INSTRUCTIONS 由三部分按顺序拼接: + // 1. 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 + // /ask 走 [pr_questions] 配置段不那么严格遵守,必须显式 prompt 强化 + // 2. PR 上下文 (title / description / 已有评论):local provider 自己不会 + // 去 Bitbucket 拉这些,必须我们这边喂;让 /describe /review 不只是看 diff + // 3. 规则正文 (rules.dir 命中):项目编码规约 + // /ask 只取 1 (语言),跳 2/3 (用户问题往往跟历史评论 / 规约无关) + const langDirective = languageDirectiveFor(getMainLanguage()); + let prContext = ''; + let matchedRuleInstructions = ''; + let matchedRuleId: string | undefined; + if (req.tool !== 'ask') { + const adapter = adapterFor(pr); + if (adapter) { + try { + prContext = await buildPrContext({ pr, adapter, logger }); + } catch (err) { + logger.warn( + { err, runId: run.id, localId: pr.localId }, + 'buildPrContext threw; proceeding without PR context', + ); + } + } + + const rules = await loadAgentRules(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), + }); + const matched = pickMatchingRule(rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: req.tool, + }); + if (matched) { + matchedRuleInstructions = matched.instructions; + matchedRuleId = matched.id; + } + } + + // anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 + // [file: , lines: -] + // + // 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 + // `[**header**](meebox:///#L-L)`,parse-output 取结构化 anchor(path 来自 + // provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/ + // end_line YAML 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。 + // 故这条 marker 作为**行号兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker + // 的行号补(resolveIssueAnchor)。两路信号都用上,最大化 anchor 覆盖。 + // + // 两种工具措辞不同: + // - /review: 每条 key_issue 末尾 **必加** marker + // - /ask: 仅当回答涉及具体文件 / 代码位置时 **才加** (自由问答可能完全跟代码 + // 无关 e.g. "PR 概述"),强制会产出假阳性 + // + // /describe / /improve 不注入:前者不出 issue,后者走 marker 行 + // `[file [start-end]](url)` 自己有 anchor + const reviewAnchorDirective = + req.tool === 'review' + ? [ + 'When writing each item under `key_issues_to_review`, append on its OWN LAST LINE', + 'a machine-readable anchor marker in this EXACT format:', + '', + ' [file: , lines: -]', + '', + 'Examples:', + ' [file: src/auth/login.ts, lines: 42-50]', + ' [file: pkg/cache.go, lines: 17]', + '', + 'Use the exact relevant_file path and start_line/end_line you already', + 'identified in the YAML output. Do NOT wrap the path in backticks. If you', + 'truly cannot identify a file/line for an issue, omit the marker for that', + 'item only.', + ].join('\n') + : req.tool === 'ask' + ? [ + 'CRITICAL: This answer is consumed by a code review GUI that converts your', + 'per-paragraph recommendations into INLINE COMMENTS pinned to specific code', + 'lines. For that to work, EVERY paragraph that names a code symbol (function,', + 'method, class, variable, identifier) from this PR MUST end with a', + 'machine-readable anchor marker on its OWN LAST LINE:', + '', + ' [file: , lines: -]', + '', + 'Examples:', + ' [file: src/auth/login.ts, lines: 42-50]', + ' [file: pkg/cache.go, lines: 17]', + ' [file: pkg/store.ts] (path-only fallback; only when you', + ' truly cannot infer any line number)', + '', + 'How to derive line numbers from the diff:', + '- Every hunk in the diff begins with a header:', + ' @@ -, +, @@', + ' The number after `+` is the FIRST head-side line of that hunk. Count down', + ' through `+` (added) and ` ` (context) lines — DO NOT count `-` (removed)', + ' lines — to locate the line where the symbol appears. Prefer head-side', + ' line numbers. For code that ONLY exists on the base side (purely removed),', + ' use the base-side `-` line number instead.', + '', + 'Rules — read carefully:', + '- The marker is REQUIRED. Do not skip it when your paragraph references a', + ' real code symbol from the diff. A paragraph without a marker becomes', + ' un-pinnable feedback the user cannot turn into a comment.', + '- Append exactly ONE marker per paragraph, at the very end of that paragraph,', + ' on its own line (blank line above it optional but recommended).', + '- If a paragraph discusses multiple locations, pick the most important one', + ' (the line where the recommended change should be made).', + '- Paragraphs that are purely general / conceptual / meta (e.g., overall', + ' praise, no specific symbol named) MAY omit the marker.', + '- Use the exact file path from the diff. Do NOT wrap the path in backticks', + ' or quotes inside the marker.', + '- If you really cannot pin a line, fall back to path-only `[file: ]`', + ' rather than omitting the marker entirely.', + ].join('\n') + : ''; + + // 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 + // pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文; + // 渲染层 (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立

。 + // 关键是「保持简洁」——只在现象/影响/建议的语义边界换行,不得借分段扩写内容。 + // 须与上面的 anchor marker 指令协同:分段在正文内部,marker 仍独占最末行。 + const reviewLayoutDirective = + req.tool === 'review' + ? [ + 'FORMATTING ONLY: Keep each `key_issues_to_review` item as concise as you', + 'already would — do NOT add length, padding, or extra explanation. The only', + 'change is line breaks: instead of one dense run-on paragraph, insert a BLANK', + 'LINE at the natural boundaries (e.g. problem → impact → suggested fix) so the', + 'text reads as a few short paragraphs. Same words, better layout.', + '', + 'This applies to the issue PROSE only. The machine-readable anchor marker', + 'described above still goes on its OWN LAST LINE, after the final paragraph', + '(a blank line may precede it).', + ].join('\n') + : ''; + + const extraParts = [ + langDirective, + reviewAnchorDirective, + reviewLayoutDirective, + prContext, + matchedRuleInstructions, + ].filter((s) => s.trim()); + if (extraParts.length > 0) { + const envKey = + req.tool === 'describe' + ? 'PR_DESCRIPTION__EXTRA_INSTRUCTIONS' + : req.tool === 'review' + ? 'PR_REVIEWER__EXTRA_INSTRUCTIONS' + : req.tool === 'improve' + ? 'PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS' + : 'PR_QUESTIONS__EXTRA_INSTRUCTIONS'; + env[envKey] = extraParts.join('\n\n---\n\n'); + } + if (matchedRuleId) { + logger.info( + { runId: run.id, ruleId: matchedRuleId, tool: req.tool }, + 'pragent run: matched rule', + ); + } + if (prContext) { + logger.debug( + { runId: run.id, tool: req.tool, contextChars: prContext.length }, + 'pragent run: pr context injected', + ); + } + + // ask 工具:问题作为位置参数(user turn,spawn args 单元素,含空格也是一个 arg 不切分), + // 并在问题**末尾**硬性追加语言要求。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对 + // 自由问答常被大量英文 diff(full diff 数万 token)盖过 → 模型用英文作答;在 user turn 末尾 + // (近因位置、用目标语言书写)再要求一次,显著提升按 UI 语言作答的遵循度。en-US 返回空、不追加。 + const askLangSuffix = req.tool === 'ask' ? askLanguageSuffixFor(getMainLanguage()) : ''; + const askQuestion = + req.tool === 'ask' && req.question + ? askLangSuffix + ? `${req.question}\n\n${askLangSuffix}` + : req.question + : undefined; + const extraArgs = askQuestion ? [askQuestion] : undefined; + + // embedded 策略:执行期在嵌入式安装目录补空 .secrets.toml 压掉启动告警 + // (直接写安装目录;memo 化只首次做)。local-cli 不需要 (pipx 装的 pr-agent + // 路径不同,告警也不出) + if (prAgentBridge.strategy === 'embedded' && embeddedPythonPath) { + await ensureEmbeddedSecrets(embeddedPythonPath); + } + + const result = await prAgentBridge.run({ + prUrl: pr.url, + tool: req.tool, + env, + onLine, + cwd: wt.path, + targetBranch: wt.targetBranchName, + extraArgs, + signal: ac.signal, + }); + // 真实 token 用量(onLine 累加的 stderr 哨兵行),落到 succeeded / llm-failed 收尾。 + const tokenUsage = finalizeUsage(usageAcc); + // pr-agent 的 local provider 把生成结果**写到工作树根的 markdown 文件**: + // /describe → /description.md (走 publish_description) + // /review → /review.md (走 publish_comment) + // /ask → /review.md ← 共用同一文件 (publish_comment 会覆盖) + // /improve → /improve.md ← 汇总建议走 publish_comment,经 LOCAL__REVIEW_PATH + // 重定向与 review.md 分流(见上方 env 注入) + // 走 worktree 路径,cleanup 前必须先把文件读出来。 + const outFile = + req.tool === 'describe' + ? 'description.md' + : req.tool === 'improve' + ? 'improve.md' + : 'review.md'; + let fileContent = ''; + try { + fileContent = await fs.readFile(path.join(wt.path, outFile), 'utf8'); + } catch (readErr) { + logger.warn( + { err: readErr, wtPath: wt.path, outFile, runId: run.id }, + 'pr-agent local provider output file missing; fall back to stdout', + ); + } + // /ask 输出里 pr-agent 把问题原样回显在 answer body 顶部 (跟 chat 输入气泡完全 + // 重复)。在解析前把跟用户问题逐字匹配的整行删掉,避免渲染时出现两次问题 + const cleanedContent = + req.tool === 'ask' && req.question?.trim() + ? stripAskQuestionEcho(fileContent, req.question, askLangSuffix) + : fileContent; + const parsed = parseReviewOutput(cleanedContent || result.stdout, req.tool); + // M4 草稿再摄入:/review 成功完成时丢掉 pending+finding 旧草稿, + // 让本轮 ChatPane 上的 finding 列表成为新的候选源。edited/posted/rejected/ + // manual 保留不动。失败的 /review 不触发清理 (没建设性数据)。 + if (req.tool === 'review') { + try { + const dropped = await dropPendingFindingDrafts(stateStore, pr.localId); + if (dropped > 0) { + logger.info( + { runId: run.id, localId: pr.localId, dropped }, + 'pragent /review: dropped stale pending drafts', + ); + broadcast('drafts:changed', { localId: pr.localId }); + } + } catch (err) { + logger.warn({ err, runId: run.id }, 'dropPendingFindingDrafts failed'); + } + } + // pr-agent CLI 可能 exit 0 但 stdout 里其实是 LLM 调用全失败 (litellm + // AuthenticationError / "Failed to generate prediction with any model" 等 + // marker)。parseReviewOutput 会在 ParsedReviewOutput.llmFailure 标出 — + // 此时不算 succeeded,落盘为 failed + reason='llm-error',UI 用红色失败 + // chip 渲染而不是"完成" + if (parsed.llmFailure) { + logger.warn( + { runId: run.id, reason: parsed.llmFailure.message }, + 'pragent exit 0 but LLM call failed; marking run as failed', + ); + return await finishWith({ + status: 'failed', + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + exitCode: result.exitCode, + errorReason: 'llm-error', + errorMessage: parsed.llmFailure.message, + stdout: fileContent + ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` + : result.stdout, + stderr: stripUsageSentinels(result.stderr), + findings: parsed.findings, + summary: parsed.summary, + tokenUsage, + }); + } + return await finishWith({ + status: 'succeeded', + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + exitCode: result.exitCode, + // 持久化「LLM 真实产出」(文件内容);stdout 留作日志在折叠区供排障 + stdout: fileContent + ? `${fileContent}\n\n---\n[pr-agent stdout log]\n${result.stdout}` + : result.stdout, + stderr: stripUsageSentinels(result.stderr), + findings: parsed.findings, + summary: parsed.summary, + tokenUsage, + }); + } catch (err) { + if (err instanceof PrAgentRunError) { + // 用户主动取消 → status='cancelled',其它 reason → 'failed'。 + // 二者都仍走 finishReviewRun 落盘,让 UI 能从历史 run 里看到这次取消事件 + const status: ReviewRunStatus = err.reason === 'cancelled' ? 'cancelled' : 'failed'; + logger.warn( + { runId: run.id, reason: err.reason, exitCode: err.result.exitCode }, + `pragent run ${status}`, + ); + // 失败 / 取消时也尽量解析已收集的 stdout:很多情况 pr-agent 已写了一部分输出 + const partialStdout = err.result.stdout ?? ''; + const parsed = partialStdout + ? parseReviewOutput(partialStdout, req.tool) + : { findings: [], summary: undefined }; + // 失败 / 取消前可能已有若干次 LLM 调用,尽量把已产生的 token 用量也记上 + const tokenUsage = finalizeUsage(usageAcc); + return await finishWith({ + status, + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + exitCode: err.result.exitCode, + errorReason: err.reason, + errorMessage: err.message, + stdout: err.result.stdout, + stderr: stripUsageSentinels(err.result.stderr), + findings: parsed.findings, + summary: parsed.summary, + tokenUsage, + }); + } + // 非预期异常:仍记一笔 failed,避免 run 永远卡在 running,再把异常往上抛 + await finishWith({ + status: 'failed', + finishedAt: new Date().toISOString(), + durationMs: Date.now() - t0, + errorMessage: err instanceof Error ? err.message : String(err), + }); + throw err; + } finally { + await wt.cleanup(); + } + }; + + /** + * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 + * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 + */ + const pump = (): void => { + while (active.size < maxConcurrency && waiting.length > 0) { + const item = waiting.shift()!; + active.set(item.info.runId, item); + item.ac = new AbortController(); + void executeRun(item) + .then((finished) => item.resolve(finished)) + .catch((err: unknown) => { + item.reject(err instanceof Error ? err : new Error(String(err))); + }) + .finally(() => { + active.delete(item.info.runId); + broadcastQueueChanged(); + // 放微任务里再泵,避免递归栈累积 + queueMicrotask(pump); + }); + } + broadcastQueueChanged(); + }; + + const enqueuePragentRun = ( + pr: StoredPullRequest, + tool: ReviewRunTool, + question?: string, + priority: RunPriority = 'user', + ): Promise => { + if (tool !== 'ask') { + const sameTask = (q: QueueItem): boolean => + q.info.prLocalId === pr.localId && q.info.tool === tool; + if ([...active.values()].some(sameTask) || waiting.some(sameTask)) { + throw new Error(t('prAgent.duplicateTask', { tool })); + } + } + // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 + const runId = makeRunId(new Date()); + return new Promise((resolve, reject) => { + const item: QueueItem = { + info: { + runId, + prLocalId: pr.localId, + repoSlug: pr.repo.repoSlug, + prNumber: pr.remoteId, + tool, + question: tool === 'ask' ? question : undefined, + enqueuedAt: new Date().toISOString(), + startedAt: null, + }, + req: { localId: pr.localId, tool, question }, + pr, + priority, + resolve, + reject, + }; + // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 + if (priority === 'user') { + const firstAgentIdx = waiting.findIndex((q) => q.priority === 'agent'); + if (firstAgentIdx >= 0) waiting.splice(firstAgentIdx, 0, item); + else waiting.push(item); + } else { + waiting.push(item); + } + logger.info( + { runId, localId: pr.localId, tool, priority, queueLen: waiting.length }, + 'pragent run enqueued', + ); + pump(); + }); + }; + + const cancel = (runId: string): { ok: boolean } => { + // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) + const running = active.get(runId); + if (running) { + logger.info({ runId }, 'pragent run cancel: active'); + running.ac?.abort(); + return { ok: true }; + } + // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) + const idx = waiting.findIndex((q) => q.info.runId === runId); + if (idx >= 0) { + const [removed] = waiting.splice(idx, 1); + logger.info({ runId, queueLen: waiting.length }, 'pragent run cancel: queued'); + removed!.reject(new Error('queued run cancelled')); + broadcastQueueChanged(); + return { ok: true }; + } + return { ok: false }; + }; + + const cancelRunsForPr = (localId: string): void => { + for (const item of active.values()) if (item.req.localId === localId) item.ac?.abort(); + let removed = false; + for (let i = waiting.length - 1; i >= 0; i--) { + if (waiting[i]!.req.localId === localId) { + const [q] = waiting.splice(i, 1); + q!.reject(new Error('pr removed')); + removed = true; + } + } + if (removed) broadcastQueueChanged(); + }; + + const queuedPrLocalIds = (): string[] => { + const ids: string[] = []; + for (const item of active.values()) ids.push(item.req.localId); + for (const item of waiting) ids.push(item.req.localId); + return ids; + }; + + const abortAllActiveRuns = (): number => { + let n = 0; + for (const item of active.values()) { + item.ac?.abort(); + n++; + } + return n; + }; + + return { + enqueuePragentRun, + cancel, + snapshot, + cancelRunsForPr, + queuedPrLocalIds, + abortAllActiveRuns, + }; +} + +/** + * 把 config.language (ISO locale) 翻成自然语言 prompt directive,注入到 pr-agent + * 各 tool 的 EXTRA_INSTRUCTIONS。 + * + * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt + * template),但 /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格 + * 类输出的标题 / 列名 / 段落标记。 + * + * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 + * pr-agent 原行为。 + */ +function languageDirectiveFor(lang: string): string { + const norm = lang.toLowerCase(); + if (norm.startsWith('zh-cn') || norm === 'zh') { + return 'Respond in Simplified Chinese (简体中文). All section labels, table headers, column names, headings, and content MUST be in Chinese — do not leave any English template strings untranslated.'; + } + if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { + return 'Respond in Traditional Chinese (繁體中文). All section labels, table headers, column names, headings, and content MUST be in Chinese.'; + } + if (norm.startsWith('ja')) { + return 'Respond in Japanese (日本語). All section labels, table headers, column names, headings, and content MUST be in Japanese — do not leave any English template strings untranslated.'; + } + if (norm.startsWith('de')) { + return 'Respond in German (Deutsch). All section labels, table headers, column names, headings, and content MUST be in German — do not leave any English template strings untranslated.'; + } + return ''; +} + +/** + * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 + * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff + * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 + */ +function askLanguageSuffixFor(lang: string): string { + const norm = lang.toLowerCase(); + if (norm.startsWith('zh-cn') || norm === 'zh') { + return '请用简体中文回答整个回复(包括所有解释、说明与结论)。代码、标识符、文件路径保留原样,但所有叙述文字必须是简体中文,不要用英文作答。'; + } + if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { + return '請用繁體中文回答整個回覆(包括所有解釋、說明與結論)。程式碼、識別符、檔案路徑保留原樣,但所有敘述文字必須是繁體中文,不要用英文作答。'; + } + if (norm.startsWith('ja')) { + return '回答全体を日本語で記述してください(説明・結論を含む)。コード・識別子・ファイルパスはそのまま残し、説明文はすべて日本語にしてください。英語で回答しないでください。'; + } + if (norm.startsWith('de')) { + return 'Bitte antworte vollständig auf Deutsch (einschließlich aller Erklärungen und Schlussfolgerungen). Code, Bezeichner und Dateipfade bleiben unverändert, aber der gesamte erläuternde Text muss auf Deutsch sein. Antworte nicht auf Englisch.'; + } + return ''; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a8a0d90..486f0fa 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -8,7 +8,7 @@ import type { IpcChannels, IpcEventName, IpcEvents, -} from '@meebox/shared'; +} from '@meebox/ipc'; console.log('[preload] script loaded'); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index d788da0..ffdf001 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import i18n, { resolveUiLanguage, persistLanguage } from './i18n'; +import type { ConnectionSummary } from '@meebox/ipc'; import type { AppInfo, AppPaths, Config, - ConnectionSummary, LocalPrStatus, PrAgentStatus, PrDiscoveryFilter, diff --git a/apps/desktop/src/renderer/src/api.ts b/apps/desktop/src/renderer/src/api.ts index 8f52638..123fb95 100644 --- a/apps/desktop/src/renderer/src/api.ts +++ b/apps/desktop/src/renderer/src/api.ts @@ -1,4 +1,4 @@ -import type { IpcChannelName, IpcChannels, IpcEventName, IpcEvents } from '@meebox/shared'; +import type { IpcChannelName, IpcChannels, IpcEventName, IpcEvents } from '@meebox/ipc'; export function invoke( channel: K, diff --git a/apps/desktop/src/renderer/src/components/ChatPane.tsx b/apps/desktop/src/renderer/src/components/ChatPane.tsx index f9e42ea..09574f0 100644 --- a/apps/desktop/src/renderer/src/components/ChatPane.tsx +++ b/apps/desktop/src/renderer/src/components/ChatPane.tsx @@ -4,11 +4,11 @@ import type { TFunction } from 'i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; +import type { IpcChannels } from '@meebox/ipc'; import type { AgentMessage, AgentStep, Finding, - IpcChannels, LocalPrStatus, PrAgentStatus, PrDocSectionKey, diff --git a/apps/desktop/src/renderer/src/components/DiffSearchPanel.tsx b/apps/desktop/src/renderer/src/components/DiffSearchPanel.tsx index 3b7b7fe..ea1a683 100644 --- a/apps/desktop/src/renderer/src/components/DiffSearchPanel.tsx +++ b/apps/desktop/src/renderer/src/components/DiffSearchPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { editor as MonacoEditorNs } from 'monaco-editor'; -import type { DiffChangedFile } from '@meebox/shared'; +import type { DiffChangedFile } from '@meebox/ipc'; import { invoke } from '../api'; import { languageFor } from '../utils/language'; diff --git a/apps/desktop/src/renderer/src/components/DiffView.tsx b/apps/desktop/src/renderer/src/components/DiffView.tsx index acad950..993b1d9 100644 --- a/apps/desktop/src/renderer/src/components/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/DiffView.tsx @@ -9,10 +9,8 @@ import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-ed import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; +import type { DiffBlameLine, DiffChangedFile, DiffFileContent } from '@meebox/ipc'; import type { - DiffBlameLine, - DiffChangedFile, - DiffFileContent, DiffHunkRange, PlatformCapabilities, PrComment, diff --git a/apps/desktop/src/renderer/src/components/FileTree.tsx b/apps/desktop/src/renderer/src/components/FileTree.tsx index a4eaa3d..1f0821a 100644 --- a/apps/desktop/src/renderer/src/components/FileTree.tsx +++ b/apps/desktop/src/renderer/src/components/FileTree.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { Icon } from '@iconify/react'; -import type { DiffChangedFile } from '@meebox/shared'; +import type { DiffChangedFile } from '@meebox/ipc'; import { ChevronIcon } from './icons'; interface FileTreeProps { diff --git a/apps/desktop/src/renderer/src/components/StatusBar.tsx b/apps/desktop/src/renderer/src/components/StatusBar.tsx index 5aeca64..7205e21 100644 --- a/apps/desktop/src/renderer/src/components/StatusBar.tsx +++ b/apps/desktop/src/renderer/src/components/StatusBar.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import type { Config, ConnectionSummary, PrAgentStatus, UpdateCheckResult } from '@meebox/shared'; +import type { ConnectionSummary } from '@meebox/ipc'; +import type { Config, PrAgentStatus, UpdateCheckResult } from '@meebox/shared'; import { invoke } from '../api'; import { useChatRunStore } from '../stores/chat-run-store'; import { useRepoSyncStore } from '../stores/repo-sync-store'; diff --git a/apps/desktop/src/renderer/src/stores/chat-run-store.ts b/apps/desktop/src/renderer/src/stores/chat-run-store.ts index 7fbc829..34cae14 100644 --- a/apps/desktop/src/renderer/src/stores/chat-run-store.ts +++ b/apps/desktop/src/renderer/src/stores/chat-run-store.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import type { PragentRunInfo } from '@meebox/shared'; +import type { PragentRunInfo } from '@meebox/ipc'; import { invoke, subscribe } from '../api'; /** diff --git a/apps/desktop/typings/env.d.ts b/apps/desktop/typings/env.d.ts index c82b043..08adf7f 100644 --- a/apps/desktop/typings/env.d.ts +++ b/apps/desktop/typings/env.d.ts @@ -5,7 +5,7 @@ * vite/client 的全局类型通过 tsconfig.json 的 compilerOptions.types 引入; * 不再 /// reference,跟 logger 包共享 typings 的做法保持一致。 */ -import type { IpcBridge } from '@meebox/shared'; +import type { IpcBridge } from '@meebox/ipc'; declare global { interface Window { diff --git a/package-lock.json b/package-lock.json index 3e88edf..80ff0d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@iconify/react": "^5.2.1", "@meebox/agent": "*", "@meebox/config": "*", + "@meebox/ipc": "*", "@meebox/logger": "*", "@meebox/platform-bitbucket-server": "*", "@meebox/platform-github": "*", @@ -1668,6 +1669,10 @@ "resolved": "apps/desktop", "link": true }, + "node_modules/@meebox/ipc": { + "resolved": "packages/ipc", + "link": true + }, "node_modules/@meebox/logger": { "resolved": "packages/logger", "link": true @@ -14066,6 +14071,13 @@ "zod": "^3.23.0" } }, + "packages/ipc": { + "name": "@meebox/ipc", + "version": "0.0.0", + "dependencies": { + "@meebox/shared": "*" + } + }, "packages/logger": { "name": "@meebox/logger", "version": "0.0.0", diff --git a/packages/ipc/package.json b/packages/ipc/package.json new file mode 100644 index 0000000..9f6d223 --- /dev/null +++ b/packages/ipc/package.json @@ -0,0 +1,25 @@ +{ + "name": "@meebox/ipc", + "version": "0.0.0", + "private": true, + "description": "Typed IPC 契约(renderer ↔ main):按业务领域拆分的 IpcChannels / IpcEvents / IpcBridge", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint src --max-warnings=0" + }, + "nx": { + "targets": { + "typecheck": { "cache": true }, + "lint": { "cache": true } + } + }, + "dependencies": { + "@meebox/shared": "*" + } +} diff --git a/packages/ipc/src/agent.ts b/packages/ipc/src/agent.ts new file mode 100644 index 0000000..73897c1 --- /dev/null +++ b/packages/ipc/src/agent.ts @@ -0,0 +1,68 @@ +import type { + AgentMessage, + AgentRecommendationVerdict, + AgentSession, + AgentStep, + ReviewRunTool, +} from '@meebox/shared'; + +/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取。 */ +export interface AgentChannels { + /** + * 给指定 PR 查 `/rules` 当前命中的规则 (按 priority desc + path asc 取首条)。 + * 调用方传 tool 区分 /describe / /review (规则可能只对其中一个 tool 生效)。 + * agent.dir 未配置 / 整体禁用 / 无命中 → 返回 null。 + */ + 'rules:matchForPr': { + request: { localId: string; tool: ReviewRunTool }; + response: { + id: string; + filePath: string; + priority: number; + tools: ReviewRunTool[]; + instructions: string; + } | null; + }; + /** + * 对指定 PR 跑一次 Agent 评审微流程(describe→review→条件追问→总结)。同步等待, + * 期间经 agent:stepProgress 推送步骤;返回收尾后的 AgentSession(含 summary / + * recommendation)。pr-agent 不可用时 reject。 + */ + 'agent:run': { + request: { localId: string }; + response: AgentSession; + }; + /** + * 对指定 PR 跑自由规划 Agent(自然语言入口「对话即委派」)。同步等待,步骤经 + * agent:stepProgress 推送;返回收尾会话(summary = Agent 最终回答)。 + */ + 'agent:ask': { + request: { localId: string; question: string }; + response: AgentSession; + }; + /** 暂停当前 PR 的 Agent 运行(abort);会话置 paused、保态。 */ + 'agent:stop': { request: { localId: string }; response: { ok: boolean } }; + /** + * 读取指定 PR 已落盘的 Agent 会话(含收尾 summary / recommendation);无则返回 null。 + * 供 UI 打开 PR 时恢复「评审总结」卡片——总结归属其发起 PR、跨 PR 切换不丢失、不串台。 + */ + 'agent:getSession': { request: { localId: string }; response: AgentSession | null }; + /** + * 读取指定 PR 的多轮对话消息(用户输入 + Agent 回答,按时间升序);无则空数组。 + * UI 据此渲染多轮会话;跨 PR 切换 / 重启后恢复。 + */ + 'agent:getConversation': { request: { localId: string }; response: AgentMessage[] }; + /** + * 读取指定 PR 已落盘的 Agent 过程步骤(transcript,按时间升序);无则空数组。 + * UI 据此恢复「过程化跟踪」的思考步骤——跨 PR 切换 / 重启后不丢失(步骤随产生增量落盘)。 + */ + 'agent:getTranscript': { request: { localId: string }; response: AgentStep[] }; + /** + * 批量读 AutoPilot 台账:返回各 PR 已自动评审的 recommendation(仅 decision=review 且有 + * 建议者)。PR 列表据此显示徽标,无需逐个加载会话。 + */ + 'agent:autopilotLedgers': { + request: { localIds: string[] }; + response: Record; + }; +} diff --git a/packages/ipc/src/app.ts b/packages/ipc/src/app.ts new file mode 100644 index 0000000..8b2e76e --- /dev/null +++ b/packages/ipc/src/app.ts @@ -0,0 +1,58 @@ +import type { AppInfo, AppPaths, PrAgentStatus, UpdateCheckResult } from '@meebox/shared'; +import type { ConnectionSummary } from './common.js'; + +/** GUI 框架交互域:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像。 */ +export interface AppChannels { + 'app:info': { request: void; response: AppInfo }; + 'app:paths': { request: void; response: AppPaths }; + 'app:prAgentStatus': { request: void; response: PrAgentStatus }; + /** 调 Electron shell.openPath 让 OS 默认编辑器打开 config.yaml */ + 'app:openConfigFile': { request: void; response: void }; + /** 调 shell.openPath 在系统文件管理器打开当前生效的 Agent 目录(不存在则先建)。 */ + 'app:openAgentDir': { request: void; response: void }; + /** 打开 Electron DevTools(分离窗口) */ + 'app:openDevTools': { request: void; response: void }; + /** 手动检测版本更新(设置页「检查更新」)。仅检测 + 返回结果,不下载 / 安装; + * 结果同时缓存进 main 单一真相源并在有新版时广播 app:updateAvailable,使状态栏同步。 */ + 'app:checkUpdate': { request: void; response: UpdateCheckResult }; + /** 读取 main 缓存的最近一次成功更新检测结果(不发起网络请求)。供窗口 / 状态栏挂载时水合, + * 无缓存(尚未检测过)时返回 null。 */ + 'app:getUpdateStatus': { request: void; response: UpdateCheckResult | null }; + /** + * 渲染层日志回传:把渲染进程的错误 / 未捕获异常转发到 main,落进同一份 meebox.log + * (renderer 自己的 console 不进文件)。preload 装 window.onerror / unhandledrejection + * 调用。`scope` 固定 'renderer',`meta` 任意结构化上下文(如 stack / url)。 + */ + 'log:write': { + request: { + level: 'error' | 'warn' | 'info' | 'debug'; + msg: string; + meta?: Record; + }; + response: void; + }; + /** + * 用系统默认浏览器打开 URL (shell.openExternal)。评论 markdown 内链点击 → 强制 + * 外部打开,避免 Electron 在 app window 内跳转覆盖整个界面 + */ + 'app:openExternal': { request: { url: string }; response: void }; + /** + * 调起系统原生目录选择对话框;用户取消返回 path: null。 + * defaultPath 可空,作为初始定位目录。 + */ + 'dialog:pickDirectory': { + request: { defaultPath?: string; title?: string }; + response: { path: string | null }; + }; + /** 各连接的 ping 后缓存:当前用户 + display_name,Header 用 */ + 'app:connections': { request: void; response: ConnectionSummary[] }; + /** + * 按 (connectionId, slug) 拉用户头像 data URL;主进程缓存命中直接返回。 + * 平台不支持 / 网络失败 / 用户无头像时返回 null,renderer 走 initials 回退。 + */ + 'app:userAvatar': { + // avatarUrl 可选:平台返回的头像直链(GitHub 机器人必须靠它);缺省时 main 按 slug 推导 + request: { connectionId: string; slug: string; avatarUrl?: string }; + response: { dataUrl: string } | null; + }; +} diff --git a/packages/ipc/src/common.ts b/packages/ipc/src/common.ts new file mode 100644 index 0000000..a94e0bd --- /dev/null +++ b/packages/ipc/src/common.ts @@ -0,0 +1,66 @@ +import type { + PlatformCapabilities, + PlatformUser, + ReviewRunTool, +} from '@meebox/shared'; + +/** ChangedFile / FileContent 跨 IPC 边界用,与 @meebox/repo-mirror 类型同形。 */ +export type DiffFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; + +export interface DiffChangedFile { + path: string; + oldPath?: string; + status: DiffFileStatus; + similarity?: number; +} + +export type DiffFileContent = { binary: false; content: string } | { binary: true }; + +export type DiffSide = 'base' | 'head'; + +/** 单行 blame 信息(main 跑 git blame --porcelain,renderer 渲染左侧列)。 */ +export interface DiffBlameLine { + line: number; + commit: string; + author: string; + authorEmail: string; + authorDate: string; + summary: string; +} + +export interface ConnectionSummary { + connectionId: string; + /** 来自 config 的 display_name */ + displayName: string; + /** ping 后缓存的当前 PAT 所属用户;ping 未完成或失败时为 null */ + user: PlatformUser | null; + /** 该连接所属平台的能力描述符;渲染层据此 显/隐/灰(多平台降级,见 platform.ts) */ + capabilities: PlatformCapabilities; +} + +/** + * 一个 pr-agent run 的元信息,覆盖"正在跑 (active)"和"排队中 (waiting)"两种状态。 + * + * - active:`startedAt` 是 ISO 启动时间,UI 计时器起点 + * - waiting:`startedAt` 为 null,UI 显示"排队中"+ enqueuedAt + * + * 入队即生成 runId (跟最终落盘的 ReviewRun.id 一致;queued 状态不写盘,等真正 + * 开始时 startReviewRun 才落 disk)。这让 `pragent:cancel(runId)` 在 queued/active + * 两种状态下都能用同一个 id 引用。 + */ +export interface PragentRunInfo { + runId: string; + prLocalId: string; + /** 仓库 slug 与 PR 号(队列展示用,避免只显示 localId hash)。 */ + repoSlug: string; + prNumber: string; + tool: ReviewRunTool; + question?: string; + /** 入队时间,ISO */ + enqueuedAt: string; + /** 开始执行时间,ISO;waiting 状态为 null */ + startedAt: string | null; +} + +/** 兼容旧引用:active 状态本质就是 startedAt 非空的 PragentRunInfo */ +export type ActiveRunInfo = PragentRunInfo; diff --git a/packages/ipc/src/config.ts b/packages/ipc/src/config.ts new file mode 100644 index 0000000..b8976a7 --- /dev/null +++ b/packages/ipc/src/config.ts @@ -0,0 +1,57 @@ +import type { Config, PingResult, PlatformKind, SupportedLanguage } from '@meebox/shared'; + +/** 配置操作域:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连。 */ +export interface ConfigChannels { + 'config:read': { request: void; response: Config }; + /** 写入新的 repos_dir 到 config.yaml;重启生效 */ + 'config:setReposDir': { request: { reposDir: string }; response: void }; + /** + * 写入 UI 语言到 config.yaml 并**即时生效**:主进程 i18n 立刻 changeLanguage(后续 dialog/ + * 错误文案 + 下次 pragent:run 的响应语言随之),渲染层另行 i18n.changeLanguage 实时切换。 + * 与代理/连接同属热生效项,无需依赖设置页全局保存。 + */ + 'config:setLanguage': { request: { language: SupportedLanguage }; response: void }; + /** 写入 LLM Provider 配置到 config.yaml;下次 pragent:run 自动用新值 */ + 'config:setLlm': { request: { llm: Config['llm'] }; response: void }; + /** 写入 agent.dir 到 config.yaml;下次 pragent:run 立即生效 (现读规则) */ + 'config:setAgent': { request: { agent: Config['agent'] }; response: void }; + /** 翻转 AutoPilot 开关 (agent.autopilot.enabled) 并写 config.yaml;下次 poll tick 生效。 */ + 'agent:setAutopilotEnabled': { request: { enabled: boolean }; response: void }; + /** 写入轮询间隔 (秒,60~900 整数) 到 config.yaml,并热替换 poller 定时器,无需重启 */ + 'config:setPoller': { request: { interval_seconds: number }; response: void }; + /** + * 写入网络代理配置到 config.yaml,并**热重建** adapter(REST 经代理即时生效)。 + * pr-agent / git 出口下次操作读最新配置,无需重启。 + */ + 'config:setProxy': { request: { proxy: Config['proxy'] }; response: void }; + /** 用给定代理配置试连一个外部地址,验证代理是否可用;不写配置。 */ + 'config:testProxy': { + request: { proxy: Config['proxy'] }; + response: { ok: boolean; reason?: string }; + }; + /** + * 写入连接列表 + 当前启用连接到 config.yaml,并**热重建** adapter/poller 即时生效 + * (无需重启)。active 那条被轮询,其余仅保留配置。 + */ + 'config:setConnections': { + request: { connections: Config['connections']; active_connection_id: string }; + response: void; + }; + /** 用草稿 url/token 临时起 adapter ping,保存前测试连接是否可达;不写配置。 */ + 'config:testConnection': { + request: { base_url: string; token: string; kind?: PlatformKind }; + response: PingResult; + }; + /** + * 配置过程中自动把连接 + LLM 草稿写入 config.yaml(防丢失),但**不应用到运行时** + * (不 reconfigure adapter/poller、不更新内存 config)——重启或点底栏「保存」才生效。 + */ + 'config:autosaveDraft': { + request: { + connections: Config['connections']; + active_connection_id: string; + llm: Config['llm']; + }; + response: void; + }; +} diff --git a/packages/ipc/src/events.ts b/packages/ipc/src/events.ts new file mode 100644 index 0000000..0bf17cc --- /dev/null +++ b/packages/ipc/src/events.ts @@ -0,0 +1,64 @@ +import type { AgentStep, PollResult, SyncProgressEvent, UpdateCheckResult } from '@meebox/shared'; +import type { PragentRunInfo } from './common.js'; + +/** Poller tick 完成后广播给 renderer 用于更新"最近一次同步"显示。 */ +export interface PollTickEvent { + /** tick 完成时间 ISO */ + at: string; + result: PollResult; +} + +/** + * pr-agent run 期间 stdout / stderr 整行流式推送。renderer 拿来在 ChatPane + * 或日志区域实时显示。一次 run 多条;run 结束后不再发。 + */ +export interface PragentRunProgressEvent { + runId: string; + line: string; + stream: 'stdout' | 'stderr'; +} + +/** main → renderer 推送事件。renderer 用 window.api.subscribe 监听。 */ +export interface IpcEvents { + 'sync:progress': SyncProgressEvent; + 'poll:tick': PollTickEvent; + 'pragent:runProgress': PragentRunProgressEvent; + /** + * 草稿变更广播:某 PR 的 drafts.json 发生增/删/改 / /review 完成时的"再摄入" + * 清理都触发。renderer 据此重拉 drafts 列表 (per localId 过滤)。 + */ + 'drafts:changed': { localId: string }; + /** 评论 reply / 状态变更后广播,renderer 各组件 (CommentsPanel / DiffView inline) 重拉 */ + 'comments:changed': { localId: string }; + /** + * 队列变化广播:active 增删 / waiting 增删都触发。renderer 据此同步 chat-pane + * 运行中 UI + StatusBar 队列 chip。`active` 是当前并发运行中的 run 列表 + * (长度 ≤ max_concurrency)。 + */ + 'pragent:queueChanged': { + active: PragentRunInfo[]; + waiting: PragentRunInfo[]; + }; + /** 启动检测到新版本时推送(仅 hasUpdate=true 时发),renderer 据此提示。 */ + 'app:updateAvailable': UpdateCheckResult; + /** Agent 编排步骤流式推送:每产生一个 AgentStep 即发,renderer 据此实时呈现。 */ + 'agent:stepProgress': { sessionId: string; prLocalId: string; step: AgentStep }; + /** + * 某 PR 的多轮对话有新落盘消息(如后台 AutoPilot 评审收尾追加的「评审总结」)。renderer 若正打开 + * 该 PR 则据此重载会话,让后台产生的总结卡片即时出现(手动评审走 invoke 返回后自行重载,不依赖此事件)。 + */ + 'agent:conversationChanged': { prLocalId: string }; + /** + * 运行中(思考或派发工具)的编排 Agent 所属 PR 集合变化时推送:手动 `agent:run` / `agent:ask` + * 与 AutoPilot 后台评审一并计入。renderer 据此在 PR 列表项显示「执行中」指示——覆盖**纯思考阶段** + * (无活跃工具 run 时),补齐仅看运行队列时思考态缺失执行中标记的空档。 + */ + 'agent:runningChanged': { prLocalIds: string[] }; + /** + * 某 PR 的评审状态被清除(清空执行历史时一并清掉 AutoPilot 台账)。renderer 据此即时清掉 PR 列表 + * 该 PR 的评审建议 ★ 徽标,避免清空后仍残留陈旧评审状态(不必等下个 poll 重取台账)。 + */ + 'agent:reviewStatusCleared': { prLocalId: string }; +} + +export type IpcEventName = keyof IpcEvents; diff --git a/packages/ipc/src/index.ts b/packages/ipc/src/index.ts new file mode 100644 index 0000000..74e77fa --- /dev/null +++ b/packages/ipc/src/index.ts @@ -0,0 +1,32 @@ +import type { AgentChannels } from './agent.js'; +import type { AppChannels } from './app.js'; +import type { ConfigChannels } from './config.js'; +import type { IpcEventName, IpcEvents } from './events.js'; +import type { PrChannels } from './pr.js'; + +export * from './common.js'; +export * from './events.js'; +export * from './app.js'; +export * from './pr.js'; +export * from './config.js'; +export * from './agent.js'; + +/** + * Typed IPC channel contract. + * + * 按业务领域拆分维护(app / pr / config / agent),在此合并为单一映射。 + * The preload bridge and main handlers both reference this map so that + * Renderer ↔ Main calls stay end-to-end type-safe. + */ +export type IpcChannels = AppChannels & PrChannels & ConfigChannels & AgentChannels; + +export type IpcChannelName = keyof IpcChannels; + +export interface IpcBridge { + invoke( + channel: K, + req: IpcChannels[K]['request'], + ): Promise; + /** 订阅 main → renderer 推送事件,返回取消订阅函数。 */ + subscribe(event: E, handler: (data: IpcEvents[E]) => void): () => void; +} diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts new file mode 100644 index 0000000..9b7d547 --- /dev/null +++ b/packages/ipc/src/pr.ts @@ -0,0 +1,263 @@ +import type { + LocalPrStatus, + PollResult, + PrComment, + PrCommit, + ReviewDraft, + ReviewRun, + ReviewRunTool, + StoredPullRequest, +} from '@meebox/shared'; +import type { + DiffBlameLine, + DiffChangedFile, + DiffFileContent, + DiffSide, + PragentRunInfo, +} from './common.js'; + +/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列。 */ +export interface PrChannels { + /** + * 拉评论 body 内嵌图片 (`![alt](url)`)。url 可能是 Bitbucket attachment 绝对/相对地址, + * 私有实例需要带 PAT 才能取 → renderer `` 标签无法直接 fetch,必须走 main 代理。 + * 返回 data URL 给 renderer 拼到 ``;获取失败 (404 / 跨 host / 非图片) 返回 null + */ + 'comments:fetchAttachment': { + request: { localId: string; url: string }; + response: { dataUrl: string } | null; + }; + /** + * 对已有评论发回复。提交成功后 main 端会刷新 comments cache + broadcast + * comments:changed 事件,renderer 各组件重新拉取列表自动展示新 reply + */ + 'comments:reply': { + request: { localId: string; parentCommentId: string; body: string }; + response: PrComment; + }; + /** + * 删除自己作者的远端评论。Bitbucket 要求带 version (乐观锁),调用方从已有 PrComment + * 拿;不一致 / 评论已有回复 / 自己不是作者都会失败 (Bitbucket 409/403)。成功后 main + * 端清空评论缓存 + broadcast comments:changed,UI 自动重拉刷新 + */ + 'comments:delete': { + request: { localId: string; commentId: string; version: number }; + response: void; + }; + /** + * 编辑自己作者评论的 body。Bitbucket PUT 同样要 version (乐观锁) — 不一致回 409, + * 上层应提示"远端已更新,请刷新后重试"并拒绝静默覆盖。Bitbucket 允许编辑带 reply + * 的评论 (跟 delete 区别)。成功后 main 端清评论缓存 + 广播 + * comments:changed,UI 自动重拉显示新文本 + */ + 'comments:edit': { + request: { + localId: string; + commentId: string; + version: number; + body: string; + }; + response: PrComment; + }; + 'prs:list': { request: void; response: StoredPullRequest[] }; + 'prs:refresh': { request: void; response: PollResult }; + /** Poller 最近一次完成时间(ISO 或 null);启动时初始化用 */ + 'prs:lastSync': { request: void; response: { at: string | null } }; + 'prs:setLocalStatus': { + request: { localId: string; status: LocalPrStatus }; + response: StoredPullRequest | null; + }; + /** + * 合并 PR 到目标分支(仅对 canMerge=true 的 PR 暴露入口)。成功后远端 PR 转 + * MERGED,调用方应自行刷新列表(下一轮 poll 会软删该 PR)。失败抛错冒泡到 renderer。 + */ + 'prs:merge': { + request: { localId: string }; + response: void; + }; + /** 同步 PR 所属 repo 的本地镜像(必要时 clone,否则 fetch),返回镜像绝对路径 */ + 'repo:sync': { + request: { localId: string }; + response: { mirrorPath: string; freshClone: boolean }; + }; + /** 列出 PR baseSha → headSha 之间变更的文件(自动先 sync mirror) */ + 'diff:listChangedFiles': { + request: { localId: string }; + response: DiffChangedFile[]; + }; + /** 读取 PR base 或 head 一侧某文件的内容(二进制返回 {binary:true}) */ + 'diff:getFileContent': { + request: { localId: string; side: DiffSide; path: string }; + response: DiffFileContent; + }; + /** + * 拉取 PR 上的已有评论(inline + summary 都拉,renderer 自己分)。 + * + * 默认走 cache + pr_updated_at stale 比对:命中回缓存,stale/miss 拉远端。 + * 但本地 PR.updatedAt 来自 poller 周期性拉,可能滞后 — 远端新增评论后, + * 本地 updatedAt 不变 → cache 误判命中 → 不刷新。打开 PR 时 renderer 应该 + * 传 force=true 跳过 stale 比对强制远端拉一次,确保 badge 计数 / inline + * 评论是最新的 + */ + 'diff:listComments': { + request: { localId: string; force?: boolean }; + response: PrComment[]; + }; + /** + * 仅读评论缓存里的总条数 (inline + summary 顶层条目数;不展开 replies),**不** + * 打远端。UI 用于 tab 角标 "评论 (N)" 的懒展示:缓存有就直接显示,缓存空就不显示。 + * 用户切到 Comments 标签时触发 `diff:listComments` 拉远端 + 写缓存,下次进 PR + * 角标就有数字了。 + */ + 'diff:commentCountCached': { + request: { localId: string }; + response: { count: number } | null; + }; + /** 拉取 PR 包含的 commits,newest first */ + 'diff:listCommits': { + request: { localId: string }; + response: PrCommit[]; + }; + /** + * 本地 git rev-list 算 PR 引入的 commit 数 (base..head)。完全走本地 bare 镜像, + * 不打远端;任一 sha 不在镜像 (尚未 sync 到本 PR 范围) → null。 + * UI 用于 Commits 标签页角标的懒展示,跟 diff:commentCountCached 同模式 + */ + 'diff:commitCount': { + request: { localId: string }; + response: { count: number } | null; + }; + /** + * 给 head 侧文件跑 git blame;同时返回 PR 引入的 head 行号集合, + * renderer 能区分"未变更行(出 blame)"vs"PR 改动行(出色带占位)"。 + */ + 'diff:getBlame': { + request: { localId: string; path: string }; + response: { + /** 仅未变更行的 blame(已过滤掉 PR 改动行) */ + lines: DiffBlameLine[]; + /** PR 引入的 head 行号 (added / modified),用于 blame 列画色带占位 */ + changedLines: number[]; + }; + }; + /** 计算本地所有 repo 镜像的总占用字节数(设置页用) */ + 'repo:getTotalSize': { request: void; response: { totalBytes: number } }; + /** + * 触发一次 pr-agent /describe 或 /review。同步等待执行结束(可能数十秒到数分钟), + * 期间通过 pragent:runProgress 事件推送 stdout / stderr 行。返回最终 ReviewRun + * 状态 (succeeded / failed)。pr-agent 不可用时 reject。 + */ + 'pragent:run': { + /** + * tool='ask' 时 question 必填,作为 pr-agent CLI 的位置参数传给 ask 子命令。 + * tool='describe'/'review' 时 question 字段被忽略。 + */ + request: { localId: string; tool: ReviewRunTool; question?: string }; + response: ReviewRun; + }; + /** + * 列出指定 PR 的全部草稿 (pending / edited / posted / rejected 都返回,UI 端按 + * status 过滤显示 / 折叠)。 + */ + 'drafts:list': { + request: { localId: string }; + response: ReviewDraft[]; + }; + /** + * 创建一条草稿。id / createdAt / updatedAt 由 main 端生成,调用方传业务字段即可。 + * 调用约定:origin='finding' 时必须传 source;origin='manual' 时不要传 source。 + * 成功后 main 端广播 `drafts:changed` 事件。 + */ + 'drafts:create': { + request: { + localId: string; + draft: Omit; + }; + response: ReviewDraft; + }; + /** + * 部分更新一条草稿。规则: + * - 编辑 body 且 status='pending' → 自动转 'edited' + * - 显式传 status (e.g., 'rejected') → 按传入值覆盖 + * - 找不到 draftId 返回 null (不抛错,UI 静默兜底) + */ + 'drafts:update': { + request: { + localId: string; + draftId: string; + patch: Partial>; + }; + response: ReviewDraft | null; + }; + /** 删除一条草稿。删 posted 草稿是允许的 (只清本地,远端 comment 不动) */ + 'drafts:delete': { + request: { localId: string; draftId: string }; + response: void; + }; + /** + * 批量发布草稿到远端:每条 draft 经 adapter.publishInlineComment 发到 Bitbucket, + * 成功 → 本地 draft status='posted' + 写 posted_remote_id;失败 → 保持原 status + * 不变并把错误收集到 results 里。**单条失败不中断后续条目** —— 跟 Bitbucket web UI + * "Start review" 行为对齐 (那边也是逐条 POST,某条 400 不影响其它)。 + * + * 一次性发完后 main 会: + * 1. 广播 `drafts:changed` —— DiffView / FindingCard 重拉草稿换 status chip + * 2. force-refresh Bitbucket PR 评论 (跳缓存) + 广播 `comments:changed`,让 CommentsPanel + * 立即看到自己刚发布的评论,不用等下一轮 poller + * + * 调用方 (renderer modal) 据 results 显示 "成功 N 失败 M" + 错误明细 + */ + 'drafts:publishBatch': { + request: { localId: string; draftIds: string[] }; + response: { + results: Array<{ + draftId: string; + ok: boolean; + /** 成功时填,跟落库的 draft.posted_remote_id 同值 */ + postedRemoteId?: string; + /** 失败时填,人读错因 (Bitbucket REST 4xx body 经过 PlatformError 包装) */ + error?: string; + }>; + }; + }; + /** + * 列出某 PR 的历史 run,newest first。支持时间戳游标分页: + * - limit:截到 N 条;省略 = 不限(renderer 端慎用,规模大时可能慢) + * - beforeId:游标,返回 runId **严格小于** 此值的条目;省略 = 不限上界 + * + * runId 是时序字典序 (`yyyymmdd-HHmmss-mmm`),"取游标后 N 条" 即"取此时刻之前的 N 条" + */ + 'pragent:listRuns': { + request: { localId: string; limit?: number; beforeId?: string }; + response: ReviewRun[]; + }; + /** 单条 run 查询(用于 renderer 在事件断流后兜底刷新) */ + 'pragent:getRun': { + request: { localId: string; runId: string }; + response: ReviewRun | null; + }; + /** 清空指定 PR 的全部 run 历史记录(仅该 PR 生效)。返回删除条数。 */ + 'pragent:clearRuns': { + request: { localId: string }; + response: { cleared: number }; + }; + /** + * 取消一个 run。语义跟 run 当前状态相关: + * - 跟 active 匹配 → SIGKILL 子进程,落盘 status='cancelled' + * - 在 waiting 队列里 → 从队列删除,**不**写盘 (从未真正跑过);触发 pragent:run + * 原调用方的 Promise reject 让 ChatPane handleRun 走 error 分支 + * - 都不匹配 (已结束 / 不存在) → 静默 no-op (返回 ok:false) + */ + 'pragent:cancel': { + request: { runId: string }; + response: { ok: boolean }; + }; + /** + * 查询当前队列快照 (active + waiting);renderer 启动 / 重连时拉一下, + * 跟 queueChanged 事件配套兜底。 + */ + 'pragent:queue': { + request: void; + response: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; + }; +} diff --git a/packages/ipc/tsconfig.json b/packages/ipc/tsconfig.json new file mode 100644 index 0000000..05af863 --- /dev/null +++ b/packages/ipc/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8643856..721e124 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,8 +2,8 @@ export * from './agent-contract.js'; export * from './app-info.js'; export * from './config.js'; export * from './inline-comment-policy.js'; -export * from './ipc.js'; export * from './language.js'; export * from './platform.js'; export * from './poller-contract.js'; export * from './pr-agent-status.js'; +export * from './sync-progress.js'; diff --git a/packages/shared/src/ipc.ts b/packages/shared/src/ipc.ts deleted file mode 100644 index 4425729..0000000 --- a/packages/shared/src/ipc.ts +++ /dev/null @@ -1,589 +0,0 @@ -import type { - AgentMessage, - AgentRecommendationVerdict, - AgentSession, - AgentStep, -} from './agent-contract.js'; -import type { AppInfo, AppPaths, UpdateCheckResult } from './app-info.js'; -import type { Config } from './config.js'; -import type { SupportedLanguage } from './language.js'; -import type { - PingResult, - PlatformCapabilities, - PlatformKind, - PlatformUser, - PrComment, - PrCommit, -} from './platform.js'; -import type { - LocalPrStatus, - PollResult, - ReviewDraft, - ReviewRun, - ReviewRunTool, - StoredPullRequest, -} from './poller-contract.js'; -import type { PrAgentStatus } from './pr-agent-status.js'; - -/** ChangedFile / FileContent 跨 IPC 边界用,与 @meebox/repo-mirror 类型同形。 */ -export type DiffFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; - -export interface DiffChangedFile { - path: string; - oldPath?: string; - status: DiffFileStatus; - similarity?: number; -} - -export type DiffFileContent = { binary: false; content: string } | { binary: true }; - -export type DiffSide = 'base' | 'head'; - -/** 单行 blame 信息(main 跑 git blame --porcelain,renderer 渲染左侧列)。 */ -export interface DiffBlameLine { - line: number; - commit: string; - author: string; - authorEmail: string; - authorDate: string; - summary: string; -} - -/** - * 仓库 sync 进度事件。RepoMirrorManager 在 clone/fetch 期间通过 onProgress - * 回调发出;main 进程经 webContents.send 推到 renderer。 - */ -export interface SyncProgressEvent { - /** "host/projectKey/repoSlug" 标识 */ - repo: string; - phase: 'start' | 'progress' | 'done' | 'error'; - /** simple-git 阶段名(compressing / receiving / resolving / ...) */ - stage?: string; - /** 0-100 */ - percent?: number; - /** 人读消息 */ - message?: string; -} - -/** Poller tick 完成后广播给 renderer 用于更新"最近一次同步"显示。 */ -export interface PollTickEvent { - /** tick 完成时间 ISO */ - at: string; - result: PollResult; -} - -/** - * pr-agent run 期间 stdout / stderr 整行流式推送。renderer 拿来在 ChatPane - * 或日志区域实时显示。一次 run 多条;run 结束后不再发。 - */ -export interface PragentRunProgressEvent { - runId: string; - line: string; - stream: 'stdout' | 'stderr'; -} - -/** - * 一个 pr-agent run 的元信息,覆盖"正在跑 (active)"和"排队中 (waiting)"两种状态。 - * - * - active:`startedAt` 是 ISO 启动时间,UI 计时器起点 - * - waiting:`startedAt` 为 null,UI 显示"排队中"+ enqueuedAt - * - * 入队即生成 runId (跟最终落盘的 ReviewRun.id 一致;queued 状态不写盘,等真正 - * 开始时 startReviewRun 才落 disk)。这让 `pragent:cancel(runId)` 在 queued/active - * 两种状态下都能用同一个 id 引用。 - */ -export interface PragentRunInfo { - runId: string; - prLocalId: string; - /** 仓库 slug 与 PR 号(队列展示用,避免只显示 localId hash)。 */ - repoSlug: string; - prNumber: string; - tool: ReviewRunTool; - question?: string; - /** 入队时间,ISO */ - enqueuedAt: string; - /** 开始执行时间,ISO;waiting 状态为 null */ - startedAt: string | null; -} - -/** 兼容旧引用:active 状态本质就是 startedAt 非空的 PragentRunInfo */ -export type ActiveRunInfo = PragentRunInfo; - -/** main → renderer 推送事件。renderer 用 window.api.subscribe 监听。 */ -export interface IpcEvents { - 'sync:progress': SyncProgressEvent; - 'poll:tick': PollTickEvent; - 'pragent:runProgress': PragentRunProgressEvent; - /** - * 草稿变更广播:某 PR 的 drafts.json 发生增/删/改 / /review 完成时的"再摄入" - * 清理都触发。renderer 据此重拉 drafts 列表 (per localId 过滤)。 - */ - 'drafts:changed': { localId: string }; - /** 评论 reply / 状态变更后广播,renderer 各组件 (CommentsPanel / DiffView inline) 重拉 */ - 'comments:changed': { localId: string }; - /** - * 队列变化广播:active 增删 / waiting 增删都触发。renderer 据此同步 chat-pane - * 运行中 UI + StatusBar 队列 chip。`active` 是当前并发运行中的 run 列表 - * (长度 ≤ max_concurrency)。 - */ - 'pragent:queueChanged': { - active: PragentRunInfo[]; - waiting: PragentRunInfo[]; - }; - /** 启动检测到新版本时推送(仅 hasUpdate=true 时发),renderer 据此提示。 */ - 'app:updateAvailable': UpdateCheckResult; - /** Agent 编排步骤流式推送:每产生一个 AgentStep 即发,renderer 据此实时呈现。 */ - 'agent:stepProgress': { sessionId: string; prLocalId: string; step: AgentStep }; - /** - * 某 PR 的多轮对话有新落盘消息(如后台 AutoPilot 评审收尾追加的「评审总结」)。renderer 若正打开 - * 该 PR 则据此重载会话,让后台产生的总结卡片即时出现(手动评审走 invoke 返回后自行重载,不依赖此事件)。 - */ - 'agent:conversationChanged': { prLocalId: string }; - /** - * 运行中(思考或派发工具)的编排 Agent 所属 PR 集合变化时推送:手动 `agent:run` / `agent:ask` - * 与 AutoPilot 后台评审一并计入。renderer 据此在 PR 列表项显示「执行中」指示——覆盖**纯思考阶段** - * (无活跃工具 run 时),补齐仅看运行队列时思考态缺失执行中标记的空档。 - */ - 'agent:runningChanged': { prLocalIds: string[] }; - /** - * 某 PR 的评审状态被清除(清空执行历史时一并清掉 AutoPilot 台账)。renderer 据此即时清掉 PR 列表 - * 该 PR 的评审建议 ★ 徽标,避免清空后仍残留陈旧评审状态(不必等下个 poll 重取台账)。 - */ - 'agent:reviewStatusCleared': { prLocalId: string }; -} - -export type IpcEventName = keyof IpcEvents; - -export interface ConnectionSummary { - connectionId: string; - /** 来自 config 的 display_name */ - displayName: string; - /** ping 后缓存的当前 PAT 所属用户;ping 未完成或失败时为 null */ - user: PlatformUser | null; - /** 该连接所属平台的能力描述符;渲染层据此 显/隐/灰(多平台降级,见 platform.ts) */ - capabilities: PlatformCapabilities; -} - -/** - * Typed IPC channel contract. - * - * Each entry maps a channel name to its request and response types. - * The preload bridge and main handlers both reference this map so that - * Renderer ↔ Main calls stay end-to-end type-safe. - */ -export interface IpcChannels { - 'app:info': { request: void; response: AppInfo }; - 'app:paths': { request: void; response: AppPaths }; - 'app:prAgentStatus': { request: void; response: PrAgentStatus }; - /** 调 Electron shell.openPath 让 OS 默认编辑器打开 config.yaml */ - 'app:openConfigFile': { request: void; response: void }; - /** 调 shell.openPath 在系统文件管理器打开当前生效的 Agent 目录(不存在则先建)。 */ - 'app:openAgentDir': { request: void; response: void }; - /** 打开 Electron DevTools(分离窗口) */ - 'app:openDevTools': { request: void; response: void }; - /** 手动检测版本更新(设置页「检查更新」)。仅检测 + 返回结果,不下载 / 安装; - * 结果同时缓存进 main 单一真相源并在有新版时广播 app:updateAvailable,使状态栏同步。 */ - 'app:checkUpdate': { request: void; response: UpdateCheckResult }; - /** 读取 main 缓存的最近一次成功更新检测结果(不发起网络请求)。供窗口 / 状态栏挂载时水合, - * 无缓存(尚未检测过)时返回 null。 */ - 'app:getUpdateStatus': { request: void; response: UpdateCheckResult | null }; - /** - * 渲染层日志回传:把渲染进程的错误 / 未捕获异常转发到 main,落进同一份 meebox.log - * (renderer 自己的 console 不进文件)。preload 装 window.onerror / unhandledrejection - * 调用。`scope` 固定 'renderer',`meta` 任意结构化上下文(如 stack / url)。 - */ - 'log:write': { - request: { - level: 'error' | 'warn' | 'info' | 'debug'; - msg: string; - meta?: Record; - }; - response: void; - }; - /** - * 用系统默认浏览器打开 URL (shell.openExternal)。评论 markdown 内链点击 → 强制 - * 外部打开,避免 Electron 在 app window 内跳转覆盖整个界面 - */ - 'app:openExternal': { request: { url: string }; response: void }; - /** - * 调起系统原生目录选择对话框;用户取消返回 path: null。 - * defaultPath 可空,作为初始定位目录。 - */ - 'dialog:pickDirectory': { - request: { defaultPath?: string; title?: string }; - response: { path: string | null }; - }; - /** 各连接的 ping 后缓存:当前用户 + display_name,Header 用 */ - 'app:connections': { request: void; response: ConnectionSummary[] }; - /** - * 按 (connectionId, slug) 拉用户头像 data URL;主进程缓存命中直接返回。 - * 平台不支持 / 网络失败 / 用户无头像时返回 null,renderer 走 initials 回退。 - */ - 'app:userAvatar': { - // avatarUrl 可选:平台返回的头像直链(GitHub 机器人必须靠它);缺省时 main 按 slug 推导 - request: { connectionId: string; slug: string; avatarUrl?: string }; - response: { dataUrl: string } | null; - }; - /** - * 拉评论 body 内嵌图片 (`![alt](url)`)。url 可能是 Bitbucket attachment 绝对/相对地址, - * 私有实例需要带 PAT 才能取 → renderer `` 标签无法直接 fetch,必须走 main 代理。 - * 返回 data URL 给 renderer 拼到 ``;获取失败 (404 / 跨 host / 非图片) 返回 null - */ - 'comments:fetchAttachment': { - request: { localId: string; url: string }; - response: { dataUrl: string } | null; - }; - /** - * 对已有评论发回复。提交成功后 main 端会刷新 comments cache + broadcast - * comments:changed 事件,renderer 各组件重新拉取列表自动展示新 reply - */ - 'comments:reply': { - request: { localId: string; parentCommentId: string; body: string }; - response: PrComment; - }; - /** - * 删除自己作者的远端评论。Bitbucket 要求带 version (乐观锁),调用方从已有 PrComment - * 拿;不一致 / 评论已有回复 / 自己不是作者都会失败 (Bitbucket 409/403)。成功后 main - * 端清空评论缓存 + broadcast comments:changed,UI 自动重拉刷新 - */ - 'comments:delete': { - request: { localId: string; commentId: string; version: number }; - response: void; - }; - /** - * 编辑自己作者评论的 body。Bitbucket PUT 同样要 version (乐观锁) — 不一致回 409, - * 上层应提示"远端已更新,请刷新后重试"并拒绝静默覆盖。Bitbucket 允许编辑带 reply - * 的评论 (跟 delete 区别)。成功后 main 端清评论缓存 + 广播 - * comments:changed,UI 自动重拉显示新文本 - */ - 'comments:edit': { - request: { - localId: string; - commentId: string; - version: number; - body: string; - }; - response: PrComment; - }; - 'config:read': { request: void; response: Config }; - 'prs:list': { request: void; response: StoredPullRequest[] }; - 'prs:refresh': { request: void; response: PollResult }; - /** Poller 最近一次完成时间(ISO 或 null);启动时初始化用 */ - 'prs:lastSync': { request: void; response: { at: string | null } }; - 'prs:setLocalStatus': { - request: { localId: string; status: LocalPrStatus }; - response: StoredPullRequest | null; - }; - /** - * 合并 PR 到目标分支(仅对 canMerge=true 的 PR 暴露入口)。成功后远端 PR 转 - * MERGED,调用方应自行刷新列表(下一轮 poll 会软删该 PR)。失败抛错冒泡到 renderer。 - */ - 'prs:merge': { - request: { localId: string }; - response: void; - }; - /** 同步 PR 所属 repo 的本地镜像(必要时 clone,否则 fetch),返回镜像绝对路径 */ - 'repo:sync': { - request: { localId: string }; - response: { mirrorPath: string; freshClone: boolean }; - }; - /** 列出 PR baseSha → headSha 之间变更的文件(自动先 sync mirror) */ - 'diff:listChangedFiles': { - request: { localId: string }; - response: DiffChangedFile[]; - }; - /** 读取 PR base 或 head 一侧某文件的内容(二进制返回 {binary:true}) */ - 'diff:getFileContent': { - request: { localId: string; side: DiffSide; path: string }; - response: DiffFileContent; - }; - /** - * 拉取 PR 上的已有评论(inline + summary 都拉,renderer 自己分)。 - * - * 默认走 cache + pr_updated_at stale 比对:命中回缓存,stale/miss 拉远端。 - * 但本地 PR.updatedAt 来自 poller 周期性拉,可能滞后 — 远端新增评论后, - * 本地 updatedAt 不变 → cache 误判命中 → 不刷新。打开 PR 时 renderer 应该 - * 传 force=true 跳过 stale 比对强制远端拉一次,确保 badge 计数 / inline - * 评论是最新的 - */ - 'diff:listComments': { - request: { localId: string; force?: boolean }; - response: PrComment[]; - }; - /** - * 仅读评论缓存里的总条数 (inline + summary 顶层条目数;不展开 replies),**不** - * 打远端。UI 用于 tab 角标 "评论 (N)" 的懒展示:缓存有就直接显示,缓存空就不显示。 - * 用户切到 Comments 标签时触发 `diff:listComments` 拉远端 + 写缓存,下次进 PR - * 角标就有数字了。 - */ - 'diff:commentCountCached': { - request: { localId: string }; - response: { count: number } | null; - }; - /** 拉取 PR 包含的 commits,newest first */ - 'diff:listCommits': { - request: { localId: string }; - response: PrCommit[]; - }; - /** - * 本地 git rev-list 算 PR 引入的 commit 数 (base..head)。完全走本地 bare 镜像, - * 不打远端;任一 sha 不在镜像 (尚未 sync 到本 PR 范围) → null。 - * UI 用于 Commits 标签页角标的懒展示,跟 diff:commentCountCached 同模式 - */ - 'diff:commitCount': { - request: { localId: string }; - response: { count: number } | null; - }; - /** - * 给 head 侧文件跑 git blame;同时返回 PR 引入的 head 行号集合, - * renderer 能区分"未变更行(出 blame)"vs"PR 改动行(出色带占位)"。 - */ - 'diff:getBlame': { - request: { localId: string; path: string }; - response: { - /** 仅未变更行的 blame(已过滤掉 PR 改动行) */ - lines: DiffBlameLine[]; - /** PR 引入的 head 行号 (added / modified),用于 blame 列画色带占位 */ - changedLines: number[]; - }; - }; - /** 计算本地所有 repo 镜像的总占用字节数(设置页用) */ - 'repo:getTotalSize': { request: void; response: { totalBytes: number } }; - /** 写入新的 repos_dir 到 config.yaml;重启生效 */ - 'config:setReposDir': { request: { reposDir: string }; response: void }; - /** - * 写入 UI 语言到 config.yaml 并**即时生效**:主进程 i18n 立刻 changeLanguage(后续 dialog/ - * 错误文案 + 下次 pragent:run 的响应语言随之),渲染层另行 i18n.changeLanguage 实时切换。 - * 与代理/连接同属热生效项,无需依赖设置页全局保存。 - */ - 'config:setLanguage': { request: { language: SupportedLanguage }; response: void }; - /** 写入 LLM Provider 配置到 config.yaml;下次 pragent:run 自动用新值 */ - 'config:setLlm': { request: { llm: Config['llm'] }; response: void }; - /** 写入 agent.dir 到 config.yaml;下次 pragent:run 立即生效 (现读规则) */ - 'config:setAgent': { request: { agent: Config['agent'] }; response: void }; - /** 翻转 AutoPilot 开关 (agent.autopilot.enabled) 并写 config.yaml;下次 poll tick 生效。 */ - 'agent:setAutopilotEnabled': { request: { enabled: boolean }; response: void }; - /** 写入轮询间隔 (秒,60~900 整数) 到 config.yaml,并热替换 poller 定时器,无需重启 */ - 'config:setPoller': { request: { interval_seconds: number }; response: void }; - /** - * 写入网络代理配置到 config.yaml,并**热重建** adapter(REST 经代理即时生效)。 - * pr-agent / git 出口下次操作读最新配置,无需重启。 - */ - 'config:setProxy': { request: { proxy: Config['proxy'] }; response: void }; - /** 用给定代理配置试连一个外部地址,验证代理是否可用;不写配置。 */ - 'config:testProxy': { - request: { proxy: Config['proxy'] }; - response: { ok: boolean; reason?: string }; - }; - /** - * 写入连接列表 + 当前启用连接到 config.yaml,并**热重建** adapter/poller 即时生效 - * (无需重启)。active 那条被轮询,其余仅保留配置。 - */ - 'config:setConnections': { - request: { connections: Config['connections']; active_connection_id: string }; - response: void; - }; - /** 用草稿 url/token 临时起 adapter ping,保存前测试连接是否可达;不写配置。 */ - 'config:testConnection': { - request: { base_url: string; token: string; kind?: PlatformKind }; - response: PingResult; - }; - /** - * 配置过程中自动把连接 + LLM 草稿写入 config.yaml(防丢失),但**不应用到运行时** - * (不 reconfigure adapter/poller、不更新内存 config)——重启或点底栏「保存」才生效。 - */ - 'config:autosaveDraft': { - request: { - connections: Config['connections']; - active_connection_id: string; - llm: Config['llm']; - }; - response: void; - }; - /** - * 给指定 PR 查 `/rules` 当前命中的规则 (按 priority desc + path asc 取首条)。 - * 调用方传 tool 区分 /describe / /review (规则可能只对其中一个 tool 生效)。 - * agent.dir 未配置 / 整体禁用 / 无命中 → 返回 null。 - */ - 'rules:matchForPr': { - request: { localId: string; tool: ReviewRunTool }; - response: { - id: string; - filePath: string; - priority: number; - tools: ReviewRunTool[]; - instructions: string; - } | null; - }; - /** - * 触发一次 pr-agent /describe 或 /review。同步等待执行结束(可能数十秒到数分钟), - * 期间通过 pragent:runProgress 事件推送 stdout / stderr 行。返回最终 ReviewRun - * 状态 (succeeded / failed)。pr-agent 不可用时 reject。 - */ - 'pragent:run': { - /** - * tool='ask' 时 question 必填,作为 pr-agent CLI 的位置参数传给 ask 子命令。 - * tool='describe'/'review' 时 question 字段被忽略。 - */ - request: { localId: string; tool: ReviewRunTool; question?: string }; - response: ReviewRun; - }; - /** - * 对指定 PR 跑一次 Agent 评审微流程(describe→review→条件追问→总结)。同步等待, - * 期间经 agent:stepProgress 推送步骤;返回收尾后的 AgentSession(含 summary / - * recommendation)。pr-agent 不可用时 reject。 - */ - 'agent:run': { - request: { localId: string }; - response: AgentSession; - }; - /** - * 对指定 PR 跑自由规划 Agent(自然语言入口「对话即委派」)。同步等待,步骤经 - * agent:stepProgress 推送;返回收尾会话(summary = Agent 最终回答)。 - */ - 'agent:ask': { - request: { localId: string; question: string }; - response: AgentSession; - }; - /** 暂停当前 PR 的 Agent 运行(abort);会话置 paused、保态。 */ - 'agent:stop': { request: { localId: string }; response: { ok: boolean } }; - /** - * 读取指定 PR 已落盘的 Agent 会话(含收尾 summary / recommendation);无则返回 null。 - * 供 UI 打开 PR 时恢复「评审总结」卡片——总结归属其发起 PR、跨 PR 切换不丢失、不串台。 - */ - 'agent:getSession': { request: { localId: string }; response: AgentSession | null }; - /** - * 读取指定 PR 的多轮对话消息(用户输入 + Agent 回答,按时间升序);无则空数组。 - * UI 据此渲染多轮会话;跨 PR 切换 / 重启后恢复。 - */ - 'agent:getConversation': { request: { localId: string }; response: AgentMessage[] }; - /** - * 读取指定 PR 已落盘的 Agent 过程步骤(transcript,按时间升序);无则空数组。 - * UI 据此恢复「过程化跟踪」的思考步骤——跨 PR 切换 / 重启后不丢失(步骤随产生增量落盘)。 - */ - 'agent:getTranscript': { request: { localId: string }; response: AgentStep[] }; - /** - * 批量读 AutoPilot 台账:返回各 PR 已自动评审的 recommendation(仅 decision=review 且有 - * 建议者)。PR 列表据此显示徽标,无需逐个加载会话。 - */ - 'agent:autopilotLedgers': { - request: { localIds: string[] }; - response: Record; - }; - /** - * 列出指定 PR 的全部草稿 (pending / edited / posted / rejected 都返回,UI 端按 - * status 过滤显示 / 折叠)。 - */ - 'drafts:list': { - request: { localId: string }; - response: ReviewDraft[]; - }; - /** - * 创建一条草稿。id / createdAt / updatedAt 由 main 端生成,调用方传业务字段即可。 - * 调用约定:origin='finding' 时必须传 source;origin='manual' 时不要传 source。 - * 成功后 main 端广播 `drafts:changed` 事件。 - */ - 'drafts:create': { - request: { - localId: string; - draft: Omit; - }; - response: ReviewDraft; - }; - /** - * 部分更新一条草稿。规则: - * - 编辑 body 且 status='pending' → 自动转 'edited' - * - 显式传 status (e.g., 'rejected') → 按传入值覆盖 - * - 找不到 draftId 返回 null (不抛错,UI 静默兜底) - */ - 'drafts:update': { - request: { - localId: string; - draftId: string; - patch: Partial>; - }; - response: ReviewDraft | null; - }; - /** 删除一条草稿。删 posted 草稿是允许的 (只清本地,远端 comment 不动) */ - 'drafts:delete': { - request: { localId: string; draftId: string }; - response: void; - }; - /** - * 批量发布草稿到远端:每条 draft 经 adapter.publishInlineComment 发到 Bitbucket, - * 成功 → 本地 draft status='posted' + 写 posted_remote_id;失败 → 保持原 status - * 不变并把错误收集到 results 里。**单条失败不中断后续条目** —— 跟 Bitbucket web UI - * "Start review" 行为对齐 (那边也是逐条 POST,某条 400 不影响其它)。 - * - * 一次性发完后 main 会: - * 1. 广播 `drafts:changed` —— DiffView / FindingCard 重拉草稿换 status chip - * 2. force-refresh Bitbucket PR 评论 (跳缓存) + 广播 `comments:changed`,让 CommentsPanel - * 立即看到自己刚发布的评论,不用等下一轮 poller - * - * 调用方 (renderer modal) 据 results 显示 "成功 N 失败 M" + 错误明细 - */ - 'drafts:publishBatch': { - request: { localId: string; draftIds: string[] }; - response: { - results: Array<{ - draftId: string; - ok: boolean; - /** 成功时填,跟落库的 draft.posted_remote_id 同值 */ - postedRemoteId?: string; - /** 失败时填,人读错因 (Bitbucket REST 4xx body 经过 PlatformError 包装) */ - error?: string; - }>; - }; - }; - /** - * 列出某 PR 的历史 run,newest first。支持时间戳游标分页: - * - limit:截到 N 条;省略 = 不限(renderer 端慎用,规模大时可能慢) - * - beforeId:游标,返回 runId **严格小于** 此值的条目;省略 = 不限上界 - * - * runId 是时序字典序 (`yyyymmdd-HHmmss-mmm`),"取游标后 N 条" 即"取此时刻之前的 N 条" - */ - 'pragent:listRuns': { - request: { localId: string; limit?: number; beforeId?: string }; - response: ReviewRun[]; - }; - /** 单条 run 查询(用于 renderer 在事件断流后兜底刷新) */ - 'pragent:getRun': { - request: { localId: string; runId: string }; - response: ReviewRun | null; - }; - /** 清空指定 PR 的全部 run 历史记录(仅该 PR 生效)。返回删除条数。 */ - 'pragent:clearRuns': { - request: { localId: string }; - response: { cleared: number }; - }; - /** - * 取消一个 run。语义跟 run 当前状态相关: - * - 跟 active 匹配 → SIGKILL 子进程,落盘 status='cancelled' - * - 在 waiting 队列里 → 从队列删除,**不**写盘 (从未真正跑过);触发 pragent:run - * 原调用方的 Promise reject 让 ChatPane handleRun 走 error 分支 - * - 都不匹配 (已结束 / 不存在) → 静默 no-op (返回 ok:false) - */ - 'pragent:cancel': { - request: { runId: string }; - response: { ok: boolean }; - }; - /** - * 查询当前队列快照 (active + waiting);renderer 启动 / 重连时拉一下, - * 跟 queueChanged 事件配套兜底。 - */ - 'pragent:queue': { - request: void; - response: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; - }; -} - -export type IpcChannelName = keyof IpcChannels; - -export interface IpcBridge { - invoke( - channel: K, - req: IpcChannels[K]['request'], - ): Promise; - /** 订阅 main → renderer 推送事件,返回取消订阅函数。 */ - subscribe(event: E, handler: (data: IpcEvents[E]) => void): () => void; -} diff --git a/packages/shared/src/sync-progress.ts b/packages/shared/src/sync-progress.ts new file mode 100644 index 0000000..d9b2fef --- /dev/null +++ b/packages/shared/src/sync-progress.ts @@ -0,0 +1,16 @@ +/** + * 仓库 sync 进度事件。RepoMirrorManager 在 clone/fetch 期间通过 onProgress 回调发出; + * main 进程经 IPC(`sync:progress` 事件)转推到 renderer。既被 @meebox/repo-mirror(产出方) + * 也被 @meebox/ipc(IpcEvents 载荷)引用,故置于 shared 作为共享领域类型。 + */ +export interface SyncProgressEvent { + /** "host/projectKey/repoSlug" 标识 */ + repo: string; + phase: 'start' | 'progress' | 'done' | 'error'; + /** simple-git 阶段名(compressing / receiving / resolving / ...) */ + stage?: string; + /** 0-100 */ + percent?: number; + /** 人读消息 */ + message?: string; +} From e66ba357c004f6ed015c23974b6691f9f20e1a2d Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 13:37:30 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor(ipc):=20IPC=20handler=20=E6=8A=BD?= =?UTF-8?q?=E5=8F=96=E4=B8=BA=20controller=20=E5=B1=82=20+=20PR=20?= =?UTF-8?q?=E9=A2=86=E5=9F=9F=E6=9C=8D=E5=8A=A1=E7=B1=BB=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接上一轮 service 层抽取,进一步按 request-controller 模式整理 main 侧 IPC: - handler 与 ipcMain.handle 解耦:每个 handler 是具名 controller(统一签名 (ctx, req, evt) => …),通道字符串只在 ipc.ts 集中 handle() 一次绑定,带每通道行内注释 - controller 从 service 层剥离到 apps/desktop/src/main/controllers/(app/pr/config/agent), register.ts 提供 IpcController 类型 + handle() 薄类型包装 - 解散 services/common/:broadcast / usage 上提为独立纯函数模块;pr-lookup / mirror / comments-cache 合并为 PrService 类(强 PR 领域,依赖构造注入),controller 经 ctx.pr.* 调用 - run-queue / agent-orchestrator 保持工厂函数(内聚单域,不强行类化) - 主进程运行时上下文 IpcContext 改名 ServiceContext(不再误导成属于 @meebox/ipc 契约包) 纯结构性重构,不改运行行为;lint / typecheck / build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/agent.ts | 66 ++ apps/desktop/src/main/controllers/app.ts | 176 +++++ apps/desktop/src/main/controllers/config.ts | 138 ++++ apps/desktop/src/main/controllers/pr.ts | 365 ++++++++++ apps/desktop/src/main/controllers/register.ts | 31 + apps/desktop/src/main/ipc.ts | 116 +++- .../src/main/services/agent-orchestrator.ts | 6 +- apps/desktop/src/main/services/agent/index.ts | 122 ---- apps/desktop/src/main/services/app.ts | 36 + apps/desktop/src/main/services/app/index.ts | 234 ------- .../main/services/{common => }/broadcast.ts | 0 apps/desktop/src/main/services/comments.ts | 45 ++ .../main/services/common/comments-cache.ts | 20 - .../src/main/services/common/mirror.ts | 78 --- .../src/main/services/common/pr-lookup.ts | 53 -- .../desktop/src/main/services/config/index.ts | 189 ------ apps/desktop/src/main/services/context.ts | 49 +- apps/desktop/src/main/services/pr-service.ts | 130 ++++ apps/desktop/src/main/services/pr/index.ts | 623 ------------------ apps/desktop/src/main/services/run-queue.ts | 17 +- .../src/main/services/{common => }/usage.ts | 0 21 files changed, 1122 insertions(+), 1372 deletions(-) create mode 100644 apps/desktop/src/main/controllers/agent.ts create mode 100644 apps/desktop/src/main/controllers/app.ts create mode 100644 apps/desktop/src/main/controllers/config.ts create mode 100644 apps/desktop/src/main/controllers/pr.ts create mode 100644 apps/desktop/src/main/controllers/register.ts delete mode 100644 apps/desktop/src/main/services/agent/index.ts create mode 100644 apps/desktop/src/main/services/app.ts delete mode 100644 apps/desktop/src/main/services/app/index.ts rename apps/desktop/src/main/services/{common => }/broadcast.ts (100%) create mode 100644 apps/desktop/src/main/services/comments.ts delete mode 100644 apps/desktop/src/main/services/common/comments-cache.ts delete mode 100644 apps/desktop/src/main/services/common/mirror.ts delete mode 100644 apps/desktop/src/main/services/common/pr-lookup.ts delete mode 100644 apps/desktop/src/main/services/config/index.ts create mode 100644 apps/desktop/src/main/services/pr-service.ts delete mode 100644 apps/desktop/src/main/services/pr/index.ts rename apps/desktop/src/main/services/{common => }/usage.ts (100%) diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts new file mode 100644 index 0000000..081f261 --- /dev/null +++ b/apps/desktop/src/main/controllers/agent.ts @@ -0,0 +1,66 @@ +import { loadAgentRules } from '@meebox/agent'; +import { + getAgentConversation, + getAgentSession, + getAgentTranscript, + getAutopilotLedger, +} from '@meebox/poller'; +import { pickMatchingRule } from '@meebox/rules'; +import type { AgentRecommendationVerdict } from '@meebox/shared'; +import type { IpcController } from './register.js'; + +// ── Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 ── + +// 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。 +export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (ctx, req) => { + if (req.tool === 'ask') return null; + const pr = await ctx.pr.findPrOrThrow(req.localId); + const rules = await loadAgentRules(ctx.effectiveAgentDir(), { + onWarn: (msg, file) => ctx.logger.warn({ file }, `rules: ${msg}`), + }); + const matched = pickMatchingRule(rules, { + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + targetBranch: pr.targetRef.displayId, + tool: req.tool, + }); + if (!matched) return null; + return { + id: matched.id, + filePath: matched.filePath, + priority: matched.priority, + tools: [...matched.tools], + instructions: matched.instructions, + }; +}; + +// 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。 +export const runReview: IpcController<'agent:run'> = async (ctx, req) => + ctx.orchestrator.runReview(await ctx.pr.findPrOrThrow(req.localId)); + +// 自由规划 Agent(自然语言「对话即委派」)。 +export const runPlanning: IpcController<'agent:ask'> = async (ctx, req) => + ctx.orchestrator.runPlanning(await ctx.pr.findPrOrThrow(req.localId), req.question); + +// 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。 +export const stopAgent: IpcController<'agent:stop'> = (ctx, req) => ctx.orchestrator.stop(req.localId); + +// 读已落盘的 Agent 会话 / 多轮对话 / 过程步骤(跨 PR 切换、重启后恢复)。 +export const getSession: IpcController<'agent:getSession'> = (ctx, req) => + getAgentSession(ctx.stateStore, req.localId); +export const getConversation: IpcController<'agent:getConversation'> = (ctx, req) => + getAgentConversation(ctx.stateStore, req.localId); +export const getTranscript: IpcController<'agent:getTranscript'> = (ctx, req) => + getAgentTranscript(ctx.stateStore, req.localId); + +// 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。 +export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (ctx, req) => { + const out: Record = {}; + for (const id of req.localIds) { + const ledger = await getAutopilotLedger(ctx.stateStore, id); + if (ledger?.decision === 'review' && ledger.recommendation) { + out[id] = ledger.recommendation; + } + } + return out; +}; diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts new file mode 100644 index 0000000..219fb4c --- /dev/null +++ b/apps/desktop/src/main/controllers/app.ts @@ -0,0 +1,176 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { app, BrowserWindow, dialog, shell } from 'electron'; +import type { Logger } from 'pino'; +import { t } from '../i18n/index.js'; +import { buildAppInfo, buildConnectionSummaries } from '../services/app.js'; +import { sniffImageContentType } from '../utils/image.js'; +import { checkForUpdate } from '../utils/update-check.js'; +import { getLastUpdateResult, publishUpdateResult } from '../utils/update-state.js'; +import type { IpcController } from './register.js'; + +// ── GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 ── + +export const readAppInfo: IpcController<'app:info'> = (ctx) => buildAppInfo(ctx.bootstrap); + +export const readAppPaths: IpcController<'app:paths'> = (ctx) => ctx.bootstrap.paths; + +export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = (ctx) => + ctx.getPrAgentStatus(); + +// 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。 +let rendererLogger: Logger | undefined; +export const writeRendererLog: IpcController<'log:write'> = (ctx, req) => { + rendererLogger ??= ctx.logger.child({ scope: 'renderer' }); + const obj = req.meta ?? {}; + switch (req.level) { + case 'error': + rendererLogger.error(obj, req.msg); + break; + case 'warn': + rendererLogger.warn(obj, req.msg); + break; + case 'info': + rendererLogger.info(obj, req.msg); + break; + case 'debug': + rendererLogger.debug(obj, req.msg); + break; + } +}; + +// 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。 +export const listConnections: IpcController<'app:connections'> = (ctx) => + buildConnectionSummaries(ctx.bootstrap, ctx.connectionRuntime.adapters); + +// 头像两级缓存:进程内 Map(含 null 负缓存)+ 磁盘文件(TTL 7 天,按 mtime 判过期)。 +const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const avatarMem = new Map(); + +// 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。 +export const getUserAvatar: IpcController<'app:userAvatar'> = async (ctx, req) => { + const { logger, connectionRuntime, bootstrap } = ctx; + const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); + const memKey = `${req.connectionId}|${req.slug}`; + if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; + + const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); + const filePath = path.join(avatarDir, `${hash}.bin`); + + // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) + try { + const stat = await fs.stat(filePath); + const age = Date.now() - stat.mtimeMs; + if (age < AVATAR_TTL_MS) { + const bytes = await fs.readFile(filePath); + const contentType = sniffImageContentType(bytes); + const result = { dataUrl: `data:${contentType};base64,${bytes.toString('base64')}` }; + avatarMem.set(memKey, result); + return result; + } + // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) + await fs.unlink(filePath).catch(() => undefined); + } catch { + // 文件不存在 / 读失败 → 走 fetch + } + + // 2) 没缓存 / 已过期:去远端拉 + const adapter = connectionRuntime.adapters.find( + (a) => a.connectionId === req.connectionId, + )?.adapter; + if (!adapter) { + avatarMem.set(memKey, null); + return null; + } + try { + const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); + if (!img) { + logger.debug({ connectionId: req.connectionId, slug: req.slug }, 'avatar fetch returned null'); + avatarMem.set(memKey, null); + return null; + } + // 落盘:best-effort,写失败不影响响应 + try { + await fs.mkdir(avatarDir, { recursive: true }); + await fs.writeFile(filePath, img.bytes); + } catch (writeErr) { + logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); + } + const base64 = Buffer.from(img.bytes).toString('base64'); + const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; + avatarMem.set(memKey, result); + logger.debug( + { hash, slug: req.slug, bytes: img.bytes.length, contentType: img.contentType }, + 'avatar fetched + cached to disk', + ); + return result; + } catch (err) { + logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); + avatarMem.set(memKey, null); + return null; + } +}; + +// OS 默认编辑器打开 config.yaml。 +export const openConfigFile: IpcController<'app:openConfigFile'> = async (ctx) => { + const err = await shell.openPath(ctx.bootstrap.paths.configFile); + if (err) throw new Error(`failed to open config.yaml: ${err}`); +}; + +// 文件管理器打开当前生效的 Agent 目录(不存在则先建)。 +export const openAgentDir: IpcController<'app:openAgentDir'> = async (ctx) => { + const dir = ctx.effectiveAgentDir(); + await fs.mkdir(dir, { recursive: true }).catch(() => undefined); + const err = await shell.openPath(dir); + if (err) throw new Error(`failed to open agent dir: ${err}`); +}; + +// 打开 DevTools(分离窗口)——需访问发起调用的 webContents。 +export const openDevTools: IpcController<'app:openDevTools'> = (_ctx, _req, evt) => { + evt.sender.openDevTools({ mode: 'detach' }); +}; + +// 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。 +export const checkUpdate: IpcController<'app:checkUpdate'> = async (ctx) => { + if (!ctx.bootstrap.config.update.check_enabled) { + return { + ok: false, + hasUpdate: false, + currentVersion: app.getVersion(), + error: 'update check disabled by config', + }; + } + const result = await checkForUpdate(app.getVersion(), ctx.bootstrap.config.proxy); + publishUpdateResult(result); + return result; +}; + +// 读 main 缓存的最近一次更新检测结果(不发请求)。 +export const getUpdateStatus: IpcController<'app:getUpdateStatus'> = () => getLastUpdateResult(); + +// 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。 +export const openExternal: IpcController<'app:openExternal'> = async (_ctx, req) => { + if (!/^https?:\/\//.test(req.url)) return; + await shell.openExternal(req.url); +}; + +// 系统原生目录选择对话框——需绑定到发起调用的窗口。 +export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (_ctx, req, evt) => { + const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; + const result = win + ? await dialog.showOpenDialog(win, { + title: req.title ?? t('dialog.selectDirectory'), + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }) + : await dialog.showOpenDialog({ + title: req.title ?? t('dialog.selectDirectory'), + defaultPath: req.defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { path: null }; + } + return { path: result.filePaths[0]! }; +}; diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts new file mode 100644 index 0000000..9637069 --- /dev/null +++ b/apps/desktop/src/main/controllers/config.ts @@ -0,0 +1,138 @@ +import { writeConfig } from '@meebox/config'; +import { buildDraftAdapter } from '../adapters.js'; +import { setMainLanguage } from '../i18n/index.js'; +import { testProxyConnectivity } from '../utils/proxy.js'; +import type { IpcController } from './register.js'; + +// ── 配置操作域 controllers:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连 ── + +export const readConfig: IpcController<'config:read'> = (ctx) => ctx.bootstrap.config; + +// 写 repos_dir(重启生效)。 +export const setReposDir: IpcController<'config:setReposDir'> = async (ctx, req) => { + const next = { + ...ctx.bootstrap.config, + workspace: { ...ctx.bootstrap.config.workspace, repos_dir: req.reposDir }, + }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); +}; + +// 写 UI 语言并即时生效:内存同步 + 主进程 i18n changeLanguage。 +export const setLanguage: IpcController<'config:setLanguage'> = async (ctx, req) => { + const next = { ...ctx.bootstrap.config, language: req.language }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.language = req.language; + setMainLanguage(req.language); + ctx.logger.info({ language: req.language }, 'language config updated'); +}; + +// 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。 +export const setLlm: IpcController<'config:setLlm'> = async (ctx, req) => { + const next = { ...ctx.bootstrap.config, llm: req.llm }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.llm = req.llm; + ctx.logger.info( + { profileCount: req.llm.profiles.length, activeId: req.llm.active_id }, + 'llm config updated', + ); +}; + +// 写 agent 配置(含 agent.dir);内存同步,下次 pragent:run 现读生效。 +export const setAgent: IpcController<'config:setAgent'> = async (ctx, req) => { + const next = { ...ctx.bootstrap.config, agent: req.agent }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.agent = req.agent; + ctx.logger.info({ agent: req.agent }, 'agent config updated'); +}; + +// 翻转 AutoPilot 开关;关→开时立即 poll 一轮按准入规则评估。 +export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = async (ctx, req) => { + const was = ctx.bootstrap.config.agent.autopilot.enabled; + const agent = { + ...ctx.bootstrap.config.agent, + autopilot: { ...ctx.bootstrap.config.agent.autopilot, enabled: req.enabled }, + }; + await writeConfig(ctx.bootstrap.paths.configFile, { ...ctx.bootstrap.config, agent }); + ctx.bootstrap.config.agent = agent; + ctx.logger.info({ enabled: req.enabled }, 'autopilot toggled'); + if (req.enabled && !was) { + void ctx.poller.tick(); + } +}; + +// 写连接列表 + 启用连接,热重建 adapter/poller 并立即 poll 一轮。 +export const setConnections: IpcController<'config:setConnections'> = async (ctx, req) => { + const next = { + ...ctx.bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.connections = req.connections; + ctx.bootstrap.config.active_connection_id = req.active_connection_id; + await ctx.reconfigureConnections(); + void ctx.poller.tick(); + ctx.logger.info( + { count: req.connections.length, activeId: req.active_connection_id }, + 'connections config updated (hot-reloaded)', + ); +}; + +// 写代理配置,热重建 adapter(REST 经代理即时生效)。 +export const setProxy: IpcController<'config:setProxy'> = async (ctx, req) => { + const next = { ...ctx.bootstrap.config, proxy: req.proxy }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.proxy = req.proxy; + await ctx.reconfigureConnections(); + ctx.logger.info( + { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, + 'proxy config updated (hot-reloaded)', + ); +}; + +// 用给定代理试连,验证可用性;不写配置。 +export const testProxy: IpcController<'config:testProxy'> = (_ctx, req) => + testProxyConnectivity(req.proxy); + +// 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason。 +export const testConnection: IpcController<'config:testConnection'> = async (ctx, req) => { + try { + return await buildDraftAdapter( + req.base_url, + req.token, + ctx.bootstrap.config.proxy, + req.kind, + ).ping(); + } catch (e) { + return { ok: false, reason: e instanceof Error ? e.message : String(e) }; + } +}; + +// 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 +export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (ctx, req) => { + const next = { + ...ctx.bootstrap.config, + connections: req.connections, + active_connection_id: req.active_connection_id, + llm: req.llm, + }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.logger.info( + { connections: req.connections.length, profiles: req.llm.profiles.length }, + 'connections/llm draft autosaved to config.yaml (not applied)', + ); +}; + +// 写轮询间隔(clamp 60~900)并热替换 poller 定时器,无需重启。 +export const setPoller: IpcController<'config:setPoller'> = async (ctx, req) => { + const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); + const next = { + ...ctx.bootstrap.config, + poller: { ...ctx.bootstrap.config.poller, interval_seconds: seconds }, + }; + await writeConfig(ctx.bootstrap.paths.configFile, next); + ctx.bootstrap.config.poller.interval_seconds = seconds; + ctx.poller.setIntervalSeconds(seconds); + ctx.logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); +}; diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts new file mode 100644 index 0000000..1524c58 --- /dev/null +++ b/apps/desktop/src/main/controllers/pr.ts @@ -0,0 +1,365 @@ +import { + clearAgentSession, + clearAutopilotLedger, + clearReviewRunsForPr, + createDraft, + deleteDraft, + getReviewRun, + isCommentsCacheStale, + listDrafts, + listReviewRunsForPr, + listStoredPullRequests, + readCommentsCache, + setLocalStatus, + updateDraft, + writeCommentsCache, +} from '@meebox/poller'; +import type { RepoIdentity } from '@meebox/repo-mirror'; +import type { PrComment } from '@meebox/shared'; +import { t } from '../i18n/index.js'; +import { annotateOwnership } from '../services/comments.js'; +import type { IpcController } from './register.js'; + +// ── PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 ── + +// 对已有评论发回复,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 +export const replyComment: IpcController<'comments:reply'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const reply = await adapter.replyToComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.parentCommentId, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return reply; +}; + +// 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 +export const deleteComment: IpcController<'comments:delete'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + await adapter.deleteComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); +}; + +// 编辑自己作者评论 body(带 version 乐观锁)。返回 updated 仅作乐观参考;清缓存 + 广播。 +export const editComment: IpcController<'comments:edit'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const updated = await adapter.editComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + req.commentId, + req.version, + req.body, + ); + await ctx.pr.invalidateCommentsCache(pr.localId); + return updated; +}; + +// 拉评论内嵌图片(私有实例需带 PAT,renderer 无法直接 fetch)→ 经 main 代理回 dataUrl。不缓存。 +export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async (ctx, req) => { + try { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterFor(pr); + if (!adapter) return null; + // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL + const res = await adapter.getAttachment(req.url, pr.repo); + if (!res) return null; + const base64 = Buffer.from(res.bytes).toString('base64'); + return { dataUrl: `data:${res.contentType};base64,${base64}` }; + } catch { + return null; + } +}; + +// 只展示当前活动连接的 PR(状态库可能仍存切换前其他连接的历史 PR)。 +export const listPrs: IpcController<'prs:list'> = async (ctx) => { + const activeId = ctx.bootstrap.config.active_connection_id; + const all = await listStoredPullRequests(ctx.stateStore); + return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; +}; + +// 立即跑一轮 poll。 +export const refreshPrs: IpcController<'prs:refresh'> = (ctx) => ctx.poller.tick(); + +// Poller 最近一次完成时间(启动初始化用)。 +export const getLastSync: IpcController<'prs:lastSync'> = (ctx) => ({ at: ctx.poller.getLastPollAt() }); + +// 设审阅状态:先写远端(失败前端不变),远端 OK 后落本地。 +export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + const remoteStatus = + req.status === 'approved' + ? 'approved' + : req.status === 'needs_work' + ? 'needsWork' + : 'unapproved'; + await adapter.setPullRequestReviewStatus( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + remoteStatus, + ); + return setLocalStatus(ctx.stateStore, req.localId, req.status); +}; + +// 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 +export const mergePr: IpcController<'prs:merge'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + await adapter.mergePullRequest( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); +}; + +// 确保 PR 所属 repo 镜像就位(快速路径命中即 noop)。 +export const syncRepo: IpcController<'repo:sync'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + return ctx.pr.ensureMirrorReadyForPr(pr); +}; + +// 列出 base..head 变更文件(先确保镜像 + 锚到固定 merge-base)。 +export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + await ctx.pr.ensureMirrorReadyForPr(pr); + const base = await ctx.pr.resolveDiffBaseSha(pr); + return ctx.repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); +}; + +// 读 base(固定 merge-base)/ head 一侧文件内容。 +export const getFileContent: IpcController<'diff:getFileContent'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const sha = req.side === 'base' ? await ctx.pr.resolveDiffBaseSha(pr) : pr.sourceRef.sha; + return ctx.repoMirror.getFileContent(id, sha, req.path); +}; + +// 仅读评论缓存条数(tab 角标懒展示),不打远端。 +export const getCommentCountCached: IpcController<'diff:commentCountCached'> = async (ctx, req) => { + const cache = await readCommentsCache(ctx.stateStore, req.localId); + if (!cache) return null; + return { count: cache.comments.length }; +}; + +// In-flight dedup: 打开 PR 时多个组件并行调 listComments(force:true),合并到同一 Promise,远端只打一次。 +const listCommentsInFlight = new Map>(); + +// 拉评论:cache + pr_updated_at stale 比对;force=true 跳缓存。同 localId in-flight 去重。 +export const listComments: IpcController<'diff:listComments'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const cache = await readCommentsCache(ctx.stateStore, pr.localId); + if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { + return cache.comments; + } + const existing = listCommentsInFlight.get(pr.localId); + if (existing) return existing; + const adapter = ctx.pr.adapterForOrThrow(pr); + const fetchPromise = adapter + .listPullRequestComments( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ) + .then((raw) => annotateOwnership(raw, adapter)) + .then(async (fresh) => { + await writeCommentsCache(ctx.stateStore, pr.localId, { + comments: fresh, + pr_updated_at: pr.updatedAt, + fetched_at: new Date().toISOString(), + }); + return fresh; + }) + .finally(() => { + listCommentsInFlight.delete(pr.localId); + }); + listCommentsInFlight.set(pr.localId, fetchPromise); + return fetchPromise; +}; + +// 拉 commits(不缓存,量少 + 进 commits 标签页才拉)。 +export const listCommits: IpcController<'diff:listCommits'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + return adapter.listPullRequestCommits( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + ); +}; + +// 本地 git 算 PR 引入提交数(base=targetRef.sha 排除合入的目标提交);镜像未齐返回 null。 +export const getCommitCount: IpcController<'diff:commitCount'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const n = await ctx.repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); + return n === null ? null : { count: n }; +}; + +// head 侧 blame;PR 引入行单独返回供 BlameColumn 画色带占位。 +export const getBlame: IpcController<'diff:getBlame'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const id = ctx.pr.repoIdentityFor(pr); + const base = await ctx.pr.resolveDiffBaseSha(pr); + const [allBlame, changedSet] = await Promise.all([ + ctx.repoMirror.getBlame(id, pr.sourceRef.sha, req.path), + ctx.repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path), + ]); + return { + lines: allBlame.filter((b) => !changedSet.has(b.line)), + changedLines: Array.from(changedSet).sort((a, b) => a - b), + }; +}; + +// 本地所有 repo 镜像总占用字节数(按 host|projectKey|repoSlug 去重)。 +export const getTotalSize: IpcController<'repo:getTotalSize'> = async (ctx) => { + const prs = await listStoredPullRequests(ctx.stateStore); + const seen = new Set(); + let total = 0; + for (const pr of prs) { + let id: RepoIdentity; + try { + id = ctx.pr.repoIdentityFor(pr); + } catch { + continue; + } + const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; + if (seen.has(key)) continue; + seen.add(key); + const r = await ctx.repoMirror.getSize(id); + total += r.totalBytes; + } + return { totalBytes: total }; +}; + +// 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 +export const runPragent: IpcController<'pragent:run'> = async (ctx, req) => { + if (!ctx.getPrAgentBridge()) { + throw new Error(t('prAgent.notReadyDetail')); + } + if (req.tool === 'ask' && !req.question?.trim()) { + throw new Error(t('prAgent.askNeedsQuestion')); + } + const pr = await ctx.pr.findPrOrThrow(req.localId); + return ctx.runQueue.enqueuePragentRun(pr, req.tool, req.question); +}; + +// 取消一个 run(active SIGKILL / waiting 出队)。 +export const cancelPragent: IpcController<'pragent:cancel'> = (ctx, req) => + ctx.runQueue.cancel(req.runId); + +// 当前队列快照(启动 / 重连兜底)。 +export const getQueue: IpcController<'pragent:queue'> = (ctx) => ctx.runQueue.snapshot(); + +// 列某 PR 历史 run(游标分页)。 +export const listRuns: IpcController<'pragent:listRuns'> = (ctx, req) => + listReviewRunsForPr(ctx.stateStore, req.localId, { limit: req.limit, beforeId: req.beforeId }); + +// 单条 run 查询。 +export const getRun: IpcController<'pragent:getRun'> = (ctx, req) => + getReviewRun(ctx.stateStore, req.localId, req.runId); + +// 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 +export const clearRuns: IpcController<'pragent:clearRuns'> = async (ctx, req) => { + await clearAgentSession(ctx.stateStore, req.localId); + await clearAutopilotLedger(ctx.stateStore, req.localId); + ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); + return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) }; +}; + +// 列某 PR 全部草稿。 +export const getDrafts: IpcController<'drafts:list'> = (ctx, req) => + listDrafts(ctx.stateStore, req.localId); + +// 创建草稿;IPC 边界再挡一道 origin/source 约束避免脏数据进盘。 +export const addDraft: IpcController<'drafts:create'> = async (ctx, req) => { + const { draft, localId } = req; + if (draft.origin === 'finding' && !draft.source) { + throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); + } + if (draft.origin === 'manual' && draft.source) { + throw new Error('drafts:create: origin=manual 不应该传 source'); + } + const created = await createDraft(ctx.stateStore, localId, draft); + ctx.broadcast('drafts:changed', { localId }); + return created; +}; + +// 部分更新草稿(pending 编辑 body 自动转 edited;找不到返回 null)。 +export const patchDraft: IpcController<'drafts:update'> = async (ctx, req) => { + const updated = await updateDraft(ctx.stateStore, req.localId, req.draftId, req.patch); + if (updated) ctx.broadcast('drafts:changed', { localId: req.localId }); + return updated; +}; + +// 删除草稿。 +export const removeDraft: IpcController<'drafts:delete'> = async (ctx, req) => { + await deleteDraft(ctx.stateStore, req.localId, req.draftId); + ctx.broadcast('drafts:changed', { localId: req.localId }); +}; + +// 批量发布草稿:逐条 publishInlineComment,单条失败不中断;成功即删本地草稿。 +// 整批跑完广播 drafts:changed;有任一成功则 force-refresh 评论 + 广播 comments:changed。 +export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (ctx, req) => { + const pr = await ctx.pr.findPrOrThrow(req.localId); + const adapter = ctx.pr.adapterForOrThrow(pr); + + // 拉一次当前草稿池:localId → id → draft,避免循环里反复 listDrafts 的 O(N²) IO + const allDrafts = await listDrafts(ctx.stateStore, req.localId); + const draftById = new Map(allDrafts.map((d) => [d.id, d])); + + const results: { draftId: string; ok: boolean; postedRemoteId?: string; error?: string }[] = []; + let anyPublished = false; + for (const draftId of req.draftIds) { + const draft = draftById.get(draftId); + if (!draft) { + results.push({ draftId, ok: false, error: t('drafts.notFound') }); + continue; + } + // rejected 不发(用户决断不发)。posted 不守卫:发布成功即删本地草稿,不存历史 posted 态。 + if (draft.status === 'rejected') { + results.push({ draftId, ok: false, error: t('drafts.rejected') }); + continue; + } + try { + // ReviewDraftAnchor → PrCommentAnchor:side 保守映射 new→added / old→removed; + // 多行落 endLine(评论出现在标注范围下方,不打断从上往下阅读)。命中 context 行 + // Bitbucket 回 400,错误收进 results 给用户看。 + const posted = await adapter.publishInlineComment( + { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, + pr.remoteId, + { + path: draft.anchor.path, + line: draft.anchor.endLine, + side: draft.anchor.side, + lineType: draft.anchor.side === 'old' ? 'removed' : 'added', + }, + draft.body, + ); + // 发布成功 = 本地草稿使命完成,直接删掉(远端评论由下面 force-refresh 拉回承接显示)。 + await deleteDraft(ctx.stateStore, req.localId, draftId); + anyPublished = true; + results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + ctx.logger.warn( + { localId: req.localId, draftId, err: msg }, + 'drafts:publishBatch: single draft failed', + ); + results.push({ draftId, ok: false, error: msg }); + } + } + + ctx.broadcast('drafts:changed', { localId: req.localId }); + if (anyPublished) { + await ctx.pr.invalidateCommentsCache(pr.localId); + } + return { results }; +}; diff --git a/apps/desktop/src/main/controllers/register.ts b/apps/desktop/src/main/controllers/register.ts new file mode 100644 index 0000000..68ffc23 --- /dev/null +++ b/apps/desktop/src/main/controllers/register.ts @@ -0,0 +1,31 @@ +import { ipcMain, type IpcMainInvokeEvent } from 'electron'; +import type { IpcChannelName, IpcChannels } from '@meebox/ipc'; +import type { ControllerContext } from '../services/context.js'; + +/** + * IPC controller 的统一签名:入参 `(ctx, req, evt)`,返回该通道 response(同步或异步)。 + * - ctx:controller 层共享上下文(依赖 + 公共工具 + 跨域 service) + * - req:该通道的强类型请求体 + * - evt:electron IpcMainInvokeEvent(仅少数需窗口上下文的 controller 用,如对话框 / DevTools) + * + * 泛型约束 `K extends IpcChannelName` 必需:req/response 由 `IpcChannels[K]` 索引取出,K 必须是 + * 合法通道名才能索引。controller 一律写成具名函数 `const xxx: IpcController<'channel'> = …`。 + */ +export type IpcController = ( + ctx: ControllerContext, + req: IpcChannels[K]['request'], + evt: IpcMainInvokeEvent, +) => IpcChannels[K]['response'] | Promise; + +/** + * 把一个具名 controller 绑定到 `ipcMain.handle`:薄类型包装,仅把 ipcMain 的 `(evt, req)` 适配为 + * controller 约定的 `(ctx, req, evt)`。泛型 K 同时约束 channel 字面量与 controller 的通道类型, + * 绑错(channel 与 controller 通道不一致)即编译报错。各域 `register*Controllers(ctx)` 调它注册。 + */ +export function handle( + channel: K, + ctx: ControllerContext, + controller: IpcController, +): void { + ipcMain.handle(channel, (evt, req: IpcChannels[K]['request']) => controller(ctx, req, evt)); +} diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c770511..590857d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,38 +1,118 @@ +import * as agent from './controllers/agent.js'; +import * as app from './controllers/app.js'; +import * as config from './controllers/config.js'; +import * as pr from './controllers/pr.js'; +import { handle } from './controllers/register.js'; import { createAgentOrchestratorService } from './services/agent-orchestrator.js'; -import { registerAgentHandlers } from './services/agent/index.js'; -import { registerAppHandlers } from './services/app/index.js'; -import { registerConfigHandlers } from './services/config/index.js'; -import { createIpcContext, type RegisterDeps } from './services/context.js'; -import { registerPrHandlers } from './services/pr/index.js'; +import { + createServiceContext, + type ControllerContext, + type RegisterDeps, +} from './services/context.js'; import { createRunQueueService } from './services/run-queue.js'; export type { RegisterDeps } from './services/context.js'; /** * 注册全部 IPC handler。薄入口:构建共享上下文 → 建两个跨域 service(run 队列 / Agent 编排) - * → 按业务领域注册 handler(GUI 框架 / PR 操作 / 配置 / Agent 交互)→ 返回运行时控制句柄。 - * - * 各域业务实现见 `services/`:app·pr·config·agent 各域 handler,run-queue / agent-orchestrator - * 两个跨域 service,common/ 公共工具,context.ts 共享上下文。新增 channel 时先在 `@meebox/ipc` - * 对应域加类型,再到对应 service 加 handler。 + * → 合成 controller 上下文 → 按业务领域逐个绑定通道 → 返回运行时控制句柄。 */ export function registerIpcHandlers(deps: RegisterDeps): { abortAllActiveRuns: () => number; runAutopilotIfDue: () => void; terminateAgentsForGonePrs: () => void; } { - const ctx = createIpcContext(deps); + const base = createServiceContext(deps); // run 队列:pragent:run(PR 域)、Agent 编排、AutoPilot 三方共用。 - const runQueue = createRunQueueService(ctx); + const runQueue = createRunQueueService(base); // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 - const orchestrator = createAgentOrchestratorService(ctx, runQueue); + const orchestrator = createAgentOrchestratorService(base, runQueue); + // controller 层统一上下文:基础上下文 + 两个跨域 service,所有 controller 共享同一 ctx。 + const ctx: ControllerContext = { ...base, runQueue, orchestrator }; - registerAppHandlers(ctx); - registerPrHandlers(ctx, runQueue); - registerConfigHandlers(ctx); - registerAgentHandlers(ctx, orchestrator); + /* + * GUI 框架交互 + * 应用信息 / 窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 + */ + handle('app:info', ctx, app.readAppInfo); // 应用 / 运行时版本信息(关于页) + handle('app:paths', ctx, app.readAppPaths); // 关键目录路径(config / agent / 日志) + handle('app:prAgentStatus', ctx, app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) + handle('log:write', ctx, app.writeRendererLog); // 渲染层日志回传落盘 + handle('app:connections', ctx, app.listConnections); // 当前活动连接摘要(Header / 状态栏) + handle('app:userAvatar', ctx, app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) + handle('app:openConfigFile', ctx, app.openConfigFile); // 打开 config.yaml + handle('app:openAgentDir', ctx, app.openAgentDir); // 打开 Agent 目录 + handle('app:openDevTools', ctx, app.openDevTools); // 打开 DevTools(分离窗口) + handle('app:checkUpdate', ctx, app.checkUpdate); // 手动检查更新 + handle('app:getUpdateStatus', ctx, app.getUpdateStatus); // 读缓存的更新检测结果(水合) + handle('app:openExternal', ctx, app.openExternal); // 系统浏览器打开外链 + handle('dialog:pickDirectory', ctx, app.pickDirectory); // 原生目录选择对话框 - ctx.logger.debug('IPC handlers registered'); + /* + * PR 操作 + * 评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 + */ + handle('comments:reply', ctx, pr.replyComment); // 回复评论 + handle('comments:delete', ctx, pr.deleteComment); // 删除自己的评论 + handle('comments:edit', ctx, pr.editComment); // 编辑自己的评论 + handle('comments:fetchAttachment', ctx, pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) + handle('prs:list', ctx, pr.listPrs); // PR 列表(仅活动连接) + handle('prs:refresh', ctx, pr.refreshPrs); // 立即轮询刷新 + handle('prs:lastSync', ctx, pr.getLastSync); // 最近一次同步时间 + handle('prs:setLocalStatus', ctx, pr.setPrStatus); // 设置审阅状态(先远端后本地) + handle('prs:merge', ctx, pr.mergePr); // 合并 PR + handle('repo:sync', ctx, pr.syncRepo); // 同步 PR 所属 repo 本地镜像 + handle('diff:listChangedFiles', ctx, pr.listChangedFiles); // 变更文件列表 + handle('diff:getFileContent', ctx, pr.getFileContent); // 文件内容(base / head 一侧) + handle('diff:commentCountCached', ctx, pr.getCommentCountCached); // 评论数角标(仅缓存) + handle('diff:listComments', ctx, pr.listComments); // 拉评论(缓存 + in-flight 去重) + handle('diff:listCommits', ctx, pr.listCommits); // 提交列表 + handle('diff:commitCount', ctx, pr.getCommitCount); // 提交数角标(本地 git) + handle('diff:getBlame', ctx, pr.getBlame); // blame + PR 引入行 + handle('repo:getTotalSize', ctx, pr.getTotalSize); // 本地镜像总占用(设置页) + handle('pragent:run', ctx, pr.runPragent); // 触发一次 pr-agent run(入队) + handle('pragent:cancel', ctx, pr.cancelPragent); // 取消一个 run + handle('pragent:queue', ctx, pr.getQueue); // 队列快照(active + waiting) + handle('pragent:listRuns', ctx, pr.listRuns); // 历史 run 列表(游标分页) + handle('pragent:getRun', ctx, pr.getRun); // 单条 run 查询 + handle('pragent:clearRuns', ctx, pr.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 + handle('drafts:list', ctx, pr.getDrafts); // 草稿列表 + handle('drafts:create', ctx, pr.addDraft); // 新建草稿 + handle('drafts:update', ctx, pr.patchDraft); // 更新草稿 + handle('drafts:delete', ctx, pr.removeDraft); // 删除草稿 + handle('drafts:publishBatch', ctx, pr.publishDraftBatch); // 批量发布草稿到远端 + + /* + * 配置操作 + * 读写 config.yaml(热生效 / 草稿暂存)及连接 / 代理试连 + */ + handle('config:read', ctx, config.readConfig); // 读当前内存配置 + handle('config:setReposDir', ctx, config.setReposDir); // 设仓库目录(重启生效) + handle('config:setLanguage', ctx, config.setLanguage); // 设 UI 语言(热生效) + handle('config:setLlm', ctx, config.setLlm); // 设 LLM Provider 配置 + handle('config:setAgent', ctx, config.setAgent); // 设 Agent 配置(含 agent.dir) + handle('agent:setAutopilotEnabled', ctx, config.setAutopilotEnabled); // AutoPilot 开关 + handle('config:setConnections', ctx, config.setConnections); // 设连接(热重建 adapter/poller) + handle('config:setProxy', ctx, config.setProxy); // 设代理(热重建 adapter) + handle('config:testProxy', ctx, config.testProxy); // 试连代理(不写配置) + handle('config:testConnection', ctx, config.testConnection); // 试连连接(不写配置) + handle('config:autosaveDraft', ctx, config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) + handle('config:setPoller', ctx, config.setPoller); // 设轮询间隔(热替换定时器) + + /* + * Agent 交互 + * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 + */ + handle('rules:matchForPr', ctx, agent.matchRuleForPr); // 查 PR 命中的规则 + handle('agent:run', ctx, agent.runReview); // 一键评审编排(describe→review→总结) + handle('agent:ask', ctx, agent.runPlanning); // 自由规划 Agent(对话即委派) + handle('agent:stop', ctx, agent.stopAgent); // 停止某 PR 的 Agent 运行 + handle('agent:getSession', ctx, agent.getSession); // 读已落盘评审会话 + handle('agent:getConversation', ctx, agent.getConversation); // 读多轮对话消息 + handle('agent:getTranscript', ctx, agent.getTranscript); // 读 Agent 过程步骤 + handle('agent:autopilotLedgers', ctx, agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 + + base.logger.debug('IPC handlers registered'); return { /** diff --git a/apps/desktop/src/main/services/agent-orchestrator.ts b/apps/desktop/src/main/services/agent-orchestrator.ts index a061195..b0a520b 100644 --- a/apps/desktop/src/main/services/agent-orchestrator.ts +++ b/apps/desktop/src/main/services/agent-orchestrator.ts @@ -22,8 +22,8 @@ import { runAgentReview } from '../agent-review.js'; import { getMainLanguage, t } from '../i18n/index.js'; import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; import { buildProxyEnv } from '../utils/proxy.js'; -import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from './common/usage.js'; -import type { IpcContext } from './context.js'; +import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from './usage.js'; +import type { ServiceContext } from './context.js'; import type { RunQueueService } from './run-queue.js'; // 共享 chat 通道:system + user → 文本 + usage。agent:run 评审与 AutoPilot 都用。 @@ -46,7 +46,7 @@ export interface AgentOrchestratorService { } export function createAgentOrchestratorService( - ctx: IpcContext, + ctx: ServiceContext, runQueue: RunQueueService, ): AgentOrchestratorService { const { bootstrap, logger, stateStore, getPrAgentBridge, broadcast, effectiveAgentDir } = ctx; diff --git a/apps/desktop/src/main/services/agent/index.ts b/apps/desktop/src/main/services/agent/index.ts deleted file mode 100644 index c1f8fb0..0000000 --- a/apps/desktop/src/main/services/agent/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ipcMain } from 'electron'; -import { loadAgentRules } from '@meebox/agent'; -import type { IpcChannels } from '@meebox/ipc'; -import { - getAgentConversation, - getAgentSession, - getAgentTranscript, - getAutopilotLedger, -} from '@meebox/poller'; -import { pickMatchingRule } from '@meebox/rules'; -import type { AgentRecommendationVerdict } from '@meebox/shared'; -import type { AgentOrchestratorService } from '../agent-orchestrator.js'; -import type { IpcContext } from '../context.js'; - -/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取。 */ -export function registerAgentHandlers( - ctx: IpcContext, - orchestrator: AgentOrchestratorService, -): void { - const { logger, stateStore, findPrOrThrow, effectiveAgentDir } = ctx; - - ipcMain.handle( - 'rules:matchForPr', - async ( - _evt, - req: IpcChannels['rules:matchForPr']['request'], - ): Promise => { - // ask 工具不接规则 (问答自由形式,没什么"规约"可应用) - if (req.tool === 'ask') return null; - const pr = await findPrOrThrow(req.localId); - const rules = await loadAgentRules(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `rules: ${msg}`), - }); - const matched = pickMatchingRule(rules, { - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - targetBranch: pr.targetRef.displayId, - tool: req.tool, - }); - if (!matched) return null; - return { - id: matched.id, - filePath: matched.filePath, - priority: matched.priority, - tools: [...matched.tools], - instructions: matched.instructions, - }; - }, - ); - - ipcMain.handle( - 'agent:run', - async ( - _evt, - req: IpcChannels['agent:run']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - return orchestrator.runReview(pr); - }, - ); - - ipcMain.handle( - 'agent:ask', - async ( - _evt, - req: IpcChannels['agent:ask']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - return orchestrator.runPlanning(pr, req.question); - }, - ); - - ipcMain.handle( - 'agent:stop', - (_evt, req: IpcChannels['agent:stop']['request']): IpcChannels['agent:stop']['response'] => - orchestrator.stop(req.localId), - ); - - ipcMain.handle( - 'agent:getSession', - async ( - _evt, - req: IpcChannels['agent:getSession']['request'], - ): Promise => - getAgentSession(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getConversation', - async ( - _evt, - req: IpcChannels['agent:getConversation']['request'], - ): Promise => - getAgentConversation(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:getTranscript', - async ( - _evt, - req: IpcChannels['agent:getTranscript']['request'], - ): Promise => - getAgentTranscript(stateStore, req.localId), - ); - - ipcMain.handle( - 'agent:autopilotLedgers', - async ( - _evt, - req: IpcChannels['agent:autopilotLedgers']['request'], - ): Promise => { - const out: Record = {}; - for (const id of req.localIds) { - const ledger = await getAutopilotLedger(stateStore, id); - if (ledger?.decision === 'review' && ledger.recommendation) { - out[id] = ledger.recommendation; - } - } - return out; - }, - ); -} diff --git a/apps/desktop/src/main/services/app.ts b/apps/desktop/src/main/services/app.ts new file mode 100644 index 0000000..ade8310 --- /dev/null +++ b/apps/desktop/src/main/services/app.ts @@ -0,0 +1,36 @@ +import { app } from 'electron'; +import type { BootstrapResult } from '@meebox/config'; +import type { ConnectionSummary } from '@meebox/ipc'; +import type { AppInfo } from '@meebox/shared'; +import type { BuiltAdapter } from '../adapters.js'; + +/** 应用 / 运行时版本信息(app:info)。纯数据组装,不依赖 controller 上下文。 */ +export function buildAppInfo(bootstrap: BootstrapResult): AppInfo { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + nodeVersion: process.versions.node, + platform: process.platform, + firstRun: bootstrap.firstRun, + }; +} + +/** 当前活动连接的状态摘要(app:connections)。 */ +export function buildConnectionSummaries( + bootstrap: BootstrapResult, + adapters: readonly BuiltAdapter[], +): ConnectionSummary[] { + // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 + const activeId = bootstrap.config.active_connection_id; + return adapters + .filter(({ connectionId }) => connectionId === activeId) + .map(({ connectionId, adapter }) => { + const conn = bootstrap.config.connections.find((c) => c.id === connectionId); + return { + connectionId, + displayName: conn?.display_name ?? connectionId, + user: adapter.getCurrentUser(), + capabilities: adapter.capabilities(), + }; + }); +} diff --git a/apps/desktop/src/main/services/app/index.ts b/apps/desktop/src/main/services/app/index.ts deleted file mode 100644 index f521bea..0000000 --- a/apps/desktop/src/main/services/app/index.ts +++ /dev/null @@ -1,234 +0,0 @@ -import crypto from 'node:crypto'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'; -import type { BootstrapResult } from '@meebox/config'; -import type { ConnectionSummary, IpcChannels } from '@meebox/ipc'; -import type { AppInfo } from '@meebox/shared'; -import type { BuiltAdapter } from '../../adapters.js'; -import { t } from '../../i18n/index.js'; -import { sniffImageContentType } from '../../utils/image.js'; -import { checkForUpdate } from '../../utils/update-check.js'; -import { getLastUpdateResult, publishUpdateResult } from '../../utils/update-state.js'; -import type { IpcContext } from '../context.js'; - -/** GUI 框架交互域:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像。 */ -export function registerAppHandlers(ctx: IpcContext): void { - const { bootstrap, logger, getPrAgentStatus, connectionRuntime, effectiveAgentDir } = ctx; - - ipcMain.handle('app:info', (): IpcChannels['app:info']['response'] => buildAppInfo(bootstrap)); - ipcMain.handle('app:paths', (): IpcChannels['app:paths']['response'] => bootstrap.paths); - ipcMain.handle( - 'app:prAgentStatus', - (): Promise => getPrAgentStatus(), - ); - - // 渲染层日志回传:落进同一份 meebox.log(scope=renderer),与 main 日志合流便于排查。 - const rendererLogger = logger.child({ scope: 'renderer' }); - ipcMain.handle('log:write', (_evt, req: IpcChannels['log:write']['request']): void => { - const obj = req.meta ?? {}; - switch (req.level) { - case 'error': - rendererLogger.error(obj, req.msg); - break; - case 'warn': - rendererLogger.warn(obj, req.msg); - break; - case 'info': - rendererLogger.info(obj, req.msg); - break; - case 'debug': - rendererLogger.debug(obj, req.msg); - break; - } - }); - - ipcMain.handle('app:connections', (): IpcChannels['app:connections']['response'] => - buildConnectionSummaries(bootstrap, connectionRuntime.adapters), - ); - - // (connectionId, slug) → dataUrl 或 null。两级 cache: - // 1) avatarMem: 进程内 Map,本会话内瞬时返回(含 null 负缓存避免重试失败 slug) - // 2) 磁盘文件 /avatars/.bin,TTL 7 天,按 mtime 判定过期 - // 过期或不存在 → 重新打 Bitbucket → 写回磁盘 - // hash = sha256(connectionId|slug) 前 24 hex,纯字母数字文件名安全 - const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; - const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); - const avatarMem = new Map(); - - ipcMain.handle( - 'app:userAvatar', - async ( - _evt, - req: IpcChannels['app:userAvatar']['request'], - ): Promise => { - const memKey = `${req.connectionId}|${req.slug}`; - if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; - - const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24); - const filePath = path.join(avatarDir, `${hash}.bin`); - - // 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音) - try { - const stat = await fs.stat(filePath); - const age = Date.now() - stat.mtimeMs; - if (age < AVATAR_TTL_MS) { - const bytes = await fs.readFile(filePath); - const contentType = sniffImageContentType(bytes); - const result = { - dataUrl: `data:${contentType};base64,${bytes.toString('base64')}`, - }; - avatarMem.set(memKey, result); - return result; - } - // 过期:删了重拉。删失败也没关系(writeFile 会覆盖) - await fs.unlink(filePath).catch(() => undefined); - } catch { - // 文件不存在 / 读失败 → 走 fetch - } - - // 2) 没缓存 / 已过期:去 Bitbucket 拉 - const adapter = connectionRuntime.adapters.find( - (a) => a.connectionId === req.connectionId, - )?.adapter; - if (!adapter) { - avatarMem.set(memKey, null); - return null; - } - try { - const img = await adapter.getUserAvatar(req.slug, req.avatarUrl); - if (!img) { - logger.debug( - { connectionId: req.connectionId, slug: req.slug }, - 'avatar fetch returned null', - ); - avatarMem.set(memKey, null); - return null; - } - // 落盘:best-effort,写失败不影响响应 - try { - await fs.mkdir(avatarDir, { recursive: true }); - await fs.writeFile(filePath, img.bytes); - } catch (writeErr) { - logger.warn({ err: writeErr, hash }, 'avatar disk write failed'); - } - const base64 = Buffer.from(img.bytes).toString('base64'); - const result = { dataUrl: `data:${img.contentType};base64,${base64}` }; - avatarMem.set(memKey, result); - logger.debug( - { - hash, - slug: req.slug, - bytes: img.bytes.length, - contentType: img.contentType, - }, - 'avatar fetched + cached to disk', - ); - return result; - } catch (err) { - logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw'); - avatarMem.set(memKey, null); - return null; - } - }, - ); - - ipcMain.handle('app:openConfigFile', async (): Promise => { - const err = await shell.openPath(bootstrap.paths.configFile); - if (err) throw new Error(`failed to open config.yaml: ${err}`); - }); - ipcMain.handle('app:openAgentDir', async (): Promise => { - // 当前生效的 Agent 目录(用户配置优先,否则默认 ~/.code-meeseeks/agent);先确保存在再打开。 - const dir = effectiveAgentDir(); - await fs.mkdir(dir, { recursive: true }).catch(() => undefined); - const err = await shell.openPath(dir); - if (err) throw new Error(`failed to open agent dir: ${err}`); - }); - ipcMain.handle('app:openDevTools', (evt) => { - evt.sender.openDevTools({ mode: 'detach' }); - }); - ipcMain.handle( - 'app:checkUpdate', - async (): Promise => { - // 与启动检测一致受 check_enabled 控制:关闭时不发起请求,直接返回禁用结果。 - if (!bootstrap.config.update.check_enabled) { - return { - ok: false, - hasUpdate: false, - currentVersion: app.getVersion(), - error: 'update check disabled by config', - }; - } - const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); - // 交给单一真相源:缓存 + 有新版则广播到所有窗口(状态栏据此同步,不再只回设置页本地)。 - publishUpdateResult(result); - return result; - }, - ); - ipcMain.handle( - 'app:getUpdateStatus', - (): IpcChannels['app:getUpdateStatus']['response'] => getLastUpdateResult(), - ); - ipcMain.handle( - 'app:openExternal', - async (_evt, req: IpcChannels['app:openExternal']['request']): Promise => { - // 白名单:仅放行 http(s),防止 file:// / javascript: 等被恶意 markdown 注入触发 - if (!/^https?:\/\//.test(req.url)) return; - await shell.openExternal(req.url); - }, - ); - - ipcMain.handle( - 'dialog:pickDirectory', - async ( - evt, - req: IpcChannels['dialog:pickDirectory']['request'], - ): Promise => { - const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; - const result = win - ? await dialog.showOpenDialog(win, { - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }) - : await dialog.showOpenDialog({ - title: req.title ?? t('dialog.selectDirectory'), - defaultPath: req.defaultPath, - properties: ['openDirectory', 'createDirectory'], - }); - if (result.canceled || result.filePaths.length === 0) { - return { path: null }; - } - return { path: result.filePaths[0]! }; - }, - ); -} - -function buildAppInfo(bootstrap: BootstrapResult): AppInfo { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - nodeVersion: process.versions.node, - platform: process.platform, - firstRun: bootstrap.firstRun, - }; -} - -function buildConnectionSummaries( - bootstrap: BootstrapResult, - adapters: readonly BuiltAdapter[], -): ConnectionSummary[] { - // 单活动连接模型:状态栏只展示当前活动连接的启用状态(与 poller 只轮询活动连接一致)。 - const activeId = bootstrap.config.active_connection_id; - return adapters - .filter(({ connectionId }) => connectionId === activeId) - .map(({ connectionId, adapter }) => { - const conn = bootstrap.config.connections.find((c) => c.id === connectionId); - return { - connectionId, - displayName: conn?.display_name ?? connectionId, - user: adapter.getCurrentUser(), - capabilities: adapter.capabilities(), - }; - }); -} diff --git a/apps/desktop/src/main/services/common/broadcast.ts b/apps/desktop/src/main/services/broadcast.ts similarity index 100% rename from apps/desktop/src/main/services/common/broadcast.ts rename to apps/desktop/src/main/services/broadcast.ts diff --git a/apps/desktop/src/main/services/comments.ts b/apps/desktop/src/main/services/comments.ts new file mode 100644 index 0000000..d42164b --- /dev/null +++ b/apps/desktop/src/main/services/comments.ts @@ -0,0 +1,45 @@ +import type { PlatformAdapter, PrComment } from '@meebox/shared'; + +/** + * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。不依赖 controller 上下文。 + * + * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version + * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) + * - canEdit: author.name === 当前 PAT 用户 && 有 version + * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) + * + * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 + * 自己比对 author / version / replies,链路最短最稳。 + */ +export function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { + const me = adapter.getCurrentUser(); + if (!me) { + return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); + } + // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 + // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 + const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; + return setOwnershipRecursive(comments, (c) => { + const isMine = c.author.name === me.name; + const hasVersion = typeof c.version === 'number'; + return { + canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), + canEdit: isMine && hasVersion, + }; + }); +} + +function setOwnershipRecursive( + comments: PrComment[], + judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, +): PrComment[] { + return comments.map((c) => { + const flags = judge(c); + return { + ...c, + canDelete: flags.canDelete, + canEdit: flags.canEdit, + replies: setOwnershipRecursive(c.replies, judge), + }; + }); +} diff --git a/apps/desktop/src/main/services/common/comments-cache.ts b/apps/desktop/src/main/services/common/comments-cache.ts deleted file mode 100644 index afaeef3..0000000 --- a/apps/desktop/src/main/services/common/comments-cache.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { JsonFileStateStore } from '@meebox/state-store'; -import { broadcast } from './broadcast.js'; - -/** - * 清掉某 PR 的评论缓存并广播 `comments:changed`,让 CommentsPanel / DiffView 内嵌评论 - * 重拉刷新。收口 comments reply/delete/edit 与 drafts:publishBatch 共用的同一套链路 - * (清 `prs//comments` 缓存 → 下次 listComments force 拉远端 → 广播触发重拉)。 - * cache miss 无所谓,吞掉异常。 - */ -export async function invalidateCommentsCache( - stateStore: JsonFileStateStore, - localId: string, -): Promise { - try { - await stateStore.delete(`prs/${localId}/comments`); - } catch { - /* cache miss 也无所谓 */ - } - broadcast('comments:changed', { localId }); -} diff --git a/apps/desktop/src/main/services/common/mirror.ts b/apps/desktop/src/main/services/common/mirror.ts deleted file mode 100644 index af67c7c..0000000 --- a/apps/desktop/src/main/services/common/mirror.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { readDiffBaseCache, writeDiffBaseCache } from '@meebox/poller'; -import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import type { StoredPullRequest } from '@meebox/shared'; -import type { JsonFileStateStore } from '@meebox/state-store'; - -export interface MirrorHelpers { - ensureMirrorReadyForPr( - pr: StoredPullRequest, - ): Promise<{ mirrorPath: string; freshClone: boolean }>; - resolveDiffBaseSha(pr: StoredPullRequest): Promise; -} - -export function createMirrorHelpers(deps: { - repoMirror: RepoMirrorManager; - stateStore: JsonFileStateStore; - repoIdentityFor: (pr: StoredPullRequest) => RepoIdentity; -}): MirrorHelpers { - const { repoMirror, stateStore, repoIdentityFor } = deps; - - /** - * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha - * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR - * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 - * - * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 - * - * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 - * 快速路径命中率应该很高。 - */ - const ensureMirrorReadyForPr = async ( - pr: StoredPullRequest, - ): Promise<{ mirrorPath: string; freshClone: boolean }> => { - const id = repoIdentityFor(pr); - const [hasHead, hasBase] = await Promise.all([ - repoMirror.hasCommit(id, pr.sourceRef.sha), - repoMirror.hasCommit(id, pr.targetRef.sha), - ]); - if (hasHead && hasBase) { - // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log - return { mirrorPath: repoMirror.mirrorPath(id), freshClone: false }; - } - const r = await repoMirror.syncMirror(id); - return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; - }; - - /** - * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 - * - * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, - * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, - * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: - * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; - * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 - * - * 失效重算:固化 base 不再是当前 head 的祖先(源分支被 rebase)→ 重算。head 正常 push(仅前进) - * 不失效。算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 - * - * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 - */ - const resolveDiffBaseSha = async (pr: StoredPullRequest): Promise => { - const id = repoIdentityFor(pr); - const head = pr.sourceRef.sha; - const cached = await readDiffBaseCache(stateStore, pr.localId); - if (cached?.base_sha && (await repoMirror.isAncestor(id, cached.base_sha, head))) { - return cached.base_sha; - } - const mb = await repoMirror.mergeBase(id, pr.targetRef.sha, head); - if (!mb) return pr.targetRef.sha; - await writeDiffBaseCache(stateStore, pr.localId, { - base_sha: mb, - head_sha: head, - computed_at: new Date().toISOString(), - }); - return mb; - }; - - return { ensureMirrorReadyForPr, resolveDiffBaseSha }; -} diff --git a/apps/desktop/src/main/services/common/pr-lookup.ts b/apps/desktop/src/main/services/common/pr-lookup.ts deleted file mode 100644 index 387e34c..0000000 --- a/apps/desktop/src/main/services/common/pr-lookup.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { BootstrapResult } from '@meebox/config'; -import { listStoredPullRequests } from '@meebox/poller'; -import type { RepoIdentity } from '@meebox/repo-mirror'; -import type { PlatformAdapter, StoredPullRequest } from '@meebox/shared'; -import type { JsonFileStateStore } from '@meebox/state-store'; -import type { ConnectionRuntime } from '../../adapters.js'; - -export interface PrLookup { - /** 按 localId 在状态库定位 PR,找不到抛错(统一错误文案)。 */ - findPrOrThrow(localId: string): Promise; - /** PR → RepoIdentity(host/projectKey/repoSlug),connection 缺失抛错。 */ - repoIdentityFor(pr: StoredPullRequest): RepoIdentity; - /** PR 对应连接的 adapter;连接无 adapter 时返回 undefined。 */ - adapterFor(pr: StoredPullRequest): PlatformAdapter | undefined; - /** 同 adapterFor,但无 adapter 时抛错(绝大多数 handler 走它)。 */ - adapterForOrThrow(pr: StoredPullRequest): PlatformAdapter; -} - -export function createPrLookup(deps: { - bootstrap: BootstrapResult; - stateStore: JsonFileStateStore; - connectionRuntime: ConnectionRuntime; -}): PrLookup { - const { bootstrap, stateStore, connectionRuntime } = deps; - - const findPrOrThrow = async (localId: string): Promise => { - const prs = await listStoredPullRequests(stateStore); - const pr = prs.find((p) => p.localId === localId); - if (!pr) throw new Error(`PR not found in local state: ${localId}`); - return pr; - }; - - const repoIdentityFor = (pr: StoredPullRequest): RepoIdentity => { - const conn = bootstrap.config.connections.find((c) => c.id === pr.connectionId); - if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); - return { - host: new URL(conn.base_url).hostname, - projectKey: pr.repo.projectKey, - repoSlug: pr.repo.repoSlug, - }; - }; - - const adapterFor = (pr: StoredPullRequest): PlatformAdapter | undefined => - connectionRuntime.adapters.find((a) => a.connectionId === pr.connectionId)?.adapter; - - const adapterForOrThrow = (pr: StoredPullRequest): PlatformAdapter => { - const adapter = adapterFor(pr); - if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); - return adapter; - }; - - return { findPrOrThrow, repoIdentityFor, adapterFor, adapterForOrThrow }; -} diff --git a/apps/desktop/src/main/services/config/index.ts b/apps/desktop/src/main/services/config/index.ts deleted file mode 100644 index b258cde..0000000 --- a/apps/desktop/src/main/services/config/index.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { ipcMain } from 'electron'; -import { writeConfig } from '@meebox/config'; -import type { IpcChannels } from '@meebox/ipc'; -import { buildDraftAdapter } from '../../adapters.js'; -import { setMainLanguage } from '../../i18n/index.js'; -import { testProxyConnectivity } from '../../utils/proxy.js'; -import type { IpcContext } from '../context.js'; - -/** 配置操作域:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连。 */ -export function registerConfigHandlers(ctx: IpcContext): void { - const { bootstrap, logger, poller, reconfigureConnections } = ctx; - - ipcMain.handle('config:read', (): IpcChannels['config:read']['response'] => bootstrap.config); - - ipcMain.handle( - 'config:setReposDir', - async (_evt, req: IpcChannels['config:setReposDir']['request']): Promise => { - const next = { - ...bootstrap.config, - workspace: { - ...bootstrap.config.workspace, - repos_dir: req.reposDir, - }, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); - }, - ); - - ipcMain.handle( - 'config:setLanguage', - async (_evt, req: IpcChannels['config:setLanguage']['request']): Promise => { - const next = { ...bootstrap.config, language: req.language }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 主进程 i18n 即时切换(新 dialog/错误文案与下次 pragent:run 的响应语言随之)。 - bootstrap.config.language = req.language; - setMainLanguage(req.language); - logger.info({ language: req.language }, 'language config updated'); - }, - ); - - ipcMain.handle( - 'config:setLlm', - async (_evt, req: IpcChannels['config:setLlm']['request']): Promise => { - const next = { ...bootstrap.config, llm: req.llm }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存中 config 同步更新,下一次 pragent:run 立刻用新值(不等重启) - bootstrap.config.llm = req.llm; - logger.info( - { - profileCount: req.llm.profiles.length, - activeId: req.llm.active_id, - }, - 'llm config updated', - ); - }, - ); - - ipcMain.handle( - 'config:setAgent', - async (_evt, req: IpcChannels['config:setAgent']['request']): Promise => { - const next = { ...bootstrap.config, agent: req.agent }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.agent = req.agent; - logger.info({ agent: req.agent }, 'agent config updated'); - }, - ); - - ipcMain.handle( - 'agent:setAutopilotEnabled', - async (_evt, req: IpcChannels['agent:setAutopilotEnabled']['request']): Promise => { - const was = bootstrap.config.agent.autopilot.enabled; - const agent = { - ...bootstrap.config.agent, - autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, - }; - await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); - bootstrap.config.agent = agent; - logger.info({ enabled: req.enabled }, 'autopilot toggled'); - // 关 → 开:立即触发一次 poll(刷新 PR 列表 / 状态),其 onTick 即按准入规则评估并按需开评审, - // 不必等下个轮询周期。 - if (req.enabled && !was) { - void poller.tick(); - } - }, - ); - - ipcMain.handle( - 'config:setConnections', - async (_evt, req: IpcChannels['config:setConnections']['request']): Promise => { - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存 config 同步 + 热重建 adapter/poller,连接变更即时生效(不等重启) - bootstrap.config.connections = req.connections; - bootstrap.config.active_connection_id = req.active_connection_id; - await reconfigureConnections(); - // 立刻 poll 一轮,让启用 / 切换的连接 PR 马上出现(active 为空则空操作) - void poller.tick(); - logger.info( - { count: req.connections.length, activeId: req.active_connection_id }, - 'connections config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:setProxy', - async (_evt, req: IpcChannels['config:setProxy']['request']): Promise => { - const next = { ...bootstrap.config, proxy: req.proxy }; - await writeConfig(bootstrap.paths.configFile, next); - // 内存同步 + 热重建 adapter(REST fetch 用上新代理);git/pr-agent 出口读最新配置无需重建 - bootstrap.config.proxy = req.proxy; - await reconfigureConnections(); - logger.info( - { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, - 'proxy config updated (hot-reloaded)', - ); - }, - ); - - ipcMain.handle( - 'config:testProxy', - async ( - _evt, - req: IpcChannels['config:testProxy']['request'], - ): Promise => { - return testProxyConnectivity(req.proxy); - }, - ); - - ipcMain.handle( - 'config:testConnection', - async ( - _evt, - req: IpcChannels['config:testConnection']['request'], - ): Promise => { - // 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason - try { - return await buildDraftAdapter( - req.base_url, - req.token, - bootstrap.config.proxy, - req.kind, - ).ping(); - } catch (e) { - return { ok: false, reason: e instanceof Error ? e.message : String(e) }; - } - }, - ); - - ipcMain.handle( - 'config:autosaveDraft', - async (_evt, req: IpcChannels['config:autosaveDraft']['request']): Promise => { - // 只写 config.yaml(含 base 非编辑字段),**不更新内存 config、不 reconfigure**: - // 持久化防丢失但不生效。重启读文件 或 点底栏「保存」走 config:setConnections/setLlm 才应用。 - const next = { - ...bootstrap.config, - connections: req.connections, - active_connection_id: req.active_connection_id, - llm: req.llm, - }; - await writeConfig(bootstrap.paths.configFile, next); - logger.info( - { connections: req.connections.length, profiles: req.llm.profiles.length }, - 'connections/llm draft autosaved to config.yaml (not applied)', - ); - }, - ); - - ipcMain.handle( - 'config:setPoller', - async (_evt, req: IpcChannels['config:setPoller']['request']): Promise => { - // 防御性 clamp 到 60~900 整数(UI 已限制,这里兜底) - const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); - const next = { - ...bootstrap.config, - poller: { ...bootstrap.config.poller, interval_seconds: seconds }, - }; - await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.poller.interval_seconds = seconds; - poller.setIntervalSeconds(seconds); // 热替换定时器,无需重启 - logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); - }, - ); -} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index de1389f..3bb6fab 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -6,10 +6,10 @@ import type { RepoMirrorManager } from '@meebox/repo-mirror'; import type { PrAgentStatus } from '@meebox/shared'; import type { JsonFileStateStore } from '@meebox/state-store'; import type { ConnectionRuntime } from '../adapters.js'; -import { broadcast } from './common/broadcast.js'; -import { invalidateCommentsCache } from './common/comments-cache.js'; -import { createMirrorHelpers, type MirrorHelpers } from './common/mirror.js'; -import { createPrLookup, type PrLookup } from './common/pr-lookup.js'; +import type { AgentOrchestratorService } from './agent-orchestrator.js'; +import { broadcast } from './broadcast.js'; +import { PrService } from './pr-service.js'; +import type { RunQueueService } from './run-queue.js'; /** registerIpcHandlers 的外部依赖(由 main/index.ts 注入)。 */ export interface RegisterDeps { @@ -31,35 +31,38 @@ export interface RegisterDeps { } /** - * 各 service 共享的运行时上下文:外部依赖 + 收口好的公共工具(广播 / PR 定位 / 镜像 / - * 评论缓存 / Agent 目录)。各域 handler 接收 ctx 即可,避免逐个透传裸 deps。 + * 各 service 共享的运行时上下文:外部依赖 + 跨域工具(广播 / Agent 目录)+ PR 领域服务。 + * 跨域服务(run 队列 / Agent 编排)在此之上由 ipc.ts 合成 ControllerContext,避免构造环。 */ -export interface IpcContext extends RegisterDeps, PrLookup, MirrorHelpers { +export interface ServiceContext extends RegisterDeps { /** 向所有窗口广播 main → renderer 事件(按 IpcEvents 强类型)。 */ broadcast: typeof broadcast; - /** 清 PR 评论缓存 + 广播 comments:changed。 */ - invalidateCommentsCache(localId: string): Promise; /** 生效的 Agent 目录:用户配置优先,未配置则回落默认位置(~/.code-meeseeks/agent)。 */ effectiveAgentDir(): string; + /** PR 领域服务:PR 定位 / adapter / 镜像 / diff base / 评论缓存。 */ + pr: PrService; } -export function createIpcContext(deps: RegisterDeps): IpcContext { - const prLookup = createPrLookup({ - bootstrap: deps.bootstrap, - stateStore: deps.stateStore, - connectionRuntime: deps.connectionRuntime, - }); - const mirror = createMirrorHelpers({ - repoMirror: deps.repoMirror, - stateStore: deps.stateStore, - repoIdentityFor: prLookup.repoIdentityFor, - }); +/** + * controller 层统一上下文:在 ServiceContext 之上再挂两个跨域 service(run 队列 / Agent 编排), + * 使所有 controller 共享同一 `ctx` 入参即可拿到全部能力,签名统一为 `(ctx, req, evt)`。 + * 两个跨域服务以基础 ServiceContext 构建(见 ipc.ts 装配顺序),构建完成后合成本上下文。 + */ +export interface ControllerContext extends ServiceContext { + runQueue: RunQueueService; + orchestrator: AgentOrchestratorService; +} + +export function createServiceContext(deps: RegisterDeps): ServiceContext { return { ...deps, - ...prLookup, - ...mirror, broadcast, - invalidateCommentsCache: (localId) => invalidateCommentsCache(deps.stateStore, localId), effectiveAgentDir: () => deps.bootstrap.config.agent.dir || deps.bootstrap.paths.agentDir, + pr: new PrService({ + bootstrap: deps.bootstrap, + stateStore: deps.stateStore, + connectionRuntime: deps.connectionRuntime, + repoMirror: deps.repoMirror, + }), }; } diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts new file mode 100644 index 0000000..e251900 --- /dev/null +++ b/apps/desktop/src/main/services/pr-service.ts @@ -0,0 +1,130 @@ +import type { BootstrapResult } from '@meebox/config'; +import { listStoredPullRequests, readDiffBaseCache, writeDiffBaseCache } from '@meebox/poller'; +import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; +import type { PlatformAdapter, StoredPullRequest } from '@meebox/shared'; +import type { JsonFileStateStore } from '@meebox/state-store'; +import type { ConnectionRuntime } from '../adapters.js'; +import { broadcast } from './broadcast.js'; + +/** PrService 构造依赖(由 context 注入)。 */ +export interface PrServiceDeps { + bootstrap: BootstrapResult; + stateStore: JsonFileStateStore; + /** 可变连接运行时;reconfigure 原地替换内容,本服务经引用读到最新 adapters。 */ + connectionRuntime: ConnectionRuntime; + repoMirror: RepoMirrorManager; +} + +/** + * PR 领域服务:PR 定位 / 连接 adapter 解析 / 仓库镜像就位 / diff base 解析 / 评论缓存失效。 + * + * 把原先散落在 common/ 的 pr-lookup·mirror·comments-cache 收拢为单一强领域类,依赖经构造注入、 + * 各方法共享 `this.deps`,避免逐函数透传。controller 一律经 `ctx.pr.()` 调用;调用方 + * 应以实例方法形式调用(勿解构方法,否则丢失 this 绑定)。 + */ +export class PrService { + constructor(private readonly deps: PrServiceDeps) {} + + /** 按 localId 在状态库定位 PR,找不到抛错(统一错误文案)。 */ + async findPrOrThrow(localId: string): Promise { + const prs = await listStoredPullRequests(this.deps.stateStore); + const pr = prs.find((p) => p.localId === localId); + if (!pr) throw new Error(`PR not found in local state: ${localId}`); + return pr; + } + + /** PR → RepoIdentity(host / projectKey / repoSlug);connection 缺失抛错。 */ + repoIdentityFor(pr: StoredPullRequest): RepoIdentity { + const conn = this.deps.bootstrap.config.connections.find((c) => c.id === pr.connectionId); + if (!conn) throw new Error(`connection not found: ${pr.connectionId}`); + return { + host: new URL(conn.base_url).hostname, + projectKey: pr.repo.projectKey, + repoSlug: pr.repo.repoSlug, + }; + } + + /** PR 对应连接的 adapter;连接无 adapter 时返回 undefined。 */ + adapterFor(pr: StoredPullRequest): PlatformAdapter | undefined { + return this.deps.connectionRuntime.adapters.find((a) => a.connectionId === pr.connectionId) + ?.adapter; + } + + /** 同 adapterFor,但无 adapter 时抛错(绝大多数 handler 走它)。 */ + adapterForOrThrow(pr: StoredPullRequest): PlatformAdapter { + const adapter = this.adapterFor(pr); + if (!adapter) throw new Error(`no adapter for connection ${pr.connectionId}`); + return adapter; + } + + /** + * 打开 PR 时镜像就位的保障。优先快速路径:本地 bare 已含 head+base 两个 sha + * → 直接回 mirrorPath,不打远端。两 sha 都齐意味着上次 sync 已经覆盖了本 PR + * 的 commit 范围(PR sha 是 immutable 的),renderer 可以直接走本地 diff 计算。 + * + * 缺 sha (任一) → 走 syncMirror 兜底走 git fetch。 + * + * 后台 poll 在拿到 PR 状态更新后会主动 syncMirror,所以正常打开 PR 时 + * 快速路径命中率应该很高。 + */ + async ensureMirrorReadyForPr( + pr: StoredPullRequest, + ): Promise<{ mirrorPath: string; freshClone: boolean }> { + const id = this.repoIdentityFor(pr); + const [hasHead, hasBase] = await Promise.all([ + this.deps.repoMirror.hasCommit(id, pr.sourceRef.sha), + this.deps.repoMirror.hasCommit(id, pr.targetRef.sha), + ]); + if (hasHead && hasBase) { + // 快速路径:mirror 已含 head + base,直接回不打远端。命中频繁,不打 log + return { mirrorPath: this.deps.repoMirror.mirrorPath(id), freshClone: false }; + } + const r = await this.deps.repoMirror.syncMirror(id); + return { mirrorPath: r.mirrorPath, freshClone: r.freshClone }; + } + + /** + * 解析 PR diff 的固定 base(merge-base)——见 `@meebox/poller` diff-base-cache。 + * + * PR diff 的语义基准是「源分支自目标分支分叉处」= `merge-base(targetRef.sha, sourceRef.sha)`, + * 而非目标分支当前 tip(会随别的 PR 合入前移)。首次算出后固化于 `prs//diff-base.json`, + * 之后 listChangedFiles / 文件内容 / commitCount / blame / pr-agent worktree 一律以它为 base: + * - 内容(Monaco 左栏)锚到 merge-base → 编辑器即真三点,目标漂移不再把别的 PR 改动倒挂进来; + * - 行锚点(评论 / finding)有了固定参照,目标漂移不致错位。 + * + * 失效重算:固化 base 不再是当前 head 的祖先(源分支被 rebase)→ 重算。head 正常 push(仅前进) + * 不失效。算不出(缺对象 / 无共同祖先)→ 兜底退回 targetRef.sha 且**不固化**,下次再试。 + * + * 前置:mirror 已含 head + targetRef.sha(diff 入口已 ensureMirrorReadyForPr / syncMirror)。 + */ + async resolveDiffBaseSha(pr: StoredPullRequest): Promise { + const id = this.repoIdentityFor(pr); + const head = pr.sourceRef.sha; + const cached = await readDiffBaseCache(this.deps.stateStore, pr.localId); + if (cached?.base_sha && (await this.deps.repoMirror.isAncestor(id, cached.base_sha, head))) { + return cached.base_sha; + } + const mb = await this.deps.repoMirror.mergeBase(id, pr.targetRef.sha, head); + if (!mb) return pr.targetRef.sha; + await writeDiffBaseCache(this.deps.stateStore, pr.localId, { + base_sha: mb, + head_sha: head, + computed_at: new Date().toISOString(), + }); + return mb; + } + + /** + * 清掉某 PR 的评论缓存并广播 `comments:changed`,让 CommentsPanel / DiffView 内嵌评论重拉刷新。 + * 收口 comments reply/delete/edit 与 drafts:publishBatch 共用的链路(清 `prs//comments` + * 缓存 → 下次 listComments force 拉远端 → 广播触发重拉)。cache miss 无所谓,吞掉异常。 + */ + async invalidateCommentsCache(localId: string): Promise { + try { + await this.deps.stateStore.delete(`prs/${localId}/comments`); + } catch { + /* cache miss 也无所谓 */ + } + broadcast('comments:changed', { localId }); + } +} diff --git a/apps/desktop/src/main/services/pr/index.ts b/apps/desktop/src/main/services/pr/index.ts deleted file mode 100644 index 74a3289..0000000 --- a/apps/desktop/src/main/services/pr/index.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { ipcMain } from 'electron'; -import type { IpcChannels } from '@meebox/ipc'; -import { - clearAgentSession, - clearAutopilotLedger, - clearReviewRunsForPr, - createDraft, - deleteDraft, - getReviewRun, - isCommentsCacheStale, - listDrafts, - listReviewRunsForPr, - listStoredPullRequests, - readCommentsCache, - setLocalStatus, - updateDraft, - writeCommentsCache, -} from '@meebox/poller'; -import type { RepoIdentity } from '@meebox/repo-mirror'; -import type { PlatformAdapter, PrComment } from '@meebox/shared'; -import { t } from '../../i18n/index.js'; -import type { IpcContext } from '../context.js'; -import type { RunQueueService } from '../run-queue.js'; - -/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列。 */ -export function registerPrHandlers(ctx: IpcContext, runQueue: RunQueueService): void { - const { - bootstrap, - logger, - stateStore, - poller, - repoMirror, - getPrAgentBridge, - broadcast, - findPrOrThrow, - repoIdentityFor, - adapterFor, - adapterForOrThrow, - ensureMirrorReadyForPr, - resolveDiffBaseSha, - invalidateCommentsCache, - } = ctx; - - const broadcastDraftsChanged = (localId: string): void => broadcast('drafts:changed', { localId }); - - // ── 评论 ── - - ipcMain.handle( - 'comments:reply', - async ( - _evt, - req: IpcChannels['comments:reply']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - const reply = await adapter.replyToComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.parentCommentId, - req.body, - ); - // 清掉 comments cache,下次 listComments 会 force 拉远端拿到最新评论树 - // (包括刚 post 的 reply 嵌入到正确父评论 .replies 数组)。同时广播事件让 - // CommentsPanel / DiffView 自动重拉 - await invalidateCommentsCache(pr.localId); - return reply; - }, - ); - - ipcMain.handle( - 'comments:delete', - async ( - _evt, - req: IpcChannels['comments:delete']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - // Bitbucket 在以下情形 409/403: - // - version 跟远端不一致 (用户在别处已编辑) - // - 评论已有回复 (跟 web UI 同步规则) - // - 当前 PAT 不是作者本人 - // 错误体已经在 BitbucketClientError.message 里带,直接抛给 renderer 显示原文 - await adapter.deleteComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - ); - // 跟 reply 同套:清 cache + 广播让 UI 立刻看到评论消失 - await invalidateCommentsCache(pr.localId); - }, - ); - - ipcMain.handle( - 'comments:edit', - async ( - _evt, - req: IpcChannels['comments:edit']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - // Bitbucket 409 (version 不一致) 时 BitbucketClientError.message 会带 "expected version X" - // 这种细节,原样抛给 renderer 显示让用户知道"远端有新版本" - const updated = await adapter.editComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - req.commentId, - req.version, - req.body, - ); - // 清 cache + 广播,UI 重拉刷新 (跟 delete 同套链路)。返回 updated 仅作 - // 调用方乐观参考 — 实际页面渲染走 cache→force-refresh 路径 - await invalidateCommentsCache(pr.localId); - return updated; - }, - ); - - ipcMain.handle( - 'comments:fetchAttachment', - async ( - _evt, - req: IpcChannels['comments:fetchAttachment']['request'], - ): Promise => { - // 找 PR 对应的 connection adapter 拉 attachment。不缓存 — 评论图片重复 - // 加载概率低 (用户决策),每次进入 PR 走 IPC 跟头像走 cache 不同 - try { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterFor(pr); - if (!adapter) return null; - // 传 pr.repo 给 adapter — Bitbucket 的 attachment: 协议需要 repo 上下文拼 URL - const res = await adapter.getAttachment(req.url, pr.repo); - if (!res) return null; - const base64 = Buffer.from(res.bytes).toString('base64'); - return { dataUrl: `data:${res.contentType};base64,${base64}` }; - } catch { - return null; - } - }, - ); - - // ── PR 列表 / 状态 / 合并 ── - - ipcMain.handle('prs:list', async (): Promise => { - // 单活动连接模型:只展示当前活动连接的 PR。状态库可能仍存着切换前其他连接的 - // 历史 PR(poller 只轮询活动连接,不会清理旧的),故在出口按 connectionId 过滤。 - const activeId = bootstrap.config.active_connection_id; - const all = await listStoredPullRequests(stateStore); - return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; - }); - ipcMain.handle( - 'prs:refresh', - async (): Promise => poller.tick(), - ); - ipcMain.handle('prs:lastSync', (): IpcChannels['prs:lastSync']['response'] => ({ - at: poller.getLastPollAt(), - })); - ipcMain.handle( - 'prs:setLocalStatus', - async ( - _evt, - req: IpcChannels['prs:setLocalStatus']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - // 先写远端:本地 status → Bitbucket reviewer.status;失败抛出,前端不会看到本地变更 - const remoteStatus = - req.status === 'approved' - ? 'approved' - : req.status === 'needs_work' - ? 'needsWork' - : 'unapproved'; - await adapter.setPullRequestReviewStatus( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - remoteStatus, - ); - // 远端 OK 后落本地,UI 立即反映;下一轮 poll 会取回相同值 - return setLocalStatus(stateStore, req.localId, req.status); - }, - ); - - ipcMain.handle( - 'prs:merge', - async (_evt, req: IpcChannels['prs:merge']['request']): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - // 合并远端;失败 (冲突 / veto / 权限) 抛出,renderer 提示,本地不变。 - // 成功后不在此落本地:PR 转 MERGED 会从 pending 消失,靠 renderer 触发的 - // refresh → poll 软删收尾,避免本地状态与远端各执一词 - await adapter.mergePullRequest( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - // ── 镜像 / diff ── - - ipcMain.handle( - 'repo:sync', - async ( - _evt, - req: IpcChannels['repo:sync']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - return ensureMirrorReadyForPr(pr); - }, - ); - - ipcMain.handle( - 'diff:listChangedFiles', - async ( - _evt, - req: IpcChannels['diff:listChangedFiles']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 自动确保 mirror 含 head + base sha (快速路径命中即 noop);再算 diff - await ensureMirrorReadyForPr(pr); - // base 锚到固定 merge-base(非漂移的 targetRef.sha),三点 diff 对目标分支前移稳定 - const base = await resolveDiffBaseSha(pr); - return repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); - }, - ); - - ipcMain.handle( - 'diff:getFileContent', - async ( - _evt, - req: IpcChannels['diff:getFileContent']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // base 侧读固定 merge-base 的内容(与三点 diff 一致),head 侧读源 tip - const sha = req.side === 'base' ? await resolveDiffBaseSha(pr) : pr.sourceRef.sha; - return repoMirror.getFileContent(id, sha, req.path); - }, - ); - - ipcMain.handle( - 'diff:commentCountCached', - async ( - _evt, - req: IpcChannels['diff:commentCountCached']['request'], - ): Promise => { - const cache = await readCommentsCache(stateStore, req.localId); - if (!cache) return null; - return { count: cache.comments.length }; - }, - ); - - // In-flight dedup: 打开 PR 时 MainPane / DiffView / CommentsPanel 三个组件 - // 并行调 listComments(force:true),没去重的话会打 3 次 Bitbucket API。同一 localId - // 的 concurrent 调用合并到同一个 Promise,远端只打一次 - const listCommentsInFlight = new Map>(); - ipcMain.handle( - 'diff:listComments', - async ( - _evt, - req: IpcChannels['diff:listComments']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - // 缓存命中条件:pr_updated_at 跟当前 PR meta updatedAt 一致 → 直接回缓存, - // 不打远端。PR 任何变更 (新评论 / 状态等) Bitbucket 都会更新 updatedAt,跳变即重拉。 - // - // **req.force=true** 跳过 cache 直接打远端 — 本地 PR.updatedAt 来自 poller - // 周期拉,可能滞后,stale 比对会误判命中。打开 PR 时 renderer 传 force=true - // 强制刷新,确保拿到最新评论 - const cache = await readCommentsCache(stateStore, pr.localId); - if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { - return cache.comments; - } - // dedup:同 localId 的 in-flight Promise 直接复用 - const existing = listCommentsInFlight.get(pr.localId); - if (existing) return existing; - const adapter = adapterForOrThrow(pr); - const fetchPromise = adapter - .listPullRequestComments( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ) - .then((raw) => annotateOwnership(raw, adapter)) - .then(async (fresh) => { - await writeCommentsCache(stateStore, pr.localId, { - comments: fresh, - pr_updated_at: pr.updatedAt, - fetched_at: new Date().toISOString(), - }); - return fresh; - }) - .finally(() => { - listCommentsInFlight.delete(pr.localId); - }); - listCommentsInFlight.set(pr.localId, fetchPromise); - return fetchPromise; - }, - ); - - ipcMain.handle( - 'diff:listCommits', - async ( - _evt, - req: IpcChannels['diff:listCommits']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - // commits 不缓存(量少 + UI 进 commits 标签页才拉,频率低);后续如发现频繁拉 - // 再补 prs//commits.json 缓存层 (走 pr_updated_at 失效,跟 comments 同模式) - return adapter.listPullRequestCommits( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - ); - }, - ); - - ipcMain.handle( - 'diff:commitCount', - async ( - _evt, - req: IpcChannels['diff:commitCount']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 本地 git 算提交数;不打远端、不主动触发 sync。镜像还没拉齐就返回 null, - // UI 角标暂不显示,等下次 poll 触发 syncMirror 完成后自然命中。 - // 口径 = PR 自身提交(源分支「不在目标分支上」的非 merge 提交),对齐平台 /commits 列表。 - // **基准用目标分支 sha(head ^target)而非固定 merge-base**:源分支把目标分支(如 dev)合入自己后, - // merge-base 之后被带进来的目标提交也可达 head、不可达 merge-base → 用 merge-base 会把它们误计 - // (标 31 实则 2)。以 targetRef.sha 排除这些合入提交;merge 提交本身由 countCommits 的 --no-merges 略去。 - // (diff 仍用固定 merge-base 保稳定,与本计数口径各司其职。) - const n = await repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); - return n === null ? null : { count: n }; - }, - ); - - ipcMain.handle( - 'diff:getBlame', - async ( - _evt, - req: IpcChannels['diff:getBlame']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const id = repoIdentityFor(pr); - // 只对 base 已有部分展示 blame;PR 引入的行单独返给 renderer, - // 由 BlameColumn 画色带占位(对应 Monaco diff 添加/修改区的视觉)。 - const base = await resolveDiffBaseSha(pr); - const [allBlame, changedSet] = await Promise.all([ - repoMirror.getBlame(id, pr.sourceRef.sha, req.path), - repoMirror.listChangedHeadLines(id, base, pr.sourceRef.sha, req.path), - ]); - return { - lines: allBlame.filter((b) => !changedSet.has(b.line)), - changedLines: Array.from(changedSet).sort((a, b) => a - b), - }; - }, - ); - - ipcMain.handle('repo:getTotalSize', async (): Promise<{ totalBytes: number }> => { - const prs = await listStoredPullRequests(stateStore); - const seen = new Set(); - let total = 0; - for (const pr of prs) { - let id: RepoIdentity; - try { - id = repoIdentityFor(pr); - } catch { - continue; - } - const key = `${id.host}|${id.projectKey}|${id.repoSlug}`; - if (seen.has(key)) continue; - seen.add(key); - const r = await repoMirror.getSize(id); - total += r.totalBytes; - } - return { totalBytes: total }; - }); - - // ── pr-agent run 队列 ── - - ipcMain.handle( - 'pragent:run', - async ( - _evt, - req: IpcChannels['pragent:run']['request'], - ): Promise => { - if (!getPrAgentBridge()) { - throw new Error(t('prAgent.notReadyDetail')); - } - // 早期校验:/ask 必须带 question,避免排队后才报错 - if (req.tool === 'ask' && !req.question?.trim()) { - throw new Error(t('prAgent.askNeedsQuestion')); - } - const pr = await findPrOrThrow(req.localId); - return runQueue.enqueuePragentRun(pr, req.tool, req.question); - }, - ); - - ipcMain.handle( - 'pragent:cancel', - (_evt, req: IpcChannels['pragent:cancel']['request']): IpcChannels['pragent:cancel']['response'] => - runQueue.cancel(req.runId), - ); - - ipcMain.handle('pragent:queue', (): IpcChannels['pragent:queue']['response'] => runQueue.snapshot()); - - ipcMain.handle( - 'pragent:listRuns', - async ( - _evt, - req: IpcChannels['pragent:listRuns']['request'], - ): Promise => - listReviewRunsForPr(stateStore, req.localId, { - limit: req.limit, - beforeId: req.beforeId, - }), - ); - - ipcMain.handle( - 'pragent:getRun', - async ( - _evt, - req: IpcChannels['pragent:getRun']['request'], - ): Promise => - getReviewRun(stateStore, req.localId, req.runId), - ); - - ipcMain.handle( - 'pragent:clearRuns', - async ( - _evt, - req: IpcChannels['pragent:clearRuns']['request'], - ): Promise => { - // 清执行历史时一并清掉 Agent 会话(含收尾 summary / 步骤 transcript),否则清空后 - // 重开 PR 仍会从落盘会话恢复出「评审总结」卡片。 - await clearAgentSession(stateStore, req.localId); - // 一并清掉 AutoPilot 台账(评审建议 verdict),并广播 → PR 列表该 PR 的 ★ 徽标即时消失, - // 不残留陈旧评审状态、也不必等下个 poll 重取台账。 - await clearAutopilotLedger(stateStore, req.localId); - broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); - return { cleared: await clearReviewRunsForPr(stateStore, req.localId) }; - }, - ); - - // ── M4 草稿 ── - // 所有 mutator (create / update / delete) 写盘成功后立刻广播 drafts:changed, - // renderer drafts-store 据此重拉刷新 - - ipcMain.handle( - 'drafts:list', - async ( - _evt, - req: IpcChannels['drafts:list']['request'], - ): Promise => listDrafts(stateStore, req.localId), - ); - - ipcMain.handle( - 'drafts:create', - async ( - _evt, - req: IpcChannels['drafts:create']['request'], - ): Promise => { - // 防御:origin='finding' 必须带 source;origin='manual' 不要 source。 - // 上层 UI 已校验,但 IPC 边界再挡一道避免脏数据进盘 - const { draft, localId } = req; - if (draft.origin === 'finding' && !draft.source) { - throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); - } - if (draft.origin === 'manual' && draft.source) { - throw new Error('drafts:create: origin=manual 不应该传 source'); - } - const created = await createDraft(stateStore, localId, draft); - broadcastDraftsChanged(localId); - return created; - }, - ); - - ipcMain.handle( - 'drafts:update', - async ( - _evt, - req: IpcChannels['drafts:update']['request'], - ): Promise => { - const updated = await updateDraft(stateStore, req.localId, req.draftId, req.patch); - if (updated) broadcastDraftsChanged(req.localId); - return updated; - }, - ); - - ipcMain.handle( - 'drafts:delete', - async ( - _evt, - req: IpcChannels['drafts:delete']['request'], - ): Promise => { - await deleteDraft(stateStore, req.localId, req.draftId); - broadcastDraftsChanged(req.localId); - }, - ); - - ipcMain.handle( - 'drafts:publishBatch', - async ( - _evt, - req: IpcChannels['drafts:publishBatch']['request'], - ): Promise => { - const pr = await findPrOrThrow(req.localId); - const adapter = adapterForOrThrow(pr); - - // 拉一次当前草稿池:localId → id → draft,下面遍历 draftIds 时按 id 查。 - // 不在循环里反复 listDrafts,避免 PR 草稿量大时 O(N²) IO - const allDrafts = await listDrafts(stateStore, req.localId); - const draftById = new Map(allDrafts.map((d) => [d.id, d])); - - const results: IpcChannels['drafts:publishBatch']['response']['results'] = []; - let anyPublished = false; - for (const draftId of req.draftIds) { - const draft = draftById.get(draftId); - if (!draft) { - results.push({ draftId, ok: false, error: t('drafts.notFound') }); - continue; - } - // 状态守卫:rejected 不发 (用户决断不发)。 - // posted 不再守卫 — 发布成功后本地草稿直接删除,不存 'posted' 历史状态, - // 调用方传过来的 draftId 在 listDrafts 找不到时已经被前面 `if (!draft)` 兜住 - if (draft.status === 'rejected') { - results.push({ draftId, ok: false, error: t('drafts.rejected') }); - continue; - } - try { - // ReviewDraftAnchor → PrCommentAnchor 转换: - // - draft.anchor 没有 lineType (草稿创建时不知道这一行的 diff 角色), - // 按 side 做保守映射:new→added / old→removed。meebox 的草稿大多锚到 - // 变更行 (finding 来自 /review 的 issue + DraftZone hover '+' 也只对 - // 变更行可见),context 行评论场景极少。命中 context 时 Bitbucket 回 400, - // 错误会被 catch 收到 results 里给用户看 - // - 多行 (endLine > startLine) 在 Bitbucket REST 里无法表达 (anchor.line 是单 - // 行)。落到 endLine 而不是 startLine:评论会出现在标注范围**下方**, - // 不打断用户从上往下阅读时已经看过的代码上下文。renderer 端 DraftZone - // 仍按 startLine 渲染 (跟 finding/AI 建议触发位置一致),发布完远端 - // 评论会自然显示在 endLine —— 这两种位置都不影响"阅读上下文" 的初衷 - const posted = await adapter.publishInlineComment( - { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, - pr.remoteId, - { - path: draft.anchor.path, - line: draft.anchor.endLine, - side: draft.anchor.side, - lineType: draft.anchor.side === 'old' ? 'removed' : 'added', - }, - draft.body, - ); - // 发布成功 = 本地草稿使命完成,直接删掉保持草稿池干净。远端 Bitbucket 评论 - // 会通过下面的 force-refresh comments 拉回,UI 上由 CommentZone 承接显示, - // 不需要本地再留一份 'posted' 副本造成重复 (跟远端评论 zone 视觉打架) - await deleteDraft(stateStore, req.localId, draftId); - anyPublished = true; - results.push({ draftId, ok: true, postedRemoteId: posted.remoteId }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - logger.warn( - { localId: req.localId, draftId, err: msg }, - 'drafts:publishBatch: single draft failed', - ); - results.push({ draftId, ok: false, error: msg }); - } - } - - // 整批跑完统一广播 — drafts 列表更新刷 DraftZone status chip + FindingCard - broadcastDraftsChanged(req.localId); - - // 至少有一条发成功 → force-refresh Bitbucket 评论:清缓存 + 广播 comments:changed - // 让 CommentsPanel / DiffView 内嵌评论立即看到自己刚发的,不用等下一轮 poller - if (anyPublished) { - await invalidateCommentsCache(pr.localId); - } - return { results }; - }, - ); -} - -/** - * 给每条评论 (含 replies 子树) 打 canDelete / canEdit 标志。 - * - * - canDelete: author.name === 当前 PAT 用户 && 无 reply && 有 version - * (Bitbucket 拒删带 reply 的;DELETE 必带 version 乐观锁) - * - canEdit: author.name === 当前 PAT 用户 && 有 version - * (Bitbucket 允许编辑带 reply 的评论;PUT 也带 version) - * - * 当前用户拿不到 (ping 未完成 / 失败) → 全部 false。renderer 直读 flag 不再 - * 自己比对 author / version / replies,链路最短最稳。 - */ -function annotateOwnership(comments: PrComment[], adapter: PlatformAdapter): PrComment[] { - const me = adapter.getCurrentUser(); - if (!me) { - return setOwnershipRecursive(comments, () => ({ canDelete: false, canEdit: false })); - } - // 「带 reply 的评论不可删」是 Bitbucket 限制(删父评论会孤立子评论);GitHub / GitLab 允许删 - // 自己的评论(含有 reply 的)。用乐观锁能力位作 Bitbucket 代理。 - const noDeleteWithReplies = adapter.capabilities().commentOptimisticLock; - return setOwnershipRecursive(comments, (c) => { - const isMine = c.author.name === me.name; - const hasVersion = typeof c.version === 'number'; - return { - canDelete: isMine && hasVersion && (!noDeleteWithReplies || c.replies.length === 0), - canEdit: isMine && hasVersion, - }; - }); -} - -function setOwnershipRecursive( - comments: PrComment[], - judge: (c: PrComment) => { canDelete: boolean; canEdit: boolean }, -): PrComment[] { - return comments.map((c) => { - const flags = judge(c); - return { - ...c, - canDelete: flags.canDelete, - canEdit: flags.canEdit, - replies: setOwnershipRecursive(c.replies, judge), - }; - }); -} diff --git a/apps/desktop/src/main/services/run-queue.ts b/apps/desktop/src/main/services/run-queue.ts index 76614d3..2f336bb 100644 --- a/apps/desktop/src/main/services/run-queue.ts +++ b/apps/desktop/src/main/services/run-queue.ts @@ -23,13 +23,13 @@ import { getMainLanguage, t } from '../i18n/index.js'; import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; import { buildPrContext } from '../utils/pr-context.js'; import { buildProxyEnv } from '../utils/proxy.js'; -import type { IpcContext } from './context.js'; +import type { ServiceContext } from './context.js'; import { accumulateUsageSentinel, finalizeUsage, newUsageAcc, stripUsageSentinels, -} from './common/usage.js'; +} from './usage.js'; /** pr-agent run 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ export type RunPriority = 'user' | 'agent'; @@ -57,7 +57,7 @@ export interface RunQueueService { abortAllActiveRuns(): number; } -export function createRunQueueService(ctx: IpcContext): RunQueueService { +export function createRunQueueService(ctx: ServiceContext): RunQueueService { const { bootstrap, logger, @@ -66,11 +66,10 @@ export function createRunQueueService(ctx: IpcContext): RunQueueService { stateStore, repoMirror, broadcast, - adapterFor, - repoIdentityFor, - resolveDiffBaseSha, effectiveAgentDir, } = ctx; + // PR 领域操作(镜像 / diff base / adapter)经 PR 领域服务获取。 + const { pr: prService } = ctx; // === pr-agent run 队列 === // @@ -201,11 +200,11 @@ export function createRunQueueService(ctx: IpcContext): RunQueueService { return updated ?? { ...run, ...patch }; }; - const repoId = repoIdentityFor(pr); + const repoId = prService.repoIdentityFor(pr); await repoMirror.syncMirror(repoId); // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base(与 UI diff 同源):让 AI 评审基于 // 「PR 自分叉后引入的改动」,而非 targetRef.sha 漂移后混入别的 PR 的两点对比 - const diffBase = await resolveDiffBaseSha(pr); + const diffBase = await prService.resolveDiffBaseSha(pr); const wt = await repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); const ac = item.ac!; try { @@ -247,7 +246,7 @@ export function createRunQueueService(ctx: IpcContext): RunQueueService { let matchedRuleInstructions = ''; let matchedRuleId: string | undefined; if (req.tool !== 'ask') { - const adapter = adapterFor(pr); + const adapter = prService.adapterFor(pr); if (adapter) { try { prContext = await buildPrContext({ pr, adapter, logger }); diff --git a/apps/desktop/src/main/services/common/usage.ts b/apps/desktop/src/main/services/usage.ts similarity index 100% rename from apps/desktop/src/main/services/common/usage.ts rename to apps/desktop/src/main/services/usage.ts From 6cd1c36f266580b8d6fbb5bc5925c3ce6c9a9bea Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 13:52:19 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor(ipc):=20run=20=E9=98=9F=E5=88=97?= =?UTF-8?q?=E4=B8=8E=20Agent=20=E7=BC=96=E6=8E=92=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=B1=BB=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把两个持有可变实例状态的服务从工厂闭包改为 class,使 service 层封装一致 (PrService / RunQueueService / AgentOrchestratorService 均为 class,纯工具仍是函数模块): - run-queue.ts → class RunQueueService:waiting / active / maxConcurrency / embeddedSecretsEnsured 为私有字段,executeRun / pump / ensureEmbeddedSecrets 为私有方法; 纯语言助手(languageDirectiveFor / askLanguageSuffixFor / stripAskQuestionEcho)保留为模块函数 - agent-orchestrator.ts → class AgentOrchestratorService:agentControllers / runningAgentPrs / autopilotBusy 私有字段,编排各步骤为私有方法 - runAutopilotIfDue 消除冗余的 void (async () => {})() 闭包:异步 pass 体抽成具名私有方法 runAutopilotPass,busy 锁置位 / 复位在其内成对管理 - ipc.ts 改用 new RunQueueService / new AgentOrchestratorService 装配 纯结构性重构,不改运行行为;lint / typecheck / build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/ipc.ts | 8 +- .../src/main/services/agent-orchestrator.ts | 584 +++++++++--------- apps/desktop/src/main/services/run-queue.ts | 427 +++++++------ 3 files changed, 511 insertions(+), 508 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 590857d..eb6ae5a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -3,13 +3,13 @@ import * as app from './controllers/app.js'; import * as config from './controllers/config.js'; import * as pr from './controllers/pr.js'; import { handle } from './controllers/register.js'; -import { createAgentOrchestratorService } from './services/agent-orchestrator.js'; +import { AgentOrchestratorService } from './services/agent-orchestrator.js'; import { createServiceContext, type ControllerContext, type RegisterDeps, } from './services/context.js'; -import { createRunQueueService } from './services/run-queue.js'; +import { RunQueueService } from './services/run-queue.js'; export type { RegisterDeps } from './services/context.js'; @@ -24,9 +24,9 @@ export function registerIpcHandlers(deps: RegisterDeps): { } { const base = createServiceContext(deps); // run 队列:pragent:run(PR 域)、Agent 编排、AutoPilot 三方共用。 - const runQueue = createRunQueueService(base); + const runQueue = new RunQueueService(base); // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 - const orchestrator = createAgentOrchestratorService(base, runQueue); + const orchestrator = new AgentOrchestratorService(base, runQueue); // controller 层统一上下文:基础上下文 + 两个跨域 service,所有 controller 共享同一 ctx。 const ctx: ControllerContext = { ...base, runQueue, orchestrator }; diff --git a/apps/desktop/src/main/services/agent-orchestrator.ts b/apps/desktop/src/main/services/agent-orchestrator.ts index b0a520b..76659b1 100644 --- a/apps/desktop/src/main/services/agent-orchestrator.ts +++ b/apps/desktop/src/main/services/agent-orchestrator.ts @@ -22,9 +22,9 @@ import { runAgentReview } from '../agent-review.js'; import { getMainLanguage, t } from '../i18n/index.js'; import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; import { buildProxyEnv } from '../utils/proxy.js'; -import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from './usage.js'; import type { ServiceContext } from './context.js'; import type { RunQueueService } from './run-queue.js'; +import { accumulateUsageSentinel, finalizeUsage, newUsageAcc } from './usage.js'; // 共享 chat 通道:system + user → 文本 + usage。agent:run 评审与 AutoPilot 都用。 type AgentChat = (input: { @@ -32,32 +32,247 @@ type AgentChat = (input: { user: string; }) => Promise<{ text: string; usage?: TokenUsage }>; -export interface AgentOrchestratorService { - /** 对指定 PR 跑评审微流程(agent:run):装配上下文 + 注册中止 + 收尾落总结。 */ - runReview(pr: StoredPullRequest): Promise; +/** + * Agent 编排服务:手动评审(agent:run)、自由规划(agent:ask)、AutoPilot 后台预评审, + * 以及随 poll tick 清理已消失 PR 的在跑操作。 + * + * 运行态(每 PR 的 AbortController、「执行中」PR 集合、AutoPilot busy 锁)是实例可变状态, + * 故以 class 封装;派发的工具 run 复用注入的 RunQueueService(agent 低优先级泳道)。 + */ +export class AgentOrchestratorService { + // 编排 Agent(手动评审 agent:run + 自由规划 agent:ask)每 PR 至多一个在跑,AbortController 供 + // agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 + private readonly agentControllers = new Map(); + // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。区别于 + // agentControllers(仅手动可停会话):这里**手动 run/ask 与 AutoPilot 后台评审一并计入**, + // 让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 + private readonly runningAgentPrs = new Set(); + // Agent 编排层全局单并发:一次只跑一遍 AutoPilot pass(busy 锁),防止上一遍未完又叠跑。 + private autopilotBusy = false; + + constructor( + private readonly ctx: ServiceContext, + private readonly runQueue: RunQueueService, + ) {} + + /** + * 对指定 PR 跑评审微流程(agent:run):现读现装配上下文 + 注册 AbortController + 标记执行中, + * 收尾把「评审总结」落多轮对话与台账。 + */ + async runReview(pr: StoredPullRequest): Promise { + const { getPrAgentBridge, effectiveAgentDir, logger } = this.ctx; + if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); + // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 + const ac = new AbortController(); + this.agentControllers.set(pr.localId, ac); + this.markAgentRunning(pr.localId); + logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); + try { + const session = await this.withAgentChat( + (chat) => this.runReviewForPr(pr, agentContext, chat, ac.signal), + ac.signal, + ); + logger.info( + { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, + 'agent review done', + ); + // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 + await this.recordReviewSummaryMessage(pr, session); + return session; + } finally { + this.agentControllers.delete(pr.localId); + this.unmarkAgentRunning(pr.localId); + } + } + /** 对指定 PR 跑自由规划 Agent(agent:ask)。 */ - runPlanning(pr: StoredPullRequest, question: string): Promise; - /** 暂停某 PR 的 Agent 运行(agent:stop)。 */ - stop(localId: string): { ok: boolean }; - /** poll tick:满足开关 + 候选时跑一遍 AutoPilot pass(内部门控)。 */ - runAutopilotIfDue(): void; - /** poll tick:终止已被移除 / purge 的 PR 上仍在执行的 agent 操作。 */ - terminateAgentsForGonePrs(): Promise; -} + async runPlanning(pr: StoredPullRequest, question: string): Promise { + const { getPrAgentBridge, effectiveAgentDir, logger } = this.ctx; + if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + const ac = new AbortController(); + this.agentControllers.set(pr.localId, ac); + this.markAgentRunning(pr.localId); + // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 + logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); + try { + const session = await this.withAgentChat( + (chat) => this.runPlanningForPr(pr, question, agentContext, chat, ac.signal), + ac.signal, + ); + logger.info( + { + prLocalId: pr.localId, + status: session.status, + steps: session.stepCount, + terminationReason: session.terminationReason, + }, + 'agent chat done', + ); + return session; + } finally { + this.agentControllers.delete(pr.localId); + this.unmarkAgentRunning(pr.localId); + } + } + + /** 暂停某 PR 的 Agent 运行(agent:stop):abort 其 AbortController。 */ + stop(localId: string): { ok: boolean } { + const ac = this.agentControllers.get(localId); + if (!ac) return { ok: false }; + ac.abort(); + return { ok: true }; + } + + /** + * poll tick:满足开关 + 候选时跑一遍 AutoPilot pass(内部门控)。见 docs/arch/06-agent.md「AutoPilot」。 + * Agent 编排层全局单并发:busy 锁防止上一遍未完又叠跑;派发的工具 run 在共享队列并行。 + * 触发节奏对齐轮询(每个 onTick 评估一遍);准入门控 + 台账去重防止重复评审 / 打爆 LLM。 + */ + runAutopilotIfDue(): void { + const ap = this.ctx.bootstrap.config.agent.autopilot; + if (!ap.enabled || this.autopilotBusy || !this.ctx.getPrAgentBridge()) return; + // 准入通过 → fire-and-forget 异步 pass(poll tick 不阻塞);busy 锁在 runAutopilotPass 内成对管理。 + void this.runAutopilotPass(); + } + + /** 跑一遍 AutoPilot pass(busy 锁置位 / 复位包住全程)。仅由 runAutopilotIfDue 通过准入后触发。 */ + private async runAutopilotPass(): Promise { + const { bootstrap, stateStore, effectiveAgentDir, logger } = this.ctx; + const ap = bootstrap.config.agent.autopilot; + this.autopilotBusy = true; + try { + // 候选准入(硬性门控,自上而下): + // 1. 仅「待我评审」分类(discoveryFilters 含 review-requested)下、「待处理」状态(localStatus + // === 'pending')的 PR —— 已通过 / 标记需修改、或非待我评审的一律不自动评审。 + // (不支持发现分类的平台 discoveryFilters 为空 → 不命中,自然不自动触发。) + // 2. 会话中已有 /describe 或 /review 产出(成功 / 正在跑,手动或自动)→ 判定已评审过 / 评审中, + // 不再自动触发(评审失败无产出 → 不算,下轮可重试)。 + // 3. 仅排除「本版本已被判定跳过」的 PR(台账 decision='skipped')——避免对判过 skip 的 PR 反复 + // 重判;无产出又未被 skip 的待评审 PR 一律放行(不再因台账有任意记录就拦下)。 + // 再按 batch_size 截断。 + const prs = await listStoredPullRequests(stateStore); + const candidates: StoredPullRequest[] = []; + // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 + let reviewReqPending = 0; // 命中「待我评审 + 待处理」 + let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 + let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 + for (const pr of prs) { + if (candidates.length >= ap.batch_size) break; + if (!pr.discoveryFilters.includes('review-requested')) continue; + if (pr.localStatus !== 'pending') continue; + reviewReqPending++; + if (await hasReviewOutput(stateStore, pr.localId)) { + alreadyReviewed++; + continue; + } + const ledger = await getAutopilotLedger(stateStore, pr.localId); + if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { + skipDeduped++; + continue; + } + candidates.push(pr); + } + if (candidates.length === 0) { + // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 + logger.info( + { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, + 'autopilot pass: no eligible candidates', + ); + return; + } -export function createAgentOrchestratorService( - ctx: ServiceContext, - runQueue: RunQueueService, -): AgentOrchestratorService { - const { bootstrap, logger, stateStore, getPrAgentBridge, broadcast, effectiveAgentDir } = ctx; - const { enqueuePragentRun, cancelRunsForPr, queuedPrLocalIds } = runQueue; + const agentContext = await loadAgentContext(effectiveAgentDir(), { + onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + }); + await this.withAgentChat(async (chat) => { + // 批量判定(例外规则来自 AGENTS.md)。 + const { decisions } = await judgeAutopilotBatch(chat, { + candidates: candidates.map((p) => ({ + prLocalId: p.localId, + title: p.title, + description: p.description, + })), + agentsRules: agentContext.files.agents, + }); + const byId = new Map(candidates.map((p) => [p.localId, p] as const)); + // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策待并行编排。 + const toReview: StoredPullRequest[] = []; + for (const d of decisions) { + const pr = byId.get(d.prLocalId); + if (!pr) continue; + if (!d.review) { + // 输出判定 skip 的原因(候选都已过准入闸、非「已评审」,故这里的原因都是 LLM 的领域判定, + // 如分支合并 / 纯依赖升级 — 打出来便于核对「为何没评审这个 PR」)。 + logger.info({ prLocalId: pr.localId, reason: d.reason }, 'autopilot judge skip'); + await writeAutopilotLedger(stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'skipped', + reason: d.reason, + at: new Date().toISOString(), + }); + continue; + } + toReview.push(pr); + } + // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让工具的并发队列 + // (run-queue maxConcurrency)尽量被填满,而非逐 PR 串行空等。各 PR 写各自的文件,无竞争。 + await Promise.all( + toReview.map(async (pr) => { + // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 + this.markAgentRunning(pr.localId); + try { + const session = await this.runReviewForPr(pr, agentContext, chat, undefined, true); + // done:落「评审总结」消息 + 台账(含 verdict)+ 广播会话变更(与手动评审一致)。 + // 失败 / 暂停不落台账 → 无产出,下轮可重试(准入闸 2 用 hasReviewOutput 判,不再靠台账拦)。 + await this.recordReviewSummaryMessage(pr, session); + } finally { + this.unmarkAgentRunning(pr.localId); + } + }), + ); + }); + logger.info({ candidates: candidates.length }, 'autopilot pass done'); + } catch (err) { + logger.warn({ err }, 'autopilot pass failed (ignored)'); + } finally { + this.autopilotBusy = false; + } + } + + /** + * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 + * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 + */ + async terminateAgentsForGonePrs(): Promise { + const { stateStore, logger } = this.ctx; + const opPrIds = new Set(); + for (const id of this.agentControllers.keys()) opPrIds.add(id); + for (const id of this.runQueue.queuedPrLocalIds()) opPrIds.add(id); + if (opPrIds.size === 0) return; + const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); + for (const id of opPrIds) { + if (!live.has(id)) { + logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); + this.terminateAgentForPr(id); + } + } + } /** 设置 LLM env + 临时 chat cwd + chat 函数,运行 fn,收尾清理临时目录。 * signal:用户停止时 abort → 杀掉在跑的 LLM chat 子进程,让思考阶段也能立即中止(不必等模型返回)。 */ - const withAgentChat = async ( + private async withAgentChat( fn: (chat: AgentChat) => Promise, signal?: AbortSignal, - ): Promise => { + ): Promise { + const { getPrAgentBridge, bootstrap } = this.ctx; const bridge = getPrAgentBridge(); if (!bridge) throw new Error(t('prAgent.notReadyDetail')); // 复用与 pr-agent run 同一套 LLM env(provider 凭据 / 模型 / 代理 / 响应语言)。 @@ -85,16 +300,16 @@ export function createAgentOrchestratorService( } finally { await fs.rm(chatCwd, { recursive: true, force: true }); } - }; + } /** * 每个编排步骤的统一出口:① 后台日志(工具选择 / 判读 / 收尾各落一条,便于排障与离线回看); - * ② 广播给渲染层(agent:stepProgress)做过程化展示。thought / result 截断避免刷屏。 + * ② 广播给渲染层(agent:stepProgress)做过程化展示。 + * 后台日志只留骨架(kind / tool / 用时):thought 与 result(含用户输入 / 总结正文)不入日志, + * 避免刷屏 + 泄漏内容;完整步骤已落 transcript.json,需要时从那里回看。 */ - // 后台日志只留骨架(kind / tool / 用时):thought 与 result(含用户输入 / 总结正文)不入日志, - // 避免刷屏 + 泄漏内容;完整步骤已落 transcript.json,需要时从那里回看。 - const emitAgentStep = (pr: StoredPullRequest, sessionId: string, step: AgentStep): void => { - logger.info( + private emitAgentStep(pr: StoredPullRequest, sessionId: string, step: AgentStep): void { + this.ctx.logger.info( { prLocalId: pr.localId, sessionId, @@ -104,61 +319,37 @@ export function createAgentOrchestratorService( }, 'agent step', ); - broadcast('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); - }; + this.ctx.broadcast('agent:stepProgress', { sessionId, prLocalId: pr.localId, step }); + } - // 编排 Agent(手动评审 agent:run + 自由规划 agent:ask)每 PR 至多一个在跑,AbortController 供 - // agent:stop 即时中止——思考 / 工具执行任意阶段都能停。 - const agentControllers = new Map(); + private broadcastAgentRunning(): void { + this.ctx.broadcast('agent:runningChanged', { prLocalIds: [...this.runningAgentPrs] }); + } - // 运行中(思考或派发工具)的编排 Agent 所属 PR 集合,向 renderer 广播「执行中」。区别于 - // agentControllers(仅手动可停会话):这里**手动 run/ask 与 AutoPilot 后台评审一并计入**, - // 让 PR 列表项在纯思考阶段(无活跃工具 run)也显示执行中标记。 - const runningAgentPrs = new Set(); - const broadcastAgentRunning = (): void => { - broadcast('agent:runningChanged', { prLocalIds: [...runningAgentPrs] }); - }; - const markAgentRunning = (localId: string): void => { - runningAgentPrs.add(localId); - broadcastAgentRunning(); - }; - const unmarkAgentRunning = (localId: string): void => { - if (runningAgentPrs.delete(localId)) broadcastAgentRunning(); - }; + private markAgentRunning(localId: string): void { + this.runningAgentPrs.add(localId); + this.broadcastAgentRunning(); + } - /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ - const terminateAgentForPr = (localId: string): void => { - agentControllers.get(localId)?.abort(); - cancelRunsForPr(localId); - }; + private unmarkAgentRunning(localId: string): void { + if (this.runningAgentPrs.delete(localId)) this.broadcastAgentRunning(); + } - /** - * poll tick 后调用:把已被移除 / purge(不再在 listStoredPullRequests 里)的 PR 上仍在执行的 - * agent 操作一律直接终止——PR 都没了,继续评审无意义且浪费 LLM / 占用 worktree。 - */ - const terminateAgentsForGonePrs = async (): Promise => { - const opPrIds = new Set(); - for (const id of agentControllers.keys()) opPrIds.add(id); - for (const id of queuedPrLocalIds()) opPrIds.add(id); - if (opPrIds.size === 0) return; - const live = new Set((await listStoredPullRequests(stateStore)).map((p) => p.localId)); - for (const id of opPrIds) { - if (!live.has(id)) { - logger.info({ prLocalId: id }, 'agent ops terminated: pr removed/purged'); - terminateAgentForPr(id); - } - } - }; + /** 终止某 PR 上的全部 agent 操作:中止编排(agent:run/ask)+ 取消其派发的工具 run。 */ + private terminateAgentForPr(localId: string): void { + this.agentControllers.get(localId)?.abort(); + this.runQueue.cancelRunsForPr(localId); + } /** 对一个 PR 跑评审微流程(共用 enqueue 队列 / 持久化 / 步骤广播)。 */ - const runReviewForPr = ( + private runReviewForPr( pr: StoredPullRequest, agentContext: AgentContext, chat: AgentChat, signal?: AbortSignal, autopilot = false, - ): Promise => { - const agentCfg = bootstrap.config.agent; + ): Promise { + const agentCfg = this.ctx.bootstrap.config.agent; const matchedRule = pickMatchingRule(agentContext.rules, { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug, @@ -166,9 +357,9 @@ export function createAgentOrchestratorService( tool: 'review', }); return runAgentReview(pr, { - stateStore, + stateStore: this.ctx.stateStore, // 编排派发的 run 走 agent 低优先级泳道:用户随时点 /review 会插到它们之前。 - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), + enqueueRun: (p, tool, question) => this.runQueue.enqueuePragentRun(p, tool, question, 'agent'), chat, agentContext, matchedRule, @@ -177,75 +368,20 @@ export function createAgentOrchestratorService( toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), maxFollowupAsks: agentCfg.autopilot.max_followup_asks, summaryMaxChars: agentCfg.summary_max_chars, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), + onStep: (sessionId, step) => this.emitAgentStep(pr, sessionId, step), signal, autopilot, }); - }; + } - /** - * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— - * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 - * updatedAt)。台账既给 PR 列表的建议徽标(★,手动 / 自动一视同仁),也供 AutoPilot 同版本去重。 - * 失败 / 用户停止(paused)不落,便于后续重试。 - */ - const recordReviewSummaryMessage = async ( - pr: StoredPullRequest, - session: AgentSession, - ): Promise => { - if (session.status !== 'done' || !session.summary) return; - await appendAgentMessage(stateStore, pr.localId, { - role: 'assistant', - content: session.summary, - recommendation: session.recommendation, - }); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'review', - recommendation: session.recommendation?.verdict, - at: new Date().toISOString(), - }); - // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 - broadcast('agent:conversationChanged', { prLocalId: pr.localId }); - }; - - const runReview = async (pr: StoredPullRequest): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - // 现读现装配 Agent 上下文(SOUL/AGENTS/MEMORY/USER + rules),无缓存。 - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - // 注册 AbortController,让停止按钮(agent:stop)能在思考 / 执行任意阶段即时中止本次评审。 - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - logger.info({ prLocalId: pr.localId }, 'agent review start (manual)'); - try { - const session = await withAgentChat( - (chat) => runReviewForPr(pr, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { prLocalId: pr.localId, status: session.status, steps: session.stepCount }, - 'agent review done', - ); - // 收尾总结计入多轮对话(assistant 评审消息)→ UI 渲染「评审总结」卡片。 - await recordReviewSummaryMessage(pr, session); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }; - - const runPlanningForPr = ( + private runPlanningForPr( pr: StoredPullRequest, userRequest: string, agentContext: AgentContext, chat: AgentChat, signal: AbortSignal, - ): Promise => { + ): Promise { + const { bootstrap, effectiveAgentDir, logger } = this.ctx; const agentCfg = bootstrap.config.agent; const matchedRule = pickMatchingRule(agentContext.rules, { projectKey: pr.repo.projectKey, @@ -254,8 +390,8 @@ export function createAgentOrchestratorService( tool: 'review', }); return runAgentPlanning(pr, userRequest, { - stateStore, - enqueueRun: (p, tool, question) => enqueuePragentRun(p, tool, question, 'agent'), + stateStore: this.ctx.stateStore, + enqueueRun: (p, tool, question) => this.runQueue.enqueuePragentRun(p, tool, question, 'agent'), chat, agentContext, toolCatalog: buildToolCatalog(agentCfg.autopilot.grants), @@ -263,7 +399,7 @@ export function createAgentOrchestratorService( language: getMainLanguage(), maxSteps: agentCfg.max_steps, signal, - onStep: (sessionId, step) => emitAgentStep(pr, sessionId, step), + onStep: (sessionId, step) => this.emitAgentStep(pr, sessionId, step), // 持久化 Agent 主动记下的非隐私条目到当前 Agent 目录的各可写文件(USER/MEMORY/AGENTS); // SOUL.md 永不写。下一轮 loadAgentContext 现读即生效(跨会话记忆)。 recordMemory: async (notes) => { @@ -277,158 +413,32 @@ export function createAgentOrchestratorService( } }, }); - }; + } - const runPlanning = async (pr: StoredPullRequest, question: string): Promise => { - if (!getPrAgentBridge()) throw new Error(t('prAgent.notReadyDetail')); - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), + /** + * 评审收尾的统一落地(手动一键评审与 AutoPilot 背景评审共用):仅成功收尾(done)且有总结时—— + * ① 追加一条 assistant 评审消息(UI 渲染「评审总结」卡片);② 写评审台账(recommendation + 当前 + * updatedAt)。台账既给 PR 列表的建议徽标(★,手动 / 自动一视同仁),也供 AutoPilot 同版本去重。 + * 失败 / 用户停止(paused)不落,便于后续重试。 + */ + private async recordReviewSummaryMessage( + pr: StoredPullRequest, + session: AgentSession, + ): Promise { + if (session.status !== 'done' || !session.summary) return; + await appendAgentMessage(this.ctx.stateStore, pr.localId, { + role: 'assistant', + content: session.summary, + recommendation: session.recommendation, }); - const ac = new AbortController(); - agentControllers.set(pr.localId, ac); - markAgentRunning(pr.localId); - // 不记用户输入正文(避免泄漏 / 刷屏):只记发起本身,输入已落多轮对话。 - logger.info({ prLocalId: pr.localId }, 'agent chat start (planning)'); - try { - const session = await withAgentChat( - (chat) => runPlanningForPr(pr, question, agentContext, chat, ac.signal), - ac.signal, - ); - logger.info( - { - prLocalId: pr.localId, - status: session.status, - steps: session.stepCount, - terminationReason: session.terminationReason, - }, - 'agent chat done', - ); - return session; - } finally { - agentControllers.delete(pr.localId); - unmarkAgentRunning(pr.localId); - } - }; - - const stop = (localId: string): { ok: boolean } => { - const ac = agentControllers.get(localId); - if (!ac) return { ok: false }; - ac.abort(); - return { ok: true }; - }; - - // === AutoPilot 调度(见 docs/arch/06-agent.md「AutoPilot」)=== - // Agent 编排层全局单并发:一次只跑一遍 pass(busy 锁);其派发的工具 run 在共享队列并行。 - // 触发节奏对齐轮询:每个 poller onTick(间隔 = poller.interval_seconds)评估一遍,不再另设独立的最小 - // 间隔守卫——准入门控 + 台账去重已防止重复评审 / 打爆 LLM;busy 锁防止上一遍未完又叠跑。 - let autopilotBusy = false; - const runAutopilotIfDue = (): void => { - const ap = bootstrap.config.agent.autopilot; - if (!ap.enabled || autopilotBusy || !getPrAgentBridge()) { - return; - } - autopilotBusy = true; - void (async () => { - try { - // 候选准入(硬性门控,自上而下): - // 1. 仅「待我评审」分类(discoveryFilters 含 review-requested)下、「待处理」状态(localStatus - // === 'pending')的 PR —— 已通过 / 标记需修改、或非待我评审的一律不自动评审。 - // (不支持发现分类的平台 discoveryFilters 为空 → 不命中,自然不自动触发。) - // 2. 会话中已有 /describe 或 /review 产出(成功 / 正在跑,手动或自动)→ 判定已评审过 / 评审中, - // 不再自动触发(评审失败无产出 → 不算,下轮可重试)。 - // 3. 仅排除「本版本已被判定跳过」的 PR(台账 decision='skipped')——避免对判过 skip 的 PR 反复 - // 重判;无产出又未被 skip 的待评审 PR 一律放行(不再因台账有任意记录就拦下)。 - // 再按 batch_size 截断。 - const prs = await listStoredPullRequests(stateStore); - const candidates: StoredPullRequest[] = []; - // 准入漏斗计数(用于 0 候选时定位卡在哪一道闸——便于排查「为何不再触发」)。 - let reviewReqPending = 0; // 命中「待我评审 + 待处理」 - let alreadyReviewed = 0; // 其中已有 describe/review 产出(成功 / 进行中)而被排除 - let skipDeduped = 0; // 其中本版本已被判定跳过而被排除 - for (const pr of prs) { - if (candidates.length >= ap.batch_size) break; - if (!pr.discoveryFilters.includes('review-requested')) continue; - if (pr.localStatus !== 'pending') continue; - reviewReqPending++; - if (await hasReviewOutput(stateStore, pr.localId)) { - alreadyReviewed++; - continue; - } - const ledger = await getAutopilotLedger(stateStore, pr.localId); - if (ledger?.decision === 'skipped' && ledger.autoReviewedUpdatedAt === pr.updatedAt) { - skipDeduped++; - continue; - } - candidates.push(pr); - } - if (candidates.length === 0) { - // 仍在按周期评估,只是当前无新合格 PR——把漏斗计数打出来,避免被误读成「没在跑」。 - logger.info( - { total: prs.length, reviewReqPending, alreadyReviewed, skipDeduped }, - 'autopilot pass: no eligible candidates', - ); - return; - } - - const agentContext = await loadAgentContext(effectiveAgentDir(), { - onWarn: (msg, file) => logger.warn({ file }, `agent context: ${msg}`), - }); - await withAgentChat(async (chat) => { - // 批量判定(例外规则来自 AGENTS.md)。 - const { decisions } = await judgeAutopilotBatch(chat, { - candidates: candidates.map((p) => ({ - prLocalId: p.localId, - title: p.title, - description: p.description, - })), - agentsRules: agentContext.files.agents, - }); - const byId = new Map(candidates.map((p) => [p.localId, p] as const)); - // 先落「跳过」决策(无工具开销,顺序写盘即可);收集「评审」决策待并行编排。 - const toReview: StoredPullRequest[] = []; - for (const d of decisions) { - const pr = byId.get(d.prLocalId); - if (!pr) continue; - if (!d.review) { - // 输出判定 skip 的原因(候选都已过准入闸、非「已评审」,故这里的原因都是 LLM 的领域判定, - // 如分支合并 / 纯依赖升级 — 打出来便于核对「为何没评审这个 PR」)。 - logger.info({ prLocalId: pr.localId, reason: d.reason }, 'autopilot judge skip'); - await writeAutopilotLedger(stateStore, { - prLocalId: pr.localId, - autoReviewedUpdatedAt: pr.updatedAt, - decision: 'skipped', - reason: d.reason, - at: new Date().toISOString(), - }); - continue; - } - toReview.push(pr); - } - // 多 PR 评审并行编排:各编排 await 自己的工具 run 时彼此不挡,让工具的并发队列 - // (run-queue maxConcurrency)尽量被填满,而非逐 PR 串行空等。各 PR 写各自的文件,无竞争。 - await Promise.all( - toReview.map(async (pr) => { - // AutoPilot 后台评审无 AbortController,但同样标记「执行中」——纯思考阶段也在 PR 列表项显示。 - markAgentRunning(pr.localId); - try { - const session = await runReviewForPr(pr, agentContext, chat, undefined, true); - // done:落「评审总结」消息 + 台账(含 verdict)+ 广播会话变更(与手动评审一致)。 - // 失败 / 暂停不落台账 → 无产出,下轮可重试(准入闸 2 用 hasReviewOutput 判,不再靠台账拦)。 - await recordReviewSummaryMessage(pr, session); - } finally { - unmarkAgentRunning(pr.localId); - } - }), - ); - }); - logger.info({ candidates: candidates.length }, 'autopilot pass done'); - } catch (err) { - logger.warn({ err }, 'autopilot pass failed (ignored)'); - } finally { - autopilotBusy = false; - } - })(); - }; - - return { runReview, runPlanning, stop, runAutopilotIfDue, terminateAgentsForGonePrs }; + await writeAutopilotLedger(this.ctx.stateStore, { + prLocalId: pr.localId, + autoReviewedUpdatedAt: pr.updatedAt, + decision: 'review', + recommendation: session.recommendation?.verdict, + at: new Date().toISOString(), + }); + // 通知渲染层:若正打开该 PR,重载会话让后台评审的「评审总结」卡片即时出现(手动评审自行重载,重复无害)。 + this.ctx.broadcast('agent:conversationChanged', { prLocalId: pr.localId }); + } } diff --git a/apps/desktop/src/main/services/run-queue.ts b/apps/desktop/src/main/services/run-queue.ts index 2f336bb..c8093eb 100644 --- a/apps/desktop/src/main/services/run-queue.ts +++ b/apps/desktop/src/main/services/run-queue.ts @@ -34,7 +34,48 @@ import { /** pr-agent run 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ export type RunPriority = 'user' | 'agent'; -export interface RunQueueService { +/** 队列项:一次入队的 pr-agent run 的全部上下文(含 resolve/reject 回原始调用方)。 */ +interface QueueItem { + info: PragentRunInfo; + req: { localId: string; tool: ReviewRunTool; question?: string }; + pr: StoredPullRequest; + resolve: (run: ReviewRun) => void; + reject: (err: Error) => void; + /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。 */ + priority: RunPriority; + /** 仅 active 状态填;用于 cancel SIGKILL */ + ac?: AbortController; +} + +/** + * pr-agent run 队列服务。 + * + * FIFO 队列,并发上限 maxConcurrency(post-Docker 下每个 run 独立 worktree + 独立子进程, + * 并发安全)。其余在 waiting 排队;每次 active 完成 / 取消 → 自动泵下一条。 + * + * 设计要点: + * - runId 在入队时就分配(跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 active / waiting + * 两种状态都能精确定位 + * - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact + * - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent + * - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 + * + * 队列与运行态(waiting / active / 并发上限)是实例可变状态,故以 class 封装;PR 领域操作 + * (镜像 / diff base / adapter)经注入的 ctx.pr 取用。 + */ +export class RunQueueService { + private readonly waiting: QueueItem[] = []; + /** 并发运行中的 run(runId → item);上限 maxConcurrency。 */ + private readonly active = new Map(); + private readonly maxConcurrency: number; + private readonly execFileP = promisify(execFile); + /** embedded .secrets.toml 兜底的 memo(只在首个 embedded run 解析一次目录 + 写文件)。 */ + private embeddedSecretsEnsured: Promise | null = null; + + constructor(private readonly ctx: ServiceContext) { + this.maxConcurrency = ctx.bootstrap.config.pr_agent.max_concurrency; + } + /** * 入队一个 pr-agent run(与用户手动 run 共用同一队列 / 并发 / 取消机制)。dedup:同 PR * 同工具已在执行 / 排队则抛错(/ask 不限)。resolve 完成的 ReviewRun。 @@ -43,92 +84,151 @@ export interface RunQueueService { pr: StoredPullRequest, tool: ReviewRunTool, question?: string, - priority?: RunPriority, - ): Promise; + priority: RunPriority = 'user', + ): Promise { + const { logger } = this.ctx; + if (tool !== 'ask') { + const sameTask = (q: QueueItem): boolean => + q.info.prLocalId === pr.localId && q.info.tool === tool; + if ([...this.active.values()].some(sameTask) || this.waiting.some(sameTask)) { + throw new Error(t('prAgent.duplicateTask', { tool })); + } + } + // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 + const runId = makeRunId(new Date()); + return new Promise((resolve, reject) => { + const item: QueueItem = { + info: { + runId, + prLocalId: pr.localId, + repoSlug: pr.repo.repoSlug, + prNumber: pr.remoteId, + tool, + question: tool === 'ask' ? question : undefined, + enqueuedAt: new Date().toISOString(), + startedAt: null, + }, + req: { localId: pr.localId, tool, question }, + pr, + priority, + resolve, + reject, + }; + // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 + if (priority === 'user') { + const firstAgentIdx = this.waiting.findIndex((q) => q.priority === 'agent'); + if (firstAgentIdx >= 0) this.waiting.splice(firstAgentIdx, 0, item); + else this.waiting.push(item); + } else { + this.waiting.push(item); + } + logger.info( + { runId, localId: pr.localId, tool, priority, queueLen: this.waiting.length }, + 'pragent run enqueued', + ); + this.pump(); + }); + } + /** 取消一个 run(pragent:cancel):active→SIGKILL;waiting→出队 + reject;都不匹配→ok:false。 */ - cancel(runId: string): { ok: boolean }; + cancel(runId: string): { ok: boolean } { + const { logger } = this.ctx; + // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) + const running = this.active.get(runId); + if (running) { + logger.info({ runId }, 'pragent run cancel: active'); + running.ac?.abort(); + return { ok: true }; + } + // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) + const idx = this.waiting.findIndex((q) => q.info.runId === runId); + if (idx >= 0) { + const [removed] = this.waiting.splice(idx, 1); + logger.info({ runId, queueLen: this.waiting.length }, 'pragent run cancel: queued'); + removed!.reject(new Error('queued run cancelled')); + this.broadcastQueueChanged(); + return { ok: true }; + } + return { ok: false }; + } + /** 当前队列快照(pragent:queue / 广播用)。 */ - snapshot(): { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; - /** 取消某 PR 的全部 run:active 的 SIGKILL,waiting 的出队 + reject。 */ - cancelRunsForPr(localId: string): void; - /** active + waiting 涉及的 PR localId 集合(terminateAgentsForGonePrs 用)。 */ - queuedPrLocalIds(): string[]; - /** 应用退出时中止所有进行中的 run,返回被中止的 run 数。 */ - abortAllActiveRuns(): number; -} + snapshot(): { active: PragentRunInfo[]; waiting: PragentRunInfo[] } { + return { + active: [...this.active.values()].map((q) => q.info), + waiting: this.waiting.map((q) => q.info), + }; + } -export function createRunQueueService(ctx: ServiceContext): RunQueueService { - const { - bootstrap, - logger, - getPrAgentBridge, - embeddedPythonPath, - stateStore, - repoMirror, - broadcast, - effectiveAgentDir, - } = ctx; - // PR 领域操作(镜像 / diff base / adapter)经 PR 领域服务获取。 - const { pr: prService } = ctx; + /** 取消某 PR 的全部 run:active 的 SIGKILL,waiting 的出队 + reject。 */ + cancelRunsForPr(localId: string): void { + for (const item of this.active.values()) if (item.req.localId === localId) item.ac?.abort(); + let removed = false; + for (let i = this.waiting.length - 1; i >= 0; i--) { + if (this.waiting[i]!.req.localId === localId) { + const [q] = this.waiting.splice(i, 1); + q!.reject(new Error('pr removed')); + removed = true; + } + } + if (removed) this.broadcastQueueChanged(); + } - // === pr-agent run 队列 === - // - // FIFO 队列,同时只有 1 条在跑 (避免撞 LLM rate limit / 抢 worktree), - // 其余在 waiting 排队。每次 active 完成 / 取消 → 自动开下一条。 - // - // 设计要点: - // - runId 在入队时就分配 (跟最终落盘 ReviewRun.id 一致),cancel(runId) 在 - // active / waiting 两种状态都能精确定位 - // - queued 状态不落盘;被取消时直接 reject 原 Promise,不留 disk artifact - // - 真正 dequeue 才 startReviewRun 写 disk + 跑 pr-agent - // - 每次队列变化广播 'pragent:queueChanged',renderer store 同步 - interface QueueItem { - info: PragentRunInfo; - req: { localId: string; tool: ReviewRunTool; question?: string }; - pr: StoredPullRequest; - resolve: (run: ReviewRun) => void; - reject: (err: Error) => void; - /** 优先级泳道:user(手动发起,高)/ agent(编排 / AutoPilot 派发,低)。见 §7 调度。 */ - priority: RunPriority; - /** 仅 active 状态填;用于 cancel SIGKILL */ - ac?: AbortController; + /** active + waiting 涉及的 PR localId 集合(terminateAgentsForGonePrs 用)。 */ + queuedPrLocalIds(): string[] { + const ids: string[] = []; + for (const item of this.active.values()) ids.push(item.req.localId); + for (const item of this.waiting) ids.push(item.req.localId); + return ids; } - const waiting: QueueItem[] = []; - // 并发运行中的 run(runId → item);上限 maxConcurrency。post-Docker 下每个 run - // 独立 worktree(路径带 nonce)+ 独立子进程,并发安全;串行不再是正确性要求。 - const active = new Map(); - const maxConcurrency = bootstrap.config.pr_agent.max_concurrency; - const snapshot = (): { active: PragentRunInfo[]; waiting: PragentRunInfo[] } => ({ - active: [...active.values()].map((q) => q.info), - waiting: waiting.map((q) => q.info), - }); + /** 应用退出时中止所有进行中的 run,返回被中止的 run 数。 */ + abortAllActiveRuns(): number { + let n = 0; + for (const item of this.active.values()) { + item.ac?.abort(); + n++; + } + return n; + } - const broadcastQueueChanged = (): void => { - broadcast('pragent:queueChanged', snapshot()); - }; + private broadcastQueueChanged(): void { + this.ctx.broadcast('pragent:queueChanged', this.snapshot()); + } - // /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), - // 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 - const stripAskQuestionEcho = (md: string, ...echoed: string[]): string => { - const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); - if (!qs.size || !md) return md; - return md - .split('\n') - .filter((line) => !qs.has(line.trim())) - .join('\n'); - }; + /** + * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 + * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 + */ + private pump(): void { + while (this.active.size < this.maxConcurrency && this.waiting.length > 0) { + const item = this.waiting.shift()!; + this.active.set(item.info.runId, item); + item.ac = new AbortController(); + void this.executeRun(item) + .then((finished) => item.resolve(finished)) + .catch((err: unknown) => { + item.reject(err instanceof Error ? err : new Error(String(err))); + }) + .finally(() => { + this.active.delete(item.info.runId); + this.broadcastQueueChanged(); + // 放微任务里再泵,避免递归栈累积 + queueMicrotask(() => this.pump()); + }); + } + this.broadcastQueueChanged(); + } - // embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 - // .secrets.toml(pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥 - // 不用 secrets.toml,写个空文件压掉告警)。 - // memo 化:只在首个 embedded run 解析一次 pr_agent 目录 + 写文件,后续直接复用。 - // importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 - const execFileP = promisify(execFile); - let embeddedSecretsEnsured: Promise | null = null; - const ensureEmbeddedSecrets = (pythonPath: string): Promise => { - embeddedSecretsEnsured ??= (async () => { - const { stdout } = await execFileP(pythonPath, [ + /** + * embedded 策略:执行期在嵌入式安装目录的 settings/ 与 settings_prod/ 补空 .secrets.toml + * (pr-agent 启动会去找该文件,缺失就打 WARNING;我们走 env 传密钥不用 secrets.toml,写个空 + * 文件压掉告警)。memo 化:只在首个 embedded run 解析一次目录 + 写文件,后续直接复用。 + * importlib.util.find_spec 仅定位不 import pr_agent,快;失败仅 warn 不阻断 run。 + */ + private ensureEmbeddedSecrets(pythonPath: string): Promise { + this.embeddedSecretsEnsured ??= (async () => { + const { stdout } = await this.execFileP(pythonPath, [ '-c', "import importlib.util,os;print(os.path.dirname(importlib.util.find_spec('pr_agent').origin))", ]); @@ -147,17 +247,28 @@ export function createRunQueueService(ctx: ServiceContext): RunQueueService { } } })().catch((err: unknown) => { - logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); + this.ctx.logger.warn({ err }, 'ensure embedded .secrets.toml failed (ignored)'); }); - return embeddedSecretsEnsured; - }; + return this.embeddedSecretsEnsured; + } /** * 真正执行一个 queue item:startReviewRun → worktree → bridge.run → finishWith。 - * 由 pump() 调用,签名稳定后跟 queue 主体解耦;任何抛错都被 pump 兜成 - * Promise reject,外层 pragent:run 调用方收到。 + * 由 pump() 调用;任何抛错都被 pump 兜成 Promise reject,外层 pragent:run 调用方收到。 */ - const executeRun = async (item: QueueItem): Promise => { + private async executeRun(item: QueueItem): Promise { + const { + bootstrap, + logger, + getPrAgentBridge, + embeddedPythonPath, + stateStore, + repoMirror, + broadcast, + effectiveAgentDir, + pr: prService, + } = this.ctx; + const prAgentBridge = getPrAgentBridge(); if (!prAgentBridge) throw new Error(t('prAgent.notReady')); const { req, pr } = item; @@ -180,7 +291,7 @@ export function createRunQueueService(ctx: ServiceContext): RunQueueService { }); // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播 item.info = { ...item.info, startedAt: run.startedAt }; - broadcastQueueChanged(); + this.broadcastQueueChanged(); logger.info( { runId: run.id, localId: pr.localId, tool: req.tool, strategy: prAgentBridge.strategy }, 'pragent run start', @@ -417,7 +528,7 @@ export function createRunQueueService(ctx: ServiceContext): RunQueueService { // (直接写安装目录;memo 化只首次做)。local-cli 不需要 (pipx 装的 pr-agent // 路径不同,告警也不出) if (prAgentBridge.strategy === 'embedded' && embeddedPythonPath) { - await ensureEmbeddedSecrets(embeddedPythonPath); + await this.ensureEmbeddedSecrets(embeddedPythonPath); } const result = await prAgentBridge.run({ @@ -559,138 +670,20 @@ export function createRunQueueService(ctx: ServiceContext): RunQueueService { } finally { await wt.cleanup(); } - }; - - /** - * 队列泵:在并发未达上限且 waiting 非空时,连续 dequeue 起跑,直到填满 maxConcurrency。 - * 每条 run 结束(成功/失败/取消)后从 active 移除并再泵一次,自然续上后续任务。 - */ - const pump = (): void => { - while (active.size < maxConcurrency && waiting.length > 0) { - const item = waiting.shift()!; - active.set(item.info.runId, item); - item.ac = new AbortController(); - void executeRun(item) - .then((finished) => item.resolve(finished)) - .catch((err: unknown) => { - item.reject(err instanceof Error ? err : new Error(String(err))); - }) - .finally(() => { - active.delete(item.info.runId); - broadcastQueueChanged(); - // 放微任务里再泵,避免递归栈累积 - queueMicrotask(pump); - }); - } - broadcastQueueChanged(); - }; - - const enqueuePragentRun = ( - pr: StoredPullRequest, - tool: ReviewRunTool, - question?: string, - priority: RunPriority = 'user', - ): Promise => { - if (tool !== 'ask') { - const sameTask = (q: QueueItem): boolean => - q.info.prLocalId === pr.localId && q.info.tool === tool; - if ([...active.values()].some(sameTask) || waiting.some(sameTask)) { - throw new Error(t('prAgent.duplicateTask', { tool })); - } - } - // 入队时就分配 runId;后续 cancel(runId) 在 waiting / active 都能定位 - const runId = makeRunId(new Date()); - return new Promise((resolve, reject) => { - const item: QueueItem = { - info: { - runId, - prLocalId: pr.localId, - repoSlug: pr.repo.repoSlug, - prNumber: pr.remoteId, - tool, - question: tool === 'ask' ? question : undefined, - enqueuedAt: new Date().toISOString(), - startedAt: null, - }, - req: { localId: pr.localId, tool, question }, - pr, - priority, - resolve, - reject, - }; - // 优先级插队:user 任务排到所有 agent 任务之前(同泳道内仍 FIFO);不打断在跑的 run。 - if (priority === 'user') { - const firstAgentIdx = waiting.findIndex((q) => q.priority === 'agent'); - if (firstAgentIdx >= 0) waiting.splice(firstAgentIdx, 0, item); - else waiting.push(item); - } else { - waiting.push(item); - } - logger.info( - { runId, localId: pr.localId, tool, priority, queueLen: waiting.length }, - 'pragent run enqueued', - ); - pump(); - }); - }; - - const cancel = (runId: string): { ok: boolean } => { - // active 命中 → SIGKILL (finally 会写 cancelled 到 disk) - const running = active.get(runId); - if (running) { - logger.info({ runId }, 'pragent run cancel: active'); - running.ac?.abort(); - return { ok: true }; - } - // waiting 命中 → 从队列删除 + reject 原 Promise,不写盘 (从未真正跑过) - const idx = waiting.findIndex((q) => q.info.runId === runId); - if (idx >= 0) { - const [removed] = waiting.splice(idx, 1); - logger.info({ runId, queueLen: waiting.length }, 'pragent run cancel: queued'); - removed!.reject(new Error('queued run cancelled')); - broadcastQueueChanged(); - return { ok: true }; - } - return { ok: false }; - }; - - const cancelRunsForPr = (localId: string): void => { - for (const item of active.values()) if (item.req.localId === localId) item.ac?.abort(); - let removed = false; - for (let i = waiting.length - 1; i >= 0; i--) { - if (waiting[i]!.req.localId === localId) { - const [q] = waiting.splice(i, 1); - q!.reject(new Error('pr removed')); - removed = true; - } - } - if (removed) broadcastQueueChanged(); - }; - - const queuedPrLocalIds = (): string[] => { - const ids: string[] = []; - for (const item of active.values()) ids.push(item.req.localId); - for (const item of waiting) ids.push(item.req.localId); - return ids; - }; - - const abortAllActiveRuns = (): number => { - let n = 0; - for (const item of active.values()) { - item.ac?.abort(); - n++; - } - return n; - }; + } +} - return { - enqueuePragentRun, - cancel, - snapshot, - cancelRunsForPr, - queuedPrLocalIds, - abortAllActiveRuns, - }; +/** + * /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), + * 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 + */ +function stripAskQuestionEcho(md: string, ...echoed: string[]): string { + const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); + if (!qs.size || !md) return md; + return md + .split('\n') + .filter((line) => !qs.has(line.trim())) + .join('\n'); } /** From cf6c46efe5e36b536630f4fb36aa6fa487c92067 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 15:09:06 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor(ipc):=20controller=20=E6=94=B9?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20ipcMain.handle=20+=20ServiceContext=20?= =?UTF-8?q?=E5=8D=95=E4=BE=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handler 回归标准 ipcMain.handle 形态,去掉自定义 handle 包装层: - controller 改为原生监听器签名 (event, req) => response(IpcController 类型仍绑通道契约 做 body 类型校验);不带 ctx 参数,依赖经 getContext() 进程级单例取用 - ServiceContext 装配后经 setControllerContext 安装为单例;controllers/register.ts 去掉 handle 包装函数,更名 controllers/types.ts 只留 IpcController 类型 - ipc.ts 直接 ipcMain.handle('channel', controller) 注册,每通道一行 + 行内场景注释 - diff:listComments 的 in-flight 去重改为「显式构造 Promise(内部 async IIFE 顺序 await)+ 同步 set 进 map」,并补注释说明为何不能整体写成顶层 async(首个 await 挂起前需先注册 Promise) - controller 文档注释统一 3 行 /** */,领域分节统一块注释;IpcController 注释 JSDoc 化 纯结构性重构,不改运行行为;lint / typecheck / build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/agent.ts | 74 +++-- apps/desktop/src/main/controllers/app.ts | 103 ++++--- apps/desktop/src/main/controllers/config.ts | 180 +++++++----- apps/desktop/src/main/controllers/pr.ts | 270 ++++++++++++------ apps/desktop/src/main/controllers/register.ts | 31 -- apps/desktop/src/main/controllers/types.ts | 17 ++ apps/desktop/src/main/ipc.ts | 135 ++++----- apps/desktop/src/main/services/context.ts | 19 ++ 8 files changed, 518 insertions(+), 311 deletions(-) delete mode 100644 apps/desktop/src/main/controllers/register.ts create mode 100644 apps/desktop/src/main/controllers/types.ts diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts index 081f261..77d5fac 100644 --- a/apps/desktop/src/main/controllers/agent.ts +++ b/apps/desktop/src/main/controllers/agent.ts @@ -7,13 +7,19 @@ import { } from '@meebox/poller'; import { pickMatchingRule } from '@meebox/rules'; import type { AgentRecommendationVerdict } from '@meebox/shared'; -import type { IpcController } from './register.js'; +import { getContext } from '../services/context.js'; +import type { IpcController } from './types.js'; -// ── Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 ── +/* + * Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 + */ -// 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。 -export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (ctx, req) => { +/** + * 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。 + */ +export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (_event, req) => { if (req.tool === 'ask') return null; + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const rules = await loadAgentRules(ctx.effectiveAgentDir(), { onWarn: (msg, file) => ctx.logger.warn({ file }, `rules: ${msg}`), @@ -34,30 +40,54 @@ export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (ctx, req }; }; -// 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。 -export const runReview: IpcController<'agent:run'> = async (ctx, req) => - ctx.orchestrator.runReview(await ctx.pr.findPrOrThrow(req.localId)); +/** + * 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。 + */ +export const runReview: IpcController<'agent:run'> = async (_event, req) => { + const ctx = getContext(); + return ctx.orchestrator.runReview(await ctx.pr.findPrOrThrow(req.localId)); +}; + +/** + * 自由规划 Agent(自然语言「对话即委派」)。 + */ +export const runPlanning: IpcController<'agent:ask'> = async (_event, req) => { + const ctx = getContext(); + return ctx.orchestrator.runPlanning(await ctx.pr.findPrOrThrow(req.localId), req.question); +}; + +/** + * 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。 + */ +export const stopAgent: IpcController<'agent:stop'> = (_event, req) => + getContext().orchestrator.stop(req.localId); -// 自由规划 Agent(自然语言「对话即委派」)。 -export const runPlanning: IpcController<'agent:ask'> = async (ctx, req) => - ctx.orchestrator.runPlanning(await ctx.pr.findPrOrThrow(req.localId), req.question); +/** + * 读指定 PR 已落盘的 Agent 会话(跨 PR 切换、重启后恢复)。 + */ +export const getSession: IpcController<'agent:getSession'> = (_event, req) => + getAgentSession(getContext().stateStore, req.localId); -// 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。 -export const stopAgent: IpcController<'agent:stop'> = (ctx, req) => ctx.orchestrator.stop(req.localId); +/** + * 读指定 PR 的多轮对话消息。 + */ +export const getConversation: IpcController<'agent:getConversation'> = (_event, req) => + getAgentConversation(getContext().stateStore, req.localId); -// 读已落盘的 Agent 会话 / 多轮对话 / 过程步骤(跨 PR 切换、重启后恢复)。 -export const getSession: IpcController<'agent:getSession'> = (ctx, req) => - getAgentSession(ctx.stateStore, req.localId); -export const getConversation: IpcController<'agent:getConversation'> = (ctx, req) => - getAgentConversation(ctx.stateStore, req.localId); -export const getTranscript: IpcController<'agent:getTranscript'> = (ctx, req) => - getAgentTranscript(ctx.stateStore, req.localId); +/** + * 读指定 PR 的 Agent 过程步骤(transcript)。 + */ +export const getTranscript: IpcController<'agent:getTranscript'> = (_event, req) => + getAgentTranscript(getContext().stateStore, req.localId); -// 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。 -export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (ctx, req) => { +/** + * 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。 + */ +export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (_event, req) => { + const { stateStore } = getContext(); const out: Record = {}; for (const id of req.localIds) { - const ledger = await getAutopilotLedger(ctx.stateStore, id); + const ledger = await getAutopilotLedger(stateStore, id); if (ledger?.decision === 'review' && ledger.recommendation) { out[id] = ledger.recommendation; } diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts index 219fb4c..02f7066 100644 --- a/apps/desktop/src/main/controllers/app.ts +++ b/apps/desktop/src/main/controllers/app.ts @@ -5,24 +5,38 @@ import { app, BrowserWindow, dialog, shell } from 'electron'; import type { Logger } from 'pino'; import { t } from '../i18n/index.js'; import { buildAppInfo, buildConnectionSummaries } from '../services/app.js'; +import { getContext } from '../services/context.js'; import { sniffImageContentType } from '../utils/image.js'; import { checkForUpdate } from '../utils/update-check.js'; import { getLastUpdateResult, publishUpdateResult } from '../utils/update-state.js'; -import type { IpcController } from './register.js'; +import type { IpcController } from './types.js'; -// ── GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 ── +/* + * GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 + */ -export const readAppInfo: IpcController<'app:info'> = (ctx) => buildAppInfo(ctx.bootstrap); +/** + * 应用 / 运行时版本信息(关于页)。 + */ +export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getContext().bootstrap); -export const readAppPaths: IpcController<'app:paths'> = (ctx) => ctx.bootstrap.paths; +/** + * 关键目录路径(config / agent / 日志)。 + */ +export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths; -export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = (ctx) => - ctx.getPrAgentStatus(); +/** + * pr-agent 探测状态(是否就绪)。 + */ +export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = () => + getContext().getPrAgentStatus(); -// 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。 let rendererLogger: Logger | undefined; -export const writeRendererLog: IpcController<'log:write'> = (ctx, req) => { - rendererLogger ??= ctx.logger.child({ scope: 'renderer' }); +/** + * 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。 + */ +export const writeRendererLog: IpcController<'log:write'> = (_event, req) => { + rendererLogger ??= getContext().logger.child({ scope: 'renderer' }); const obj = req.meta ?? {}; switch (req.level) { case 'error': @@ -40,17 +54,23 @@ export const writeRendererLog: IpcController<'log:write'> = (ctx, req) => { } }; -// 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。 -export const listConnections: IpcController<'app:connections'> = (ctx) => - buildConnectionSummaries(ctx.bootstrap, ctx.connectionRuntime.adapters); +/** + * 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。 + */ +export const listConnections: IpcController<'app:connections'> = () => { + const { bootstrap, connectionRuntime } = getContext(); + return buildConnectionSummaries(bootstrap, connectionRuntime.adapters); +}; // 头像两级缓存:进程内 Map(含 null 负缓存)+ 磁盘文件(TTL 7 天,按 mtime 判过期)。 const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000; const avatarMem = new Map(); -// 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。 -export const getUserAvatar: IpcController<'app:userAvatar'> = async (ctx, req) => { - const { logger, connectionRuntime, bootstrap } = ctx; +/** + * 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。 + */ +export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req) => { + const { logger, connectionRuntime, bootstrap } = getContext(); const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars'); const memKey = `${req.connectionId}|${req.slug}`; if (avatarMem.has(memKey)) return avatarMem.get(memKey)!; @@ -112,28 +132,37 @@ export const getUserAvatar: IpcController<'app:userAvatar'> = async (ctx, req) = } }; -// OS 默认编辑器打开 config.yaml。 -export const openConfigFile: IpcController<'app:openConfigFile'> = async (ctx) => { - const err = await shell.openPath(ctx.bootstrap.paths.configFile); +/** + * OS 默认编辑器打开 config.yaml。 + */ +export const openConfigFile: IpcController<'app:openConfigFile'> = async () => { + const err = await shell.openPath(getContext().bootstrap.paths.configFile); if (err) throw new Error(`failed to open config.yaml: ${err}`); }; -// 文件管理器打开当前生效的 Agent 目录(不存在则先建)。 -export const openAgentDir: IpcController<'app:openAgentDir'> = async (ctx) => { - const dir = ctx.effectiveAgentDir(); +/** + * 文件管理器打开当前生效的 Agent 目录(不存在则先建)。 + */ +export const openAgentDir: IpcController<'app:openAgentDir'> = async () => { + const dir = getContext().effectiveAgentDir(); await fs.mkdir(dir, { recursive: true }).catch(() => undefined); const err = await shell.openPath(dir); if (err) throw new Error(`failed to open agent dir: ${err}`); }; -// 打开 DevTools(分离窗口)——需访问发起调用的 webContents。 -export const openDevTools: IpcController<'app:openDevTools'> = (_ctx, _req, evt) => { - evt.sender.openDevTools({ mode: 'detach' }); +/** + * 打开 DevTools(分离窗口)——需访问发起调用的 webContents。 + */ +export const openDevTools: IpcController<'app:openDevTools'> = (event) => { + event.sender.openDevTools({ mode: 'detach' }); }; -// 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。 -export const checkUpdate: IpcController<'app:checkUpdate'> = async (ctx) => { - if (!ctx.bootstrap.config.update.check_enabled) { +/** + * 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。 + */ +export const checkUpdate: IpcController<'app:checkUpdate'> = async () => { + const { bootstrap } = getContext(); + if (!bootstrap.config.update.check_enabled) { return { ok: false, hasUpdate: false, @@ -141,23 +170,29 @@ export const checkUpdate: IpcController<'app:checkUpdate'> = async (ctx) => { error: 'update check disabled by config', }; } - const result = await checkForUpdate(app.getVersion(), ctx.bootstrap.config.proxy); + const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy); publishUpdateResult(result); return result; }; -// 读 main 缓存的最近一次更新检测结果(不发请求)。 +/** + * 读 main 缓存的最近一次更新检测结果(不发请求)。 + */ export const getUpdateStatus: IpcController<'app:getUpdateStatus'> = () => getLastUpdateResult(); -// 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。 -export const openExternal: IpcController<'app:openExternal'> = async (_ctx, req) => { +/** + * 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。 + */ +export const openExternal: IpcController<'app:openExternal'> = async (_event, req) => { if (!/^https?:\/\//.test(req.url)) return; await shell.openExternal(req.url); }; -// 系统原生目录选择对话框——需绑定到发起调用的窗口。 -export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (_ctx, req, evt) => { - const win = BrowserWindow.fromWebContents(evt.sender) ?? undefined; +/** + * 系统原生目录选择对话框——需绑定到发起调用的窗口。 + */ +export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (event, req) => { + const win = BrowserWindow.fromWebContents(event.sender) ?? undefined; const result = win ? await dialog.showOpenDialog(win, { title: req.title ?? t('dialog.selectDirectory'), diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index 9637069..4c04c2f 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -1,107 +1,141 @@ import { writeConfig } from '@meebox/config'; import { buildDraftAdapter } from '../adapters.js'; import { setMainLanguage } from '../i18n/index.js'; +import { getContext } from '../services/context.js'; import { testProxyConnectivity } from '../utils/proxy.js'; -import type { IpcController } from './register.js'; +import type { IpcController } from './types.js'; -// ── 配置操作域 controllers:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连 ── +/* + * 配置操作域 controllers:读 / 写 config.yaml(含热生效与草稿暂存)及连接 / 代理试连 + */ -export const readConfig: IpcController<'config:read'> = (ctx) => ctx.bootstrap.config; +/** + * 读当前内存配置。 + */ +export const readConfig: IpcController<'config:read'> = () => getContext().bootstrap.config; -// 写 repos_dir(重启生效)。 -export const setReposDir: IpcController<'config:setReposDir'> = async (ctx, req) => { +/** + * 写 repos_dir(重启生效)。 + */ +export const setReposDir: IpcController<'config:setReposDir'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); const next = { - ...ctx.bootstrap.config, - workspace: { ...ctx.bootstrap.config.workspace, repos_dir: req.reposDir }, + ...bootstrap.config, + workspace: { ...bootstrap.config.workspace, repos_dir: req.reposDir }, }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); + await writeConfig(bootstrap.paths.configFile, next); + logger.info({ reposDir: req.reposDir }, 'repos_dir updated; restart required'); }; -// 写 UI 语言并即时生效:内存同步 + 主进程 i18n changeLanguage。 -export const setLanguage: IpcController<'config:setLanguage'> = async (ctx, req) => { - const next = { ...ctx.bootstrap.config, language: req.language }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.language = req.language; +/** + * 写 UI 语言并即时生效:内存同步 + 主进程 i18n changeLanguage。 + */ +export const setLanguage: IpcController<'config:setLanguage'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, language: req.language }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.language = req.language; setMainLanguage(req.language); - ctx.logger.info({ language: req.language }, 'language config updated'); + logger.info({ language: req.language }, 'language config updated'); }; -// 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。 -export const setLlm: IpcController<'config:setLlm'> = async (ctx, req) => { - const next = { ...ctx.bootstrap.config, llm: req.llm }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.llm = req.llm; - ctx.logger.info( +/** + * 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。 + */ +export const setLlm: IpcController<'config:setLlm'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, llm: req.llm }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.llm = req.llm; + logger.info( { profileCount: req.llm.profiles.length, activeId: req.llm.active_id }, 'llm config updated', ); }; -// 写 agent 配置(含 agent.dir);内存同步,下次 pragent:run 现读生效。 -export const setAgent: IpcController<'config:setAgent'> = async (ctx, req) => { - const next = { ...ctx.bootstrap.config, agent: req.agent }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.agent = req.agent; - ctx.logger.info({ agent: req.agent }, 'agent config updated'); +/** + * 写 agent 配置(含 agent.dir);内存同步,下次 pragent:run 现读生效。 + */ +export const setAgent: IpcController<'config:setAgent'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); + const next = { ...bootstrap.config, agent: req.agent }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.agent = req.agent; + logger.info({ agent: req.agent }, 'agent config updated'); }; -// 翻转 AutoPilot 开关;关→开时立即 poll 一轮按准入规则评估。 -export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = async (ctx, req) => { - const was = ctx.bootstrap.config.agent.autopilot.enabled; +/** + * 翻转 AutoPilot 开关;关→开时立即 poll 一轮按准入规则评估。 + */ +export const setAutopilotEnabled: IpcController<'agent:setAutopilotEnabled'> = async ( + _event, + req, +) => { + const { bootstrap, logger, poller } = getContext(); + const was = bootstrap.config.agent.autopilot.enabled; const agent = { - ...ctx.bootstrap.config.agent, - autopilot: { ...ctx.bootstrap.config.agent.autopilot, enabled: req.enabled }, + ...bootstrap.config.agent, + autopilot: { ...bootstrap.config.agent.autopilot, enabled: req.enabled }, }; - await writeConfig(ctx.bootstrap.paths.configFile, { ...ctx.bootstrap.config, agent }); - ctx.bootstrap.config.agent = agent; - ctx.logger.info({ enabled: req.enabled }, 'autopilot toggled'); + await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, agent }); + bootstrap.config.agent = agent; + logger.info({ enabled: req.enabled }, 'autopilot toggled'); if (req.enabled && !was) { - void ctx.poller.tick(); + void poller.tick(); } }; -// 写连接列表 + 启用连接,热重建 adapter/poller 并立即 poll 一轮。 -export const setConnections: IpcController<'config:setConnections'> = async (ctx, req) => { +/** + * 写连接列表 + 启用连接,热重建 adapter/poller 并立即 poll 一轮。 + */ +export const setConnections: IpcController<'config:setConnections'> = async (_event, req) => { + const { bootstrap, logger, poller, reconfigureConnections } = getContext(); const next = { - ...ctx.bootstrap.config, + ...bootstrap.config, connections: req.connections, active_connection_id: req.active_connection_id, }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.connections = req.connections; - ctx.bootstrap.config.active_connection_id = req.active_connection_id; - await ctx.reconfigureConnections(); - void ctx.poller.tick(); - ctx.logger.info( + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.connections = req.connections; + bootstrap.config.active_connection_id = req.active_connection_id; + await reconfigureConnections(); + void poller.tick(); + logger.info( { count: req.connections.length, activeId: req.active_connection_id }, 'connections config updated (hot-reloaded)', ); }; -// 写代理配置,热重建 adapter(REST 经代理即时生效)。 -export const setProxy: IpcController<'config:setProxy'> = async (ctx, req) => { - const next = { ...ctx.bootstrap.config, proxy: req.proxy }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.proxy = req.proxy; - await ctx.reconfigureConnections(); - ctx.logger.info( +/** + * 写代理配置,热重建 adapter(REST 经代理即时生效)。 + */ +export const setProxy: IpcController<'config:setProxy'> = async (_event, req) => { + const { bootstrap, logger, reconfigureConnections } = getContext(); + const next = { ...bootstrap.config, proxy: req.proxy }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.proxy = req.proxy; + await reconfigureConnections(); + logger.info( { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, 'proxy config updated (hot-reloaded)', ); }; -// 用给定代理试连,验证可用性;不写配置。 -export const testProxy: IpcController<'config:testProxy'> = (_ctx, req) => +/** + * 用给定代理试连,验证可用性;不写配置。 + */ +export const testProxy: IpcController<'config:testProxy'> = (_event, req) => testProxyConnectivity(req.proxy); -// 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason。 -export const testConnection: IpcController<'config:testConnection'> = async (ctx, req) => { +/** + * 用草稿 url/token 临时起 adapter ping,不落配置;失败归一成 ok:false + reason。 + */ +export const testConnection: IpcController<'config:testConnection'> = async (_event, req) => { try { return await buildDraftAdapter( req.base_url, req.token, - ctx.bootstrap.config.proxy, + getContext().bootstrap.config.proxy, req.kind, ).ping(); } catch (e) { @@ -109,30 +143,36 @@ export const testConnection: IpcController<'config:testConnection'> = async (ctx } }; -// 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 -export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (ctx, req) => { +/** + * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 + */ +export const autosaveDraft: IpcController<'config:autosaveDraft'> = async (_event, req) => { + const { bootstrap, logger } = getContext(); const next = { - ...ctx.bootstrap.config, + ...bootstrap.config, connections: req.connections, active_connection_id: req.active_connection_id, llm: req.llm, }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.logger.info( + await writeConfig(bootstrap.paths.configFile, next); + logger.info( { connections: req.connections.length, profiles: req.llm.profiles.length }, 'connections/llm draft autosaved to config.yaml (not applied)', ); }; -// 写轮询间隔(clamp 60~900)并热替换 poller 定时器,无需重启。 -export const setPoller: IpcController<'config:setPoller'> = async (ctx, req) => { +/** + * 写轮询间隔(clamp 60~900)并热替换 poller 定时器,无需重启。 + */ +export const setPoller: IpcController<'config:setPoller'> = async (_event, req) => { + const { bootstrap, logger, poller } = getContext(); const seconds = Math.min(900, Math.max(60, Math.round(req.interval_seconds))); const next = { - ...ctx.bootstrap.config, - poller: { ...ctx.bootstrap.config.poller, interval_seconds: seconds }, + ...bootstrap.config, + poller: { ...bootstrap.config.poller, interval_seconds: seconds }, }; - await writeConfig(ctx.bootstrap.paths.configFile, next); - ctx.bootstrap.config.poller.interval_seconds = seconds; - ctx.poller.setIntervalSeconds(seconds); - ctx.logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.poller.interval_seconds = seconds; + poller.setIntervalSeconds(seconds); + logger.info({ intervalSeconds: seconds }, 'poller interval updated (hot-reloaded)'); }; diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 1524c58..0fd4bb3 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -18,12 +18,18 @@ import type { RepoIdentity } from '@meebox/repo-mirror'; import type { PrComment } from '@meebox/shared'; import { t } from '../i18n/index.js'; import { annotateOwnership } from '../services/comments.js'; -import type { IpcController } from './register.js'; - -// ── PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 ── - -// 对已有评论发回复,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 -export const replyComment: IpcController<'comments:reply'> = async (ctx, req) => { +import { getContext } from '../services/context.js'; +import type { IpcController } from './types.js'; + +/* + * PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 + */ + +/** + * 对已有评论发回复,成功后清评论缓存 + 广播 comments:changed 让 UI 重拉。 + */ +export const replyComment: IpcController<'comments:reply'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); const reply = await adapter.replyToComment( @@ -36,8 +42,11 @@ export const replyComment: IpcController<'comments:reply'> = async (ctx, req) => return reply; }; -// 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 -export const deleteComment: IpcController<'comments:delete'> = async (ctx, req) => { +/** + * 删除自己作者的远端评论(带 version 乐观锁)。失败原文抛给 renderer;成功后清缓存 + 广播。 + */ +export const deleteComment: IpcController<'comments:delete'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); await adapter.deleteComment( @@ -49,8 +58,11 @@ export const deleteComment: IpcController<'comments:delete'> = async (ctx, req) await ctx.pr.invalidateCommentsCache(pr.localId); }; -// 编辑自己作者评论 body(带 version 乐观锁)。返回 updated 仅作乐观参考;清缓存 + 广播。 -export const editComment: IpcController<'comments:edit'> = async (ctx, req) => { +/** + * 编辑自己作者评论 body(带 version 乐观锁)。返回 updated 仅作乐观参考;清缓存 + 广播。 + */ +export const editComment: IpcController<'comments:edit'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); const updated = await adapter.editComment( @@ -64,9 +76,12 @@ export const editComment: IpcController<'comments:edit'> = async (ctx, req) => { return updated; }; -// 拉评论内嵌图片(私有实例需带 PAT,renderer 无法直接 fetch)→ 经 main 代理回 dataUrl。不缓存。 -export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async (ctx, req) => { +/** + * 拉评论内嵌图片(私有实例需带 PAT,renderer 无法直接 fetch)→ 经 main 代理回 dataUrl。不缓存。 + */ +export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async (_event, req) => { try { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterFor(pr); if (!adapter) return null; @@ -80,21 +95,33 @@ export const fetchAttachment: IpcController<'comments:fetchAttachment'> = async } }; -// 只展示当前活动连接的 PR(状态库可能仍存切换前其他连接的历史 PR)。 -export const listPrs: IpcController<'prs:list'> = async (ctx) => { +/** + * 只展示当前活动连接的 PR(状态库可能仍存切换前其他连接的历史 PR)。 + */ +export const listPrs: IpcController<'prs:list'> = async () => { + const ctx = getContext(); const activeId = ctx.bootstrap.config.active_connection_id; const all = await listStoredPullRequests(ctx.stateStore); return activeId ? all.filter((pr) => pr.connectionId === activeId) : all; }; -// 立即跑一轮 poll。 -export const refreshPrs: IpcController<'prs:refresh'> = (ctx) => ctx.poller.tick(); - -// Poller 最近一次完成时间(启动初始化用)。 -export const getLastSync: IpcController<'prs:lastSync'> = (ctx) => ({ at: ctx.poller.getLastPollAt() }); - -// 设审阅状态:先写远端(失败前端不变),远端 OK 后落本地。 -export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (ctx, req) => { +/** + * 立即跑一轮 poll。 + */ +export const refreshPrs: IpcController<'prs:refresh'> = () => getContext().poller.tick(); + +/** + * Poller 最近一次完成时间(启动初始化用)。 + */ +export const getLastSync: IpcController<'prs:lastSync'> = () => ({ + at: getContext().poller.getLastPollAt(), +}); + +/** + * 设审阅状态:先写远端(失败前端不变),远端 OK 后落本地。 + */ +export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); const remoteStatus = @@ -111,8 +138,11 @@ export const setPrStatus: IpcController<'prs:setLocalStatus'> = async (ctx, req) return setLocalStatus(ctx.stateStore, req.localId, req.status); }; -// 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 -export const mergePr: IpcController<'prs:merge'> = async (ctx, req) => { +/** + * 合并 PR;不在此落本地,靠 renderer refresh → poll 软删收尾,避免本地与远端各执一词。 + */ +export const mergePr: IpcController<'prs:merge'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); await adapter.mergePullRequest( @@ -121,14 +151,20 @@ export const mergePr: IpcController<'prs:merge'> = async (ctx, req) => { ); }; -// 确保 PR 所属 repo 镜像就位(快速路径命中即 noop)。 -export const syncRepo: IpcController<'repo:sync'> = async (ctx, req) => { +/** + * 确保 PR 所属 repo 镜像就位(快速路径命中即 noop)。 + */ +export const syncRepo: IpcController<'repo:sync'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); return ctx.pr.ensureMirrorReadyForPr(pr); }; -// 列出 base..head 变更文件(先确保镜像 + 锚到固定 merge-base)。 -export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (ctx, req) => { +/** + * 列出 base..head 变更文件(先确保镜像 + 锚到固定 merge-base)。 + */ +export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const id = ctx.pr.repoIdentityFor(pr); await ctx.pr.ensureMirrorReadyForPr(pr); @@ -136,17 +172,25 @@ export const listChangedFiles: IpcController<'diff:listChangedFiles'> = async (c return ctx.repoMirror.listChangedFiles(id, base, pr.sourceRef.sha); }; -// 读 base(固定 merge-base)/ head 一侧文件内容。 -export const getFileContent: IpcController<'diff:getFileContent'> = async (ctx, req) => { +/** + * 读 base(固定 merge-base)/ head 一侧文件内容。 + */ +export const getFileContent: IpcController<'diff:getFileContent'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const id = ctx.pr.repoIdentityFor(pr); const sha = req.side === 'base' ? await ctx.pr.resolveDiffBaseSha(pr) : pr.sourceRef.sha; return ctx.repoMirror.getFileContent(id, sha, req.path); }; -// 仅读评论缓存条数(tab 角标懒展示),不打远端。 -export const getCommentCountCached: IpcController<'diff:commentCountCached'> = async (ctx, req) => { - const cache = await readCommentsCache(ctx.stateStore, req.localId); +/** + * 仅读评论缓存条数(tab 角标懒展示),不打远端。 + */ +export const getCommentCountCached: IpcController<'diff:commentCountCached'> = async ( + _event, + req, +) => { + const cache = await readCommentsCache(getContext().stateStore, req.localId); if (!cache) return null; return { count: cache.comments.length }; }; @@ -154,8 +198,11 @@ export const getCommentCountCached: IpcController<'diff:commentCountCached'> = a // In-flight dedup: 打开 PR 时多个组件并行调 listComments(force:true),合并到同一 Promise,远端只打一次。 const listCommentsInFlight = new Map>(); -// 拉评论:cache + pr_updated_at stale 比对;force=true 跳缓存。同 localId in-flight 去重。 -export const listComments: IpcController<'diff:listComments'> = async (ctx, req) => { +/** + * 拉评论:cache + pr_updated_at stale 比对;force=true 跳缓存。同 localId in-flight 去重。 + */ +export const listComments: IpcController<'diff:listComments'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const cache = await readCommentsCache(ctx.stateStore, pr.localId); if (!req.force && cache && !isCommentsCacheStale(cache, pr.updatedAt)) { @@ -164,29 +211,34 @@ export const listComments: IpcController<'diff:listComments'> = async (ctx, req) const existing = listCommentsInFlight.get(pr.localId); if (existing) return existing; const adapter = ctx.pr.adapterForOrThrow(pr); - const fetchPromise = adapter - .listPullRequestComments( + // dedup 要求把 in-flight Promise **同步**存进 map 后再 await:故显式构造 Promise(内部用 async + // IIFE 顺序 await)并 set,再 return。不能整体写成顶层 async 函数体内直接 await——首个 await 挂起前 + // Promise 还没注册进 map,落在这窗口内的并发请求就会各自再打一次远端。.finally 绑在 Promise 上做 + // 清理(与具体 await 方无关,成功 / 失败都摘除 map 项)。 + const fetchPromise = (async () => { + const raw = await adapter.listPullRequestComments( { projectKey: pr.repo.projectKey, repoSlug: pr.repo.repoSlug }, pr.remoteId, - ) - .then((raw) => annotateOwnership(raw, adapter)) - .then(async (fresh) => { - await writeCommentsCache(ctx.stateStore, pr.localId, { - comments: fresh, - pr_updated_at: pr.updatedAt, - fetched_at: new Date().toISOString(), - }); - return fresh; - }) - .finally(() => { - listCommentsInFlight.delete(pr.localId); + ); + const fresh = annotateOwnership(raw, adapter); + await writeCommentsCache(ctx.stateStore, pr.localId, { + comments: fresh, + pr_updated_at: pr.updatedAt, + fetched_at: new Date().toISOString(), }); + return fresh; + })().finally(() => { + listCommentsInFlight.delete(pr.localId); + }); listCommentsInFlight.set(pr.localId, fetchPromise); return fetchPromise; }; -// 拉 commits(不缓存,量少 + 进 commits 标签页才拉)。 -export const listCommits: IpcController<'diff:listCommits'> = async (ctx, req) => { +/** + * 拉 commits(不缓存,量少 + 进 commits 标签页才拉)。 + */ +export const listCommits: IpcController<'diff:listCommits'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); return adapter.listPullRequestCommits( @@ -195,16 +247,22 @@ export const listCommits: IpcController<'diff:listCommits'> = async (ctx, req) = ); }; -// 本地 git 算 PR 引入提交数(base=targetRef.sha 排除合入的目标提交);镜像未齐返回 null。 -export const getCommitCount: IpcController<'diff:commitCount'> = async (ctx, req) => { +/** + * 本地 git 算 PR 引入提交数(base=targetRef.sha 排除合入的目标提交);镜像未齐返回 null。 + */ +export const getCommitCount: IpcController<'diff:commitCount'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const id = ctx.pr.repoIdentityFor(pr); const n = await ctx.repoMirror.countCommits(id, pr.targetRef.sha, pr.sourceRef.sha); return n === null ? null : { count: n }; }; -// head 侧 blame;PR 引入行单独返回供 BlameColumn 画色带占位。 -export const getBlame: IpcController<'diff:getBlame'> = async (ctx, req) => { +/** + * head 侧 blame;PR 引入行单独返回供 BlameColumn 画色带占位。 + */ +export const getBlame: IpcController<'diff:getBlame'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const id = ctx.pr.repoIdentityFor(pr); const base = await ctx.pr.resolveDiffBaseSha(pr); @@ -218,8 +276,11 @@ export const getBlame: IpcController<'diff:getBlame'> = async (ctx, req) => { }; }; -// 本地所有 repo 镜像总占用字节数(按 host|projectKey|repoSlug 去重)。 -export const getTotalSize: IpcController<'repo:getTotalSize'> = async (ctx) => { +/** + * 本地所有 repo 镜像总占用字节数(按 host|projectKey|repoSlug 去重)。 + */ +export const getTotalSize: IpcController<'repo:getTotalSize'> = async () => { + const ctx = getContext(); const prs = await listStoredPullRequests(ctx.stateStore); const seen = new Set(); let total = 0; @@ -239,8 +300,11 @@ export const getTotalSize: IpcController<'repo:getTotalSize'> = async (ctx) => { return { totalBytes: total }; }; -// 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 -export const runPragent: IpcController<'pragent:run'> = async (ctx, req) => { +/** + * 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 + */ +export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { + const ctx = getContext(); if (!ctx.getPrAgentBridge()) { throw new Error(t('prAgent.notReadyDetail')); } @@ -251,35 +315,54 @@ export const runPragent: IpcController<'pragent:run'> = async (ctx, req) => { return ctx.runQueue.enqueuePragentRun(pr, req.tool, req.question); }; -// 取消一个 run(active SIGKILL / waiting 出队)。 -export const cancelPragent: IpcController<'pragent:cancel'> = (ctx, req) => - ctx.runQueue.cancel(req.runId); - -// 当前队列快照(启动 / 重连兜底)。 -export const getQueue: IpcController<'pragent:queue'> = (ctx) => ctx.runQueue.snapshot(); - -// 列某 PR 历史 run(游标分页)。 -export const listRuns: IpcController<'pragent:listRuns'> = (ctx, req) => - listReviewRunsForPr(ctx.stateStore, req.localId, { limit: req.limit, beforeId: req.beforeId }); - -// 单条 run 查询。 -export const getRun: IpcController<'pragent:getRun'> = (ctx, req) => - getReviewRun(ctx.stateStore, req.localId, req.runId); - -// 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 -export const clearRuns: IpcController<'pragent:clearRuns'> = async (ctx, req) => { +/** + * 取消一个 run(active SIGKILL / waiting 出队)。 + */ +export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) => + getContext().runQueue.cancel(req.runId); + +/** + * 当前队列快照(启动 / 重连兜底)。 + */ +export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot(); + +/** + * 列某 PR 历史 run(游标分页)。 + */ +export const listRuns: IpcController<'pragent:listRuns'> = (_event, req) => + listReviewRunsForPr(getContext().stateStore, req.localId, { + limit: req.limit, + beforeId: req.beforeId, + }); + +/** + * 单条 run 查询。 + */ +export const getRun: IpcController<'pragent:getRun'> = (_event, req) => + getReviewRun(getContext().stateStore, req.localId, req.runId); + +/** + * 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 + */ +export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => { + const ctx = getContext(); await clearAgentSession(ctx.stateStore, req.localId); await clearAutopilotLedger(ctx.stateStore, req.localId); ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) }; }; -// 列某 PR 全部草稿。 -export const getDrafts: IpcController<'drafts:list'> = (ctx, req) => - listDrafts(ctx.stateStore, req.localId); - -// 创建草稿;IPC 边界再挡一道 origin/source 约束避免脏数据进盘。 -export const addDraft: IpcController<'drafts:create'> = async (ctx, req) => { +/** + * 列某 PR 全部草稿。 + */ +export const getDrafts: IpcController<'drafts:list'> = (_event, req) => + listDrafts(getContext().stateStore, req.localId); + +/** + * 创建草稿;IPC 边界再挡一道 origin/source 约束避免脏数据进盘。 + */ +export const addDraft: IpcController<'drafts:create'> = async (_event, req) => { + const ctx = getContext(); const { draft, localId } = req; if (draft.origin === 'finding' && !draft.source) { throw new Error('drafts:create: origin=finding 必须传 source { runId, findingId }'); @@ -292,22 +375,31 @@ export const addDraft: IpcController<'drafts:create'> = async (ctx, req) => { return created; }; -// 部分更新草稿(pending 编辑 body 自动转 edited;找不到返回 null)。 -export const patchDraft: IpcController<'drafts:update'> = async (ctx, req) => { +/** + * 部分更新草稿(pending 编辑 body 自动转 edited;找不到返回 null)。 + */ +export const patchDraft: IpcController<'drafts:update'> = async (_event, req) => { + const ctx = getContext(); const updated = await updateDraft(ctx.stateStore, req.localId, req.draftId, req.patch); if (updated) ctx.broadcast('drafts:changed', { localId: req.localId }); return updated; }; -// 删除草稿。 -export const removeDraft: IpcController<'drafts:delete'> = async (ctx, req) => { +/** + * 删除草稿。 + */ +export const removeDraft: IpcController<'drafts:delete'> = async (_event, req) => { + const ctx = getContext(); await deleteDraft(ctx.stateStore, req.localId, req.draftId); ctx.broadcast('drafts:changed', { localId: req.localId }); }; -// 批量发布草稿:逐条 publishInlineComment,单条失败不中断;成功即删本地草稿。 -// 整批跑完广播 drafts:changed;有任一成功则 force-refresh 评论 + 广播 comments:changed。 -export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (ctx, req) => { +/** + * 批量发布草稿:逐条 publishInlineComment,单条失败不中断;成功即删本地草稿。 + * 整批跑完广播 drafts:changed;有任一成功则 force-refresh 评论 + 广播 comments:changed。 + */ +export const publishDraftBatch: IpcController<'drafts:publishBatch'> = async (_event, req) => { + const ctx = getContext(); const pr = await ctx.pr.findPrOrThrow(req.localId); const adapter = ctx.pr.adapterForOrThrow(pr); diff --git a/apps/desktop/src/main/controllers/register.ts b/apps/desktop/src/main/controllers/register.ts deleted file mode 100644 index 68ffc23..0000000 --- a/apps/desktop/src/main/controllers/register.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ipcMain, type IpcMainInvokeEvent } from 'electron'; -import type { IpcChannelName, IpcChannels } from '@meebox/ipc'; -import type { ControllerContext } from '../services/context.js'; - -/** - * IPC controller 的统一签名:入参 `(ctx, req, evt)`,返回该通道 response(同步或异步)。 - * - ctx:controller 层共享上下文(依赖 + 公共工具 + 跨域 service) - * - req:该通道的强类型请求体 - * - evt:electron IpcMainInvokeEvent(仅少数需窗口上下文的 controller 用,如对话框 / DevTools) - * - * 泛型约束 `K extends IpcChannelName` 必需:req/response 由 `IpcChannels[K]` 索引取出,K 必须是 - * 合法通道名才能索引。controller 一律写成具名函数 `const xxx: IpcController<'channel'> = …`。 - */ -export type IpcController = ( - ctx: ControllerContext, - req: IpcChannels[K]['request'], - evt: IpcMainInvokeEvent, -) => IpcChannels[K]['response'] | Promise; - -/** - * 把一个具名 controller 绑定到 `ipcMain.handle`:薄类型包装,仅把 ipcMain 的 `(evt, req)` 适配为 - * controller 约定的 `(ctx, req, evt)`。泛型 K 同时约束 channel 字面量与 controller 的通道类型, - * 绑错(channel 与 controller 通道不一致)即编译报错。各域 `register*Controllers(ctx)` 调它注册。 - */ -export function handle( - channel: K, - ctx: ControllerContext, - controller: IpcController, -): void { - ipcMain.handle(channel, (evt, req: IpcChannels[K]['request']) => controller(ctx, req, evt)); -} diff --git a/apps/desktop/src/main/controllers/types.ts b/apps/desktop/src/main/controllers/types.ts new file mode 100644 index 0000000..b8d2303 --- /dev/null +++ b/apps/desktop/src/main/controllers/types.ts @@ -0,0 +1,17 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import type { IpcChannelName, IpcChannels } from '@meebox/ipc'; + +/** + * IPC controller 的类型:原生 `ipcMain.handle` 监听器形态 `(event, req)`,直接 + * `ipcMain.handle('channel', controller)` 注册、无包装层。通道字符串与 controller 的匹配由 + * ipcMain.handle 的宽松签名兜不住,靠注册处命名 + 注释保证。 + * + * @template K 通道名(约束 `extends IpcChannelName` 必需:req/response 由 `IpcChannels[K]` 索引取出)。 + * @param event electron IpcMainInvokeEvent;仅少数需窗口上下文的 controller 用(对话框 / DevTools),其余以 `_event` 占位。 + * @param req 该通道的强类型请求体。 + * @returns 该通道的 response(同步或异步)。 + */ +export type IpcController = ( + event: IpcMainInvokeEvent, + req: IpcChannels[K]['request'], +) => IpcChannels[K]['response'] | Promise; diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index eb6ae5a..ca8da3d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,11 +1,12 @@ +import { ipcMain } from 'electron'; import * as agent from './controllers/agent.js'; import * as app from './controllers/app.js'; import * as config from './controllers/config.js'; import * as pr from './controllers/pr.js'; -import { handle } from './controllers/register.js'; import { AgentOrchestratorService } from './services/agent-orchestrator.js'; import { createServiceContext, + setControllerContext, type ControllerContext, type RegisterDeps, } from './services/context.js'; @@ -15,7 +16,10 @@ export type { RegisterDeps } from './services/context.js'; /** * 注册全部 IPC handler。薄入口:构建共享上下文 → 建两个跨域 service(run 队列 / Agent 编排) - * → 合成 controller 上下文 → 按业务领域逐个绑定通道 → 返回运行时控制句柄。 + * → 合成 controller 上下文并安装为进程级单例 → 按业务领域逐个绑定通道 → 返回运行时控制句柄。 + * + * controller 是原生 ipcMain.handle 监听器(具名函数 `(event, req) => …`,见 controllers/<域>.ts), + * 依赖经 getContext() 取用、不带 ctx 参数;下方直接 `ipcMain.handle('channel', controller)` 注册,无包装层。 */ export function registerIpcHandlers(deps: RegisterDeps): { abortAllActiveRuns: () => number; @@ -27,90 +31,91 @@ export function registerIpcHandlers(deps: RegisterDeps): { const runQueue = new RunQueueService(base); // Agent 编排:复用 run 队列派发工具 run(agent 低优先级泳道)。 const orchestrator = new AgentOrchestratorService(base, runQueue); - // controller 层统一上下文:基础上下文 + 两个跨域 service,所有 controller 共享同一 ctx。 + // controller 层统一上下文:基础上下文 + 两个跨域 service,安装为进程级单例(controller 经 getContext() 取用)。 const ctx: ControllerContext = { ...base, runQueue, orchestrator }; + setControllerContext(ctx); /* * GUI 框架交互 * 应用信息 / 窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像 */ - handle('app:info', ctx, app.readAppInfo); // 应用 / 运行时版本信息(关于页) - handle('app:paths', ctx, app.readAppPaths); // 关键目录路径(config / agent / 日志) - handle('app:prAgentStatus', ctx, app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) - handle('log:write', ctx, app.writeRendererLog); // 渲染层日志回传落盘 - handle('app:connections', ctx, app.listConnections); // 当前活动连接摘要(Header / 状态栏) - handle('app:userAvatar', ctx, app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) - handle('app:openConfigFile', ctx, app.openConfigFile); // 打开 config.yaml - handle('app:openAgentDir', ctx, app.openAgentDir); // 打开 Agent 目录 - handle('app:openDevTools', ctx, app.openDevTools); // 打开 DevTools(分离窗口) - handle('app:checkUpdate', ctx, app.checkUpdate); // 手动检查更新 - handle('app:getUpdateStatus', ctx, app.getUpdateStatus); // 读缓存的更新检测结果(水合) - handle('app:openExternal', ctx, app.openExternal); // 系统浏览器打开外链 - handle('dialog:pickDirectory', ctx, app.pickDirectory); // 原生目录选择对话框 + ipcMain.handle('app:info', app.readAppInfo); // 应用 / 运行时版本信息(关于页) + ipcMain.handle('app:paths', app.readAppPaths); // 关键目录路径(config / agent / 日志) + ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) + ipcMain.handle('log:write', app.writeRendererLog); // 渲染层日志回传落盘 + ipcMain.handle('app:connections', app.listConnections); // 当前活动连接摘要(Header / 状态栏) + ipcMain.handle('app:userAvatar', app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) + ipcMain.handle('app:openConfigFile', app.openConfigFile); // 打开 config.yaml + ipcMain.handle('app:openAgentDir', app.openAgentDir); // 打开 Agent 目录 + ipcMain.handle('app:openDevTools', app.openDevTools); // 打开 DevTools(分离窗口) + ipcMain.handle('app:checkUpdate', app.checkUpdate); // 手动检查更新 + ipcMain.handle('app:getUpdateStatus', app.getUpdateStatus); // 读缓存的更新检测结果(水合) + ipcMain.handle('app:openExternal', app.openExternal); // 系统浏览器打开外链 + ipcMain.handle('dialog:pickDirectory', app.pickDirectory); // 原生目录选择对话框 /* * PR 操作 * 评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 */ - handle('comments:reply', ctx, pr.replyComment); // 回复评论 - handle('comments:delete', ctx, pr.deleteComment); // 删除自己的评论 - handle('comments:edit', ctx, pr.editComment); // 编辑自己的评论 - handle('comments:fetchAttachment', ctx, pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) - handle('prs:list', ctx, pr.listPrs); // PR 列表(仅活动连接) - handle('prs:refresh', ctx, pr.refreshPrs); // 立即轮询刷新 - handle('prs:lastSync', ctx, pr.getLastSync); // 最近一次同步时间 - handle('prs:setLocalStatus', ctx, pr.setPrStatus); // 设置审阅状态(先远端后本地) - handle('prs:merge', ctx, pr.mergePr); // 合并 PR - handle('repo:sync', ctx, pr.syncRepo); // 同步 PR 所属 repo 本地镜像 - handle('diff:listChangedFiles', ctx, pr.listChangedFiles); // 变更文件列表 - handle('diff:getFileContent', ctx, pr.getFileContent); // 文件内容(base / head 一侧) - handle('diff:commentCountCached', ctx, pr.getCommentCountCached); // 评论数角标(仅缓存) - handle('diff:listComments', ctx, pr.listComments); // 拉评论(缓存 + in-flight 去重) - handle('diff:listCommits', ctx, pr.listCommits); // 提交列表 - handle('diff:commitCount', ctx, pr.getCommitCount); // 提交数角标(本地 git) - handle('diff:getBlame', ctx, pr.getBlame); // blame + PR 引入行 - handle('repo:getTotalSize', ctx, pr.getTotalSize); // 本地镜像总占用(设置页) - handle('pragent:run', ctx, pr.runPragent); // 触发一次 pr-agent run(入队) - handle('pragent:cancel', ctx, pr.cancelPragent); // 取消一个 run - handle('pragent:queue', ctx, pr.getQueue); // 队列快照(active + waiting) - handle('pragent:listRuns', ctx, pr.listRuns); // 历史 run 列表(游标分页) - handle('pragent:getRun', ctx, pr.getRun); // 单条 run 查询 - handle('pragent:clearRuns', ctx, pr.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 - handle('drafts:list', ctx, pr.getDrafts); // 草稿列表 - handle('drafts:create', ctx, pr.addDraft); // 新建草稿 - handle('drafts:update', ctx, pr.patchDraft); // 更新草稿 - handle('drafts:delete', ctx, pr.removeDraft); // 删除草稿 - handle('drafts:publishBatch', ctx, pr.publishDraftBatch); // 批量发布草稿到远端 + ipcMain.handle('comments:reply', pr.replyComment); // 回复评论 + ipcMain.handle('comments:delete', pr.deleteComment); // 删除自己的评论 + ipcMain.handle('comments:edit', pr.editComment); // 编辑自己的评论 + ipcMain.handle('comments:fetchAttachment', pr.fetchAttachment); // 拉评论内嵌图片(代理带 PAT) + ipcMain.handle('prs:list', pr.listPrs); // PR 列表(仅活动连接) + ipcMain.handle('prs:refresh', pr.refreshPrs); // 立即轮询刷新 + ipcMain.handle('prs:lastSync', pr.getLastSync); // 最近一次同步时间 + ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // 设置审阅状态(先远端后本地) + ipcMain.handle('prs:merge', pr.mergePr); // 合并 PR + ipcMain.handle('repo:sync', pr.syncRepo); // 同步 PR 所属 repo 本地镜像 + ipcMain.handle('diff:listChangedFiles', pr.listChangedFiles); // 变更文件列表 + ipcMain.handle('diff:getFileContent', pr.getFileContent); // 文件内容(base / head 一侧) + ipcMain.handle('diff:commentCountCached', pr.getCommentCountCached); // 评论数角标(仅缓存) + ipcMain.handle('diff:listComments', pr.listComments); // 拉评论(缓存 + in-flight 去重) + ipcMain.handle('diff:listCommits', pr.listCommits); // 提交列表 + ipcMain.handle('diff:commitCount', pr.getCommitCount); // 提交数角标(本地 git) + ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR 引入行 + ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // 本地镜像总占用(设置页) + ipcMain.handle('pragent:run', pr.runPragent); // 触发一次 pr-agent run(入队) + ipcMain.handle('pragent:cancel', pr.cancelPragent); // 取消一个 run + ipcMain.handle('pragent:queue', pr.getQueue); // 队列快照(active + waiting) + ipcMain.handle('pragent:listRuns', pr.listRuns); // 历史 run 列表(游标分页) + ipcMain.handle('pragent:getRun', pr.getRun); // 单条 run 查询 + ipcMain.handle('pragent:clearRuns', pr.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 + ipcMain.handle('drafts:list', pr.getDrafts); // 草稿列表 + ipcMain.handle('drafts:create', pr.addDraft); // 新建草稿 + ipcMain.handle('drafts:update', pr.patchDraft); // 更新草稿 + ipcMain.handle('drafts:delete', pr.removeDraft); // 删除草稿 + ipcMain.handle('drafts:publishBatch', pr.publishDraftBatch); // 批量发布草稿到远端 /* * 配置操作 * 读写 config.yaml(热生效 / 草稿暂存)及连接 / 代理试连 */ - handle('config:read', ctx, config.readConfig); // 读当前内存配置 - handle('config:setReposDir', ctx, config.setReposDir); // 设仓库目录(重启生效) - handle('config:setLanguage', ctx, config.setLanguage); // 设 UI 语言(热生效) - handle('config:setLlm', ctx, config.setLlm); // 设 LLM Provider 配置 - handle('config:setAgent', ctx, config.setAgent); // 设 Agent 配置(含 agent.dir) - handle('agent:setAutopilotEnabled', ctx, config.setAutopilotEnabled); // AutoPilot 开关 - handle('config:setConnections', ctx, config.setConnections); // 设连接(热重建 adapter/poller) - handle('config:setProxy', ctx, config.setProxy); // 设代理(热重建 adapter) - handle('config:testProxy', ctx, config.testProxy); // 试连代理(不写配置) - handle('config:testConnection', ctx, config.testConnection); // 试连连接(不写配置) - handle('config:autosaveDraft', ctx, config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) - handle('config:setPoller', ctx, config.setPoller); // 设轮询间隔(热替换定时器) + ipcMain.handle('config:read', config.readConfig); // 读当前内存配置 + ipcMain.handle('config:setReposDir', config.setReposDir); // 设仓库目录(重启生效) + ipcMain.handle('config:setLanguage', config.setLanguage); // 设 UI 语言(热生效) + ipcMain.handle('config:setLlm', config.setLlm); // 设 LLM Provider 配置 + ipcMain.handle('config:setAgent', config.setAgent); // 设 Agent 配置(含 agent.dir) + ipcMain.handle('agent:setAutopilotEnabled', config.setAutopilotEnabled); // AutoPilot 开关 + ipcMain.handle('config:setConnections', config.setConnections); // 设连接(热重建 adapter/poller) + ipcMain.handle('config:setProxy', config.setProxy); // 设代理(热重建 adapter) + ipcMain.handle('config:testProxy', config.testProxy); // 试连代理(不写配置) + ipcMain.handle('config:testConnection', config.testConnection); // 试连连接(不写配置) + ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) + ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) /* * Agent 交互 * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 */ - handle('rules:matchForPr', ctx, agent.matchRuleForPr); // 查 PR 命中的规则 - handle('agent:run', ctx, agent.runReview); // 一键评审编排(describe→review→总结) - handle('agent:ask', ctx, agent.runPlanning); // 自由规划 Agent(对话即委派) - handle('agent:stop', ctx, agent.stopAgent); // 停止某 PR 的 Agent 运行 - handle('agent:getSession', ctx, agent.getSession); // 读已落盘评审会话 - handle('agent:getConversation', ctx, agent.getConversation); // 读多轮对话消息 - handle('agent:getTranscript', ctx, agent.getTranscript); // 读 Agent 过程步骤 - handle('agent:autopilotLedgers', ctx, agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 + ipcMain.handle('rules:matchForPr', agent.matchRuleForPr); // 查 PR 命中的规则 + ipcMain.handle('agent:run', agent.runReview); // 一键评审编排(describe→review→总结) + ipcMain.handle('agent:ask', agent.runPlanning); // 自由规划 Agent(对话即委派) + ipcMain.handle('agent:stop', agent.stopAgent); // 停止某 PR 的 Agent 运行 + ipcMain.handle('agent:getSession', agent.getSession); // 读已落盘评审会话 + ipcMain.handle('agent:getConversation', agent.getConversation); // 读多轮对话消息 + ipcMain.handle('agent:getTranscript', agent.getTranscript); // 读 Agent 过程步骤 + ipcMain.handle('agent:autopilotLedgers', agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 base.logger.debug('IPC handlers registered'); diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index 3bb6fab..1586bd5 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -66,3 +66,22 @@ export function createServiceContext(deps: RegisterDeps): ServiceContext { }), }; } + +// === controller 层进程级单例上下文 === +// registerIpcHandlers 启动时合成一次 ControllerContext(base + runQueue + orchestrator)并安装; +// controller 经 getContext() 取用,从而 handler 签名回归标准 ipcMain.handle 形态 (req, evt)、不带 ctx。 +// 单一真相、随进程生命周期存活;测试可先 setControllerContext(mock) 再调 controller。 +let currentContext: ControllerContext | undefined; + +/** 由 registerIpcHandlers 在装配完成后调用,安装进程级 controller 上下文单例。 */ +export function setControllerContext(ctx: ControllerContext): void { + currentContext = ctx; +} + +/** 取 controller 上下文单例;未初始化(registerIpcHandlers 之前 / 模块加载期)即抛错兜住时序。 */ +export function getContext(): ControllerContext { + if (!currentContext) { + throw new Error('ControllerContext 尚未初始化(registerIpcHandlers 未调用)'); + } + return currentContext; +} From cb993f3e29ef8b6ff95e048a0cc345a2a3045600 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 15:16:12 +0800 Subject: [PATCH 5/7] =?UTF-8?q?refactor(ipc):=20pragent=20run=20=E9=98=9F?= =?UTF-8?q?=E5=88=97=E5=BD=92=E5=85=A5=20Agent=20=E9=A2=86=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pragent:*(pr-agent run 执行层:触发 / 取消 / 队列 / 历史)本是「在 PR 上跑 AI 评审工具」, 与 agent 编排同源(agent:run / AutoPilot 也经同一 run 队列派发),而非通用 PR CRUD。 故从 PR 域整体移到 Agent 域,三层一致: - 契约 @meebox/ipc:pragent:run/listRuns/getRun/clearRuns/cancel/queue 从 PrChannels 移到 AgentChannels(ReviewRun/PragentRunInfo import 随迁);IpcChannels 为交集,renderer 零改动 - controller:runPragent/cancelPragent/getQueue/listRuns/getRun/clearRuns 从 pr.ts 移到 agent.ts, run 相关 poller import 随迁 - 注册 ipc.ts:6 个 ipcMain.handle('pragent:…') 从「PR 操作」组移到「Agent 交互」组 纯结构性重构,不改运行行为;lint / typecheck / build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/agent.ts | 64 ++++++++++++++++++++- apps/desktop/src/main/controllers/pr.ts | 59 +------------------- apps/desktop/src/main/ipc.ts | 14 ++--- packages/ipc/src/agent.ts | 58 ++++++++++++++++++- packages/ipc/src/pr.ts | 65 +--------------------- 5 files changed, 130 insertions(+), 130 deletions(-) diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts index 77d5fac..df2c1ab 100644 --- a/apps/desktop/src/main/controllers/agent.ts +++ b/apps/desktop/src/main/controllers/agent.ts @@ -1,17 +1,23 @@ import { loadAgentRules } from '@meebox/agent'; import { + clearAgentSession, + clearAutopilotLedger, + clearReviewRunsForPr, getAgentConversation, getAgentSession, getAgentTranscript, getAutopilotLedger, + getReviewRun, + listReviewRunsForPr, } from '@meebox/poller'; import { pickMatchingRule } from '@meebox/rules'; import type { AgentRecommendationVerdict } from '@meebox/shared'; +import { t } from '../i18n/index.js'; import { getContext } from '../services/context.js'; import type { IpcController } from './types.js'; /* - * Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 + * Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 */ /** @@ -94,3 +100,59 @@ export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = asyn } return out; }; + +/* + * pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列) + */ + +/** + * 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 + */ +export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { + const ctx = getContext(); + if (!ctx.getPrAgentBridge()) { + throw new Error(t('prAgent.notReadyDetail')); + } + if (req.tool === 'ask' && !req.question?.trim()) { + throw new Error(t('prAgent.askNeedsQuestion')); + } + const pr = await ctx.pr.findPrOrThrow(req.localId); + return ctx.runQueue.enqueuePragentRun(pr, req.tool, req.question); +}; + +/** + * 取消一个 run(active SIGKILL / waiting 出队)。 + */ +export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) => + getContext().runQueue.cancel(req.runId); + +/** + * 当前队列快照(启动 / 重连兜底)。 + */ +export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot(); + +/** + * 列某 PR 历史 run(游标分页)。 + */ +export const listRuns: IpcController<'pragent:listRuns'> = (_event, req) => + listReviewRunsForPr(getContext().stateStore, req.localId, { + limit: req.limit, + beforeId: req.beforeId, + }); + +/** + * 单条 run 查询。 + */ +export const getRun: IpcController<'pragent:getRun'> = (_event, req) => + getReviewRun(getContext().stateStore, req.localId, req.runId); + +/** + * 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 + */ +export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => { + const ctx = getContext(); + await clearAgentSession(ctx.stateStore, req.localId); + await clearAutopilotLedger(ctx.stateStore, req.localId); + ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); + return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) }; +}; diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 0fd4bb3..66d946d 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -1,13 +1,8 @@ import { - clearAgentSession, - clearAutopilotLedger, - clearReviewRunsForPr, createDraft, deleteDraft, - getReviewRun, isCommentsCacheStale, listDrafts, - listReviewRunsForPr, listStoredPullRequests, readCommentsCache, setLocalStatus, @@ -22,7 +17,7 @@ import { getContext } from '../services/context.js'; import type { IpcController } from './types.js'; /* - * PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列 + * PR 操作域 controllers:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 */ /** @@ -300,58 +295,6 @@ export const getTotalSize: IpcController<'repo:getTotalSize'> = async () => { return { totalBytes: total }; }; -/** - * 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。 - */ -export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { - const ctx = getContext(); - if (!ctx.getPrAgentBridge()) { - throw new Error(t('prAgent.notReadyDetail')); - } - if (req.tool === 'ask' && !req.question?.trim()) { - throw new Error(t('prAgent.askNeedsQuestion')); - } - const pr = await ctx.pr.findPrOrThrow(req.localId); - return ctx.runQueue.enqueuePragentRun(pr, req.tool, req.question); -}; - -/** - * 取消一个 run(active SIGKILL / waiting 出队)。 - */ -export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) => - getContext().runQueue.cancel(req.runId); - -/** - * 当前队列快照(启动 / 重连兜底)。 - */ -export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot(); - -/** - * 列某 PR 历史 run(游标分页)。 - */ -export const listRuns: IpcController<'pragent:listRuns'> = (_event, req) => - listReviewRunsForPr(getContext().stateStore, req.localId, { - limit: req.limit, - beforeId: req.beforeId, - }); - -/** - * 单条 run 查询。 - */ -export const getRun: IpcController<'pragent:getRun'> = (_event, req) => - getReviewRun(getContext().stateStore, req.localId, req.runId); - -/** - * 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。 - */ -export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => { - const ctx = getContext(); - await clearAgentSession(ctx.stateStore, req.localId); - await clearAutopilotLedger(ctx.stateStore, req.localId); - ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId }); - return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) }; -}; - /** * 列某 PR 全部草稿。 */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index ca8da3d..7651c76 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -75,12 +75,6 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('diff:commitCount', pr.getCommitCount); // 提交数角标(本地 git) ipcMain.handle('diff:getBlame', pr.getBlame); // blame + PR 引入行 ipcMain.handle('repo:getTotalSize', pr.getTotalSize); // 本地镜像总占用(设置页) - ipcMain.handle('pragent:run', pr.runPragent); // 触发一次 pr-agent run(入队) - ipcMain.handle('pragent:cancel', pr.cancelPragent); // 取消一个 run - ipcMain.handle('pragent:queue', pr.getQueue); // 队列快照(active + waiting) - ipcMain.handle('pragent:listRuns', pr.listRuns); // 历史 run 列表(游标分页) - ipcMain.handle('pragent:getRun', pr.getRun); // 单条 run 查询 - ipcMain.handle('pragent:clearRuns', pr.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 ipcMain.handle('drafts:list', pr.getDrafts); // 草稿列表 ipcMain.handle('drafts:create', pr.addDraft); // 新建草稿 ipcMain.handle('drafts:update', pr.patchDraft); // 更新草稿 @@ -106,7 +100,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { /* * Agent 交互 - * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 + * 规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列 */ ipcMain.handle('rules:matchForPr', agent.matchRuleForPr); // 查 PR 命中的规则 ipcMain.handle('agent:run', agent.runReview); // 一键评审编排(describe→review→总结) @@ -116,6 +110,12 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('agent:getConversation', agent.getConversation); // 读多轮对话消息 ipcMain.handle('agent:getTranscript', agent.getTranscript); // 读 Agent 过程步骤 ipcMain.handle('agent:autopilotLedgers', agent.getAutopilotLedgers); // 批量读 AutoPilot 评审台账 + ipcMain.handle('pragent:run', agent.runPragent); // 触发一次 pr-agent run(入队) + ipcMain.handle('pragent:cancel', agent.cancelPragent); // 取消一个 run + ipcMain.handle('pragent:queue', agent.getQueue); // 队列快照(active + waiting) + ipcMain.handle('pragent:listRuns', agent.listRuns); // 历史 run 列表(游标分页) + ipcMain.handle('pragent:getRun', agent.getRun); // 单条 run 查询 + ipcMain.handle('pragent:clearRuns', agent.clearRuns); // 清空 run 历史 + Agent 会话 / 台账 base.logger.debug('IPC handlers registered'); diff --git a/packages/ipc/src/agent.ts b/packages/ipc/src/agent.ts index 73897c1..2b52fcd 100644 --- a/packages/ipc/src/agent.ts +++ b/packages/ipc/src/agent.ts @@ -3,10 +3,12 @@ import type { AgentRecommendationVerdict, AgentSession, AgentStep, + ReviewRun, ReviewRunTool, } from '@meebox/shared'; +import type { PragentRunInfo } from './common.js'; -/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取。 */ +/** Agent 交互域:规则匹配 / 评审编排 / 自由规划 / 会话与台账 / pr-agent run 队列。 */ export interface AgentChannels { /** * 给指定 PR 查 `/rules` 当前命中的规则 (按 priority desc + path asc 取首条)。 @@ -65,4 +67,58 @@ export interface AgentChannels { request: { localIds: string[] }; response: Record; }; + // ── pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列)── + /** + * 触发一次 pr-agent /describe 或 /review。同步等待执行结束(可能数十秒到数分钟), + * 期间通过 pragent:runProgress 事件推送 stdout / stderr 行。返回最终 ReviewRun + * 状态 (succeeded / failed)。pr-agent 不可用时 reject。 + */ + 'pragent:run': { + /** + * tool='ask' 时 question 必填,作为 pr-agent CLI 的位置参数传给 ask 子命令。 + * tool='describe'/'review' 时 question 字段被忽略。 + */ + request: { localId: string; tool: ReviewRunTool; question?: string }; + response: ReviewRun; + }; + /** + * 列出某 PR 的历史 run,newest first。支持时间戳游标分页: + * - limit:截到 N 条;省略 = 不限(renderer 端慎用,规模大时可能慢) + * - beforeId:游标,返回 runId **严格小于** 此值的条目;省略 = 不限上界 + * + * runId 是时序字典序 (`yyyymmdd-HHmmss-mmm`),"取游标后 N 条" 即"取此时刻之前的 N 条" + */ + 'pragent:listRuns': { + request: { localId: string; limit?: number; beforeId?: string }; + response: ReviewRun[]; + }; + /** 单条 run 查询(用于 renderer 在事件断流后兜底刷新) */ + 'pragent:getRun': { + request: { localId: string; runId: string }; + response: ReviewRun | null; + }; + /** 清空指定 PR 的全部 run 历史记录(仅该 PR 生效)。返回删除条数。 */ + 'pragent:clearRuns': { + request: { localId: string }; + response: { cleared: number }; + }; + /** + * 取消一个 run。语义跟 run 当前状态相关: + * - 跟 active 匹配 → SIGKILL 子进程,落盘 status='cancelled' + * - 在 waiting 队列里 → 从队列删除,**不**写盘 (从未真正跑过);触发 pragent:run + * 原调用方的 Promise reject 让 ChatPane handleRun 走 error 分支 + * - 都不匹配 (已结束 / 不存在) → 静默 no-op (返回 ok:false) + */ + 'pragent:cancel': { + request: { runId: string }; + response: { ok: boolean }; + }; + /** + * 查询当前队列快照 (active + waiting);renderer 启动 / 重连时拉一下, + * 跟 queueChanged 事件配套兜底。 + */ + 'pragent:queue': { + request: void; + response: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; + }; } diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts index 9b7d547..24bf2ac 100644 --- a/packages/ipc/src/pr.ts +++ b/packages/ipc/src/pr.ts @@ -4,19 +4,11 @@ import type { PrComment, PrCommit, ReviewDraft, - ReviewRun, - ReviewRunTool, StoredPullRequest, } from '@meebox/shared'; -import type { - DiffBlameLine, - DiffChangedFile, - DiffFileContent, - DiffSide, - PragentRunInfo, -} from './common.js'; +import type { DiffBlameLine, DiffChangedFile, DiffFileContent, DiffSide } from './common.js'; -/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿 / pr-agent run 队列。 */ +/** PR 操作域:评论 / 列表 / 状态 / 合并 / 镜像 / diff / 草稿。 */ export interface PrChannels { /** * 拉评论 body 内嵌图片 (`![alt](url)`)。url 可能是 Bitbucket attachment 绝对/相对地址, @@ -142,19 +134,6 @@ export interface PrChannels { }; /** 计算本地所有 repo 镜像的总占用字节数(设置页用) */ 'repo:getTotalSize': { request: void; response: { totalBytes: number } }; - /** - * 触发一次 pr-agent /describe 或 /review。同步等待执行结束(可能数十秒到数分钟), - * 期间通过 pragent:runProgress 事件推送 stdout / stderr 行。返回最终 ReviewRun - * 状态 (succeeded / failed)。pr-agent 不可用时 reject。 - */ - 'pragent:run': { - /** - * tool='ask' 时 question 必填,作为 pr-agent CLI 的位置参数传给 ask 子命令。 - * tool='describe'/'review' 时 question 字段被忽略。 - */ - request: { localId: string; tool: ReviewRunTool; question?: string }; - response: ReviewRun; - }; /** * 列出指定 PR 的全部草稿 (pending / edited / posted / rejected 都返回,UI 端按 * status 过滤显示 / 折叠)。 @@ -220,44 +199,4 @@ export interface PrChannels { }>; }; }; - /** - * 列出某 PR 的历史 run,newest first。支持时间戳游标分页: - * - limit:截到 N 条;省略 = 不限(renderer 端慎用,规模大时可能慢) - * - beforeId:游标,返回 runId **严格小于** 此值的条目;省略 = 不限上界 - * - * runId 是时序字典序 (`yyyymmdd-HHmmss-mmm`),"取游标后 N 条" 即"取此时刻之前的 N 条" - */ - 'pragent:listRuns': { - request: { localId: string; limit?: number; beforeId?: string }; - response: ReviewRun[]; - }; - /** 单条 run 查询(用于 renderer 在事件断流后兜底刷新) */ - 'pragent:getRun': { - request: { localId: string; runId: string }; - response: ReviewRun | null; - }; - /** 清空指定 PR 的全部 run 历史记录(仅该 PR 生效)。返回删除条数。 */ - 'pragent:clearRuns': { - request: { localId: string }; - response: { cleared: number }; - }; - /** - * 取消一个 run。语义跟 run 当前状态相关: - * - 跟 active 匹配 → SIGKILL 子进程,落盘 status='cancelled' - * - 在 waiting 队列里 → 从队列删除,**不**写盘 (从未真正跑过);触发 pragent:run - * 原调用方的 Promise reject 让 ChatPane handleRun 走 error 分支 - * - 都不匹配 (已结束 / 不存在) → 静默 no-op (返回 ok:false) - */ - 'pragent:cancel': { - request: { runId: string }; - response: { ok: boolean }; - }; - /** - * 查询当前队列快照 (active + waiting);renderer 启动 / 重连时拉一下, - * 跟 queueChanged 事件配套兜底。 - */ - 'pragent:queue': { - request: void; - response: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }; - }; } From f78472a521d6e5dbaee3508b5093f03791d132aa Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 15:57:44 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(ipc):=20pr-agent=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D=E6=8A=BD=E5=8F=96=E5=88=B0=20pragent-prompts?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run 队列执行逻辑里夹杂大段内嵌提示词(anchor marker / 排版 / 语言指示 / ask 语言后缀 / 回显去重)不合理;统一抽到 services/pragent-prompts.ts 整体维护,纯字符串构造、无 I/O: - buildExtraInstructions:收口 EXTRA_INSTRUCTIONS 的按序拼接(语言 / anchor / 排版 / PR 上下文 / 规则) - extraInstructionsEnvKey:EXTRA_INSTRUCTIONS 的 env key 映射 - askLanguageSuffixFor / stripAskQuestionEcho:/ask 语言后缀与输出回显去重 - languageDirectiveFor / anchorMarkerDirective / reviewLayoutDirective 为模块私有,长文本集中此处 run-queue.ts 的 executeRun 只保留「收集 prContext + 命中规则」的 I/O,再调上述函数装配 env, 瘦身约 130 行。纯结构性重构,不改运行行为;lint / typecheck / build 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/services/pragent-prompts.ts | 200 ++++++++++++++++++ apps/desktop/src/main/services/run-queue.ts | 197 ++--------------- 2 files changed, 214 insertions(+), 183 deletions(-) create mode 100644 apps/desktop/src/main/services/pragent-prompts.ts diff --git a/apps/desktop/src/main/services/pragent-prompts.ts b/apps/desktop/src/main/services/pragent-prompts.ts new file mode 100644 index 0000000..4f11e79 --- /dev/null +++ b/apps/desktop/src/main/services/pragent-prompts.ts @@ -0,0 +1,200 @@ +import type { ReviewRunTool } from '@meebox/shared'; + +/** + * pr-agent 提示词组装:把注入各 tool 的 EXTRA_INSTRUCTIONS、/ask 语言后缀、以及输出回显去重 + * 收口到本模块,避免散落在 run 队列执行逻辑里。纯字符串构造,不含 I/O / 运行时依赖。 + */ + +/** + * 把 config.language (ISO locale) 翻成自然语言 prompt directive。 + * + * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt template),但 + * /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格类输出的标题 / 列名 / 段落标记。 + * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 pr-agent 原行为。 + */ +function languageDirectiveFor(lang: string): string { + const norm = lang.toLowerCase(); + if (norm.startsWith('zh-cn') || norm === 'zh') { + return 'Respond in Simplified Chinese (简体中文). All section labels, table headers, column names, headings, and content MUST be in Chinese — do not leave any English template strings untranslated.'; + } + if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { + return 'Respond in Traditional Chinese (繁體中文). All section labels, table headers, column names, headings, and content MUST be in Chinese.'; + } + if (norm.startsWith('ja')) { + return 'Respond in Japanese (日本語). All section labels, table headers, column names, headings, and content MUST be in Japanese — do not leave any English template strings untranslated.'; + } + if (norm.startsWith('de')) { + return 'Respond in German (Deutsch). All section labels, table headers, column names, headings, and content MUST be in German — do not leave any English template strings untranslated.'; + } + return ''; +} + +/** + * anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 + * [file: , lines: -] + * + * 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 + * `[**header**](meebox:///#L-L)`,parse-output 取结构化 anchor(path 来自 + * provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/end_line YAML + * 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。故这条 marker 作为**行号 + * 兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker 的行号补(resolveIssueAnchor)。 + * + * - /review: 每条 key_issue 末尾 **必加** marker + * - /ask: 仅当回答涉及具体文件 / 代码位置时 **才加**(自由问答可能完全跟代码无关,强制会产假阳性) + * - /describe / /improve 不注入:前者不出 issue,后者走 marker 行 `[file [start-end]](url)` 自带 anchor + */ +function anchorMarkerDirective(tool: ReviewRunTool): string { + if (tool === 'review') { + return [ + 'When writing each item under `key_issues_to_review`, append on its OWN LAST LINE', + 'a machine-readable anchor marker in this EXACT format:', + '', + ' [file: , lines: -]', + '', + 'Examples:', + ' [file: src/auth/login.ts, lines: 42-50]', + ' [file: pkg/cache.go, lines: 17]', + '', + 'Use the exact relevant_file path and start_line/end_line you already', + 'identified in the YAML output. Do NOT wrap the path in backticks. If you', + 'truly cannot identify a file/line for an issue, omit the marker for that', + 'item only.', + ].join('\n'); + } + if (tool === 'ask') { + return [ + 'CRITICAL: This answer is consumed by a code review GUI that converts your', + 'per-paragraph recommendations into INLINE COMMENTS pinned to specific code', + 'lines. For that to work, EVERY paragraph that names a code symbol (function,', + 'method, class, variable, identifier) from this PR MUST end with a', + 'machine-readable anchor marker on its OWN LAST LINE:', + '', + ' [file: , lines: -]', + '', + 'Examples:', + ' [file: src/auth/login.ts, lines: 42-50]', + ' [file: pkg/cache.go, lines: 17]', + ' [file: pkg/store.ts] (path-only fallback; only when you', + ' truly cannot infer any line number)', + '', + 'How to derive line numbers from the diff:', + '- Every hunk in the diff begins with a header:', + ' @@ -, +, @@', + ' The number after `+` is the FIRST head-side line of that hunk. Count down', + ' through `+` (added) and ` ` (context) lines — DO NOT count `-` (removed)', + ' lines — to locate the line where the symbol appears. Prefer head-side', + ' line numbers. For code that ONLY exists on the base side (purely removed),', + ' use the base-side `-` line number instead.', + '', + 'Rules — read carefully:', + '- The marker is REQUIRED. Do not skip it when your paragraph references a', + ' real code symbol from the diff. A paragraph without a marker becomes', + ' un-pinnable feedback the user cannot turn into a comment.', + '- Append exactly ONE marker per paragraph, at the very end of that paragraph,', + ' on its own line (blank line above it optional but recommended).', + '- If a paragraph discusses multiple locations, pick the most important one', + ' (the line where the recommended change should be made).', + '- Paragraphs that are purely general / conceptual / meta (e.g., overall', + ' praise, no specific symbol named) MAY omit the marker.', + '- Use the exact file path from the diff. Do NOT wrap the path in backticks', + ' or quotes inside the marker.', + '- If you really cannot pin a line, fall back to path-only `[file: ]`', + ' rather than omitting the marker entirely.', + ].join('\n'); + } + return ''; +} + +/** + * 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 + * pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文;渲染层 + * (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立

。关键是「保持简洁」——只在 + * 现象/影响/建议的语义边界换行,不得借分段扩写内容。须与 anchor marker 协同:分段在正文内部, + * marker 仍独占最末行。 + */ +function reviewLayoutDirective(tool: ReviewRunTool): string { + if (tool !== 'review') return ''; + return [ + 'FORMATTING ONLY: Keep each `key_issues_to_review` item as concise as you', + 'already would — do NOT add length, padding, or extra explanation. The only', + 'change is line breaks: instead of one dense run-on paragraph, insert a BLANK', + 'LINE at the natural boundaries (e.g. problem → impact → suggested fix) so the', + 'text reads as a few short paragraphs. Same words, better layout.', + '', + 'This applies to the issue PROSE only. The machine-readable anchor marker', + 'described above still goes on its OWN LAST LINE, after the final paragraph', + '(a blank line may precede it).', + ].join('\n'); +} + +/** + * 组装注入 pr-agent 的 EXTRA_INSTRUCTIONS:按序拼接 语言指示 / anchor marker / 排版 / PR 上下文 / + * 命中规则,空段跳过;全空返回 undefined(调用方据此决定是否设 env)。 + * - 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 /ask 走 [pr_questions] 不严格 + * 遵守,必须显式强化 + * - PR 上下文 / 规则由调用方现读传入(local provider 不自己去远端拉这些) + */ +export function buildExtraInstructions(input: { + tool: ReviewRunTool; + language: string; + prContext: string; + matchedRuleInstructions: string; +}): string | undefined { + const parts = [ + languageDirectiveFor(input.language), + anchorMarkerDirective(input.tool), + reviewLayoutDirective(input.tool), + input.prContext, + input.matchedRuleInstructions, + ].filter((s) => s.trim()); + return parts.length > 0 ? parts.join('\n\n---\n\n') : undefined; +} + +/** EXTRA_INSTRUCTIONS 对应的 pr-agent env key(按 tool)。 */ +export function extraInstructionsEnvKey(tool: ReviewRunTool): string { + switch (tool) { + case 'describe': + return 'PR_DESCRIPTION__EXTRA_INSTRUCTIONS'; + case 'review': + return 'PR_REVIEWER__EXTRA_INSTRUCTIONS'; + case 'improve': + return 'PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS'; + default: + return 'PR_QUESTIONS__EXTRA_INSTRUCTIONS'; + } +} + +/** + * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 + * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff + * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 + */ +export function askLanguageSuffixFor(lang: string): string { + const norm = lang.toLowerCase(); + if (norm.startsWith('zh-cn') || norm === 'zh') { + return '请用简体中文回答整个回复(包括所有解释、说明与结论)。代码、标识符、文件路径保留原样,但所有叙述文字必须是简体中文,不要用英文作答。'; + } + if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { + return '請用繁體中文回答整個回覆(包括所有解釋、說明與結論)。程式碼、識別符、檔案路徑保留原樣,但所有敘述文字必須是繁體中文,不要用英文作答。'; + } + if (norm.startsWith('ja')) { + return '回答全体を日本語で記述してください(説明・結論を含む)。コード・識別子・ファイルパスはそのまま残し、説明文はすべて日本語にしてください。英語で回答しないでください。'; + } + if (norm.startsWith('de')) { + return 'Bitte antworte vollständig auf Deutsch (einschließlich aller Erklärungen und Schlussfolgerungen). Code, Bezeichner und Dateipfade bleiben unverändert, aber der gesamte erläuternde Text muss auf Deutsch sein. Antworte nicht auf Englisch.'; + } + return ''; +} + +/** + * /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), + * 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 + */ +export function stripAskQuestionEcho(md: string, ...echoed: string[]): string { + const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); + if (!qs.size || !md) return md; + return md + .split('\n') + .filter((line) => !qs.has(line.trim())) + .join('\n'); +} diff --git a/apps/desktop/src/main/services/run-queue.ts b/apps/desktop/src/main/services/run-queue.ts index c8093eb..4769421 100644 --- a/apps/desktop/src/main/services/run-queue.ts +++ b/apps/desktop/src/main/services/run-queue.ts @@ -24,6 +24,12 @@ import { buildPragentEnv, resolveActiveLlmProfile } from '../utils/agent.js'; import { buildPrContext } from '../utils/pr-context.js'; import { buildProxyEnv } from '../utils/proxy.js'; import type { ServiceContext } from './context.js'; +import { + askLanguageSuffixFor, + buildExtraInstructions, + extraInstructionsEnvKey, + stripAskQuestionEcho, +} from './pragent-prompts.js'; import { accumulateUsageSentinel, finalizeUsage, @@ -345,14 +351,8 @@ export class RunQueueService { env['LOCAL__REVIEW_PATH'] = 'improve.md'; } - // 注给 pr-agent 的 EXTRA_INSTRUCTIONS 由三部分按顺序拼接: - // 1. 语言指示:CONFIG__RESPONSE_LANGUAGE 对 /describe /review 够用,但 - // /ask 走 [pr_questions] 配置段不那么严格遵守,必须显式 prompt 强化 - // 2. PR 上下文 (title / description / 已有评论):local provider 自己不会 - // 去 Bitbucket 拉这些,必须我们这边喂;让 /describe /review 不只是看 diff - // 3. 规则正文 (rules.dir 命中):项目编码规约 - // /ask 只取 1 (语言),跳 2/3 (用户问题往往跟历史评论 / 规约无关) - const langDirective = languageDirectiveFor(getMainLanguage()); + // PR 上下文 + 命中规则:local provider 不会自己去远端拉,须现读喂给 EXTRA_INSTRUCTIONS; + // /ask 跳过(用户问题往往跟历史评论 / 规约无关)。提示词文本的组装见 pragent-prompts。 let prContext = ''; let matchedRuleInstructions = ''; let matchedRuleId: string | undefined; @@ -384,120 +384,14 @@ export class RunQueueService { } } - // anchor marker 指令:让 model 在涉及代码位置的内容末尾显式追加 - // [file: , lines: -] - // - // 主路径已改为 sitecustomize 注入 LocalGitProvider.get_line_link → key_issues 渲染成 - // `[**header**](meebox:///#L-L)`,parse-output 取结构化 anchor(path 来自 - // provider 同源、最可靠)。但 #L 行号仍依赖 model 填了 pr-agent 原生 start_line/ - // end_line YAML 字段;实测部分模型只填这条 marker、留空结构化字段 → 链接只有 path。 - // 故这条 marker 作为**行号兜底**保留:parse-output 合并时链接给 path、缺行号则用 marker - // 的行号补(resolveIssueAnchor)。两路信号都用上,最大化 anchor 覆盖。 - // - // 两种工具措辞不同: - // - /review: 每条 key_issue 末尾 **必加** marker - // - /ask: 仅当回答涉及具体文件 / 代码位置时 **才加** (自由问答可能完全跟代码 - // 无关 e.g. "PR 概述"),强制会产出假阳性 - // - // /describe / /improve 不注入:前者不出 issue,后者走 marker 行 - // `[file [start-end]](url)` 自己有 anchor - const reviewAnchorDirective = - req.tool === 'review' - ? [ - 'When writing each item under `key_issues_to_review`, append on its OWN LAST LINE', - 'a machine-readable anchor marker in this EXACT format:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - '', - 'Use the exact relevant_file path and start_line/end_line you already', - 'identified in the YAML output. Do NOT wrap the path in backticks. If you', - 'truly cannot identify a file/line for an issue, omit the marker for that', - 'item only.', - ].join('\n') - : req.tool === 'ask' - ? [ - 'CRITICAL: This answer is consumed by a code review GUI that converts your', - 'per-paragraph recommendations into INLINE COMMENTS pinned to specific code', - 'lines. For that to work, EVERY paragraph that names a code symbol (function,', - 'method, class, variable, identifier) from this PR MUST end with a', - 'machine-readable anchor marker on its OWN LAST LINE:', - '', - ' [file: , lines: -]', - '', - 'Examples:', - ' [file: src/auth/login.ts, lines: 42-50]', - ' [file: pkg/cache.go, lines: 17]', - ' [file: pkg/store.ts] (path-only fallback; only when you', - ' truly cannot infer any line number)', - '', - 'How to derive line numbers from the diff:', - '- Every hunk in the diff begins with a header:', - ' @@ -, +, @@', - ' The number after `+` is the FIRST head-side line of that hunk. Count down', - ' through `+` (added) and ` ` (context) lines — DO NOT count `-` (removed)', - ' lines — to locate the line where the symbol appears. Prefer head-side', - ' line numbers. For code that ONLY exists on the base side (purely removed),', - ' use the base-side `-` line number instead.', - '', - 'Rules — read carefully:', - '- The marker is REQUIRED. Do not skip it when your paragraph references a', - ' real code symbol from the diff. A paragraph without a marker becomes', - ' un-pinnable feedback the user cannot turn into a comment.', - '- Append exactly ONE marker per paragraph, at the very end of that paragraph,', - ' on its own line (blank line above it optional but recommended).', - '- If a paragraph discusses multiple locations, pick the most important one', - ' (the line where the recommended change should be made).', - '- Paragraphs that are purely general / conceptual / meta (e.g., overall', - ' praise, no specific symbol named) MAY omit the marker.', - '- Use the exact file path from the diff. Do NOT wrap the path in backticks', - ' or quotes inside the marker.', - '- If you really cannot pin a line, fall back to path-only `[file: ]`', - ' rather than omitting the marker entirely.', - ].join('\n') - : ''; - - // 排版指令:只改 /review 每条 key_issue 的断行排版,提升 GUI 可读性,不增加篇幅。 - // pr-agent 原 prompt 要 "short and concise summary",模型默认堆成单段长跑文; - // 渲染层 (ReactMarkdown + remarkBreaks) 忠实呈现,空行分段即成独立

。 - // 关键是「保持简洁」——只在现象/影响/建议的语义边界换行,不得借分段扩写内容。 - // 须与上面的 anchor marker 指令协同:分段在正文内部,marker 仍独占最末行。 - const reviewLayoutDirective = - req.tool === 'review' - ? [ - 'FORMATTING ONLY: Keep each `key_issues_to_review` item as concise as you', - 'already would — do NOT add length, padding, or extra explanation. The only', - 'change is line breaks: instead of one dense run-on paragraph, insert a BLANK', - 'LINE at the natural boundaries (e.g. problem → impact → suggested fix) so the', - 'text reads as a few short paragraphs. Same words, better layout.', - '', - 'This applies to the issue PROSE only. The machine-readable anchor marker', - 'described above still goes on its OWN LAST LINE, after the final paragraph', - '(a blank line may precede it).', - ].join('\n') - : ''; - - const extraParts = [ - langDirective, - reviewAnchorDirective, - reviewLayoutDirective, + // 提示词组装收口到 pragent-prompts:语言指示 / anchor marker / 排版 / PR 上下文 / 命中规则。 + const extraInstructions = buildExtraInstructions({ + tool: req.tool, + language: getMainLanguage(), prContext, matchedRuleInstructions, - ].filter((s) => s.trim()); - if (extraParts.length > 0) { - const envKey = - req.tool === 'describe' - ? 'PR_DESCRIPTION__EXTRA_INSTRUCTIONS' - : req.tool === 'review' - ? 'PR_REVIEWER__EXTRA_INSTRUCTIONS' - : req.tool === 'improve' - ? 'PR_CODE_SUGGESTIONS__EXTRA_INSTRUCTIONS' - : 'PR_QUESTIONS__EXTRA_INSTRUCTIONS'; - env[envKey] = extraParts.join('\n\n---\n\n'); - } + }); + if (extraInstructions) env[extraInstructionsEnvKey(req.tool)] = extraInstructions; if (matchedRuleId) { logger.info( { runId: run.id, ruleId: matchedRuleId, tool: req.tool }, @@ -672,66 +566,3 @@ export class RunQueueService { } } } - -/** - * /ask 输出去重:pr-agent answer markdown 里会回显完整问题(以及我们追加到问题末尾的语言要求), - * 跟 UI chat-user-msg 气泡重复。逐行精确匹配(trim 后整行 == 任一给定串)删掉,保留其余正文。 - */ -function stripAskQuestionEcho(md: string, ...echoed: string[]): string { - const qs = new Set(echoed.map((q) => q.trim()).filter(Boolean)); - if (!qs.size || !md) return md; - return md - .split('\n') - .filter((line) => !qs.has(line.trim())) - .join('\n'); -} - -/** - * 把 config.language (ISO locale) 翻成自然语言 prompt directive,注入到 pr-agent - * 各 tool 的 EXTRA_INSTRUCTIONS。 - * - * CONFIG__RESPONSE_LANGUAGE 对 /describe /review 已经够用 (内嵌在它们的 prompt - * template),但 /ask 不严格遵守;显式 prompt 强化所有 tool,尤其覆盖 /ask + 表格 - * 类输出的标题 / 列名 / 段落标记。 - * - * 英文 (en-US) 返回空串,避免给 LLM 加不必要的提示。其他未知 locale 返回空保留 - * pr-agent 原行为。 - */ -function languageDirectiveFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return 'Respond in Simplified Chinese (简体中文). All section labels, table headers, column names, headings, and content MUST be in Chinese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return 'Respond in Traditional Chinese (繁體中文). All section labels, table headers, column names, headings, and content MUST be in Chinese.'; - } - if (norm.startsWith('ja')) { - return 'Respond in Japanese (日本語). All section labels, table headers, column names, headings, and content MUST be in Japanese — do not leave any English template strings untranslated.'; - } - if (norm.startsWith('de')) { - return 'Respond in German (Deutsch). All section labels, table headers, column names, headings, and content MUST be in German — do not leave any English template strings untranslated.'; - } - return ''; -} - -/** - * /ask 专用:把语言要求作为「问题末尾」的硬性指令,**用目标语言书写本身**(最能促使模型切换到该 - * 语言作答)。系统侧 CONFIG__RESPONSE_LANGUAGE / EXTRA_INSTRUCTIONS 对自由问答常被大量英文 diff - * 盖过,故在 user turn 末尾(近因位置)再要求一次。en-US / 未知 locale 返回空串(默认即英文)。 - */ -function askLanguageSuffixFor(lang: string): string { - const norm = lang.toLowerCase(); - if (norm.startsWith('zh-cn') || norm === 'zh') { - return '请用简体中文回答整个回复(包括所有解释、说明与结论)。代码、标识符、文件路径保留原样,但所有叙述文字必须是简体中文,不要用英文作答。'; - } - if (norm.startsWith('zh-tw') || norm.startsWith('zh-hk')) { - return '請用繁體中文回答整個回覆(包括所有解釋、說明與結論)。程式碼、識別符、檔案路徑保留原樣,但所有敘述文字必須是繁體中文,不要用英文作答。'; - } - if (norm.startsWith('ja')) { - return '回答全体を日本語で記述してください(説明・結論を含む)。コード・識別子・ファイルパスはそのまま残し、説明文はすべて日本語にしてください。英語で回答しないでください。'; - } - if (norm.startsWith('de')) { - return 'Bitte antworte vollständig auf Deutsch (einschließlich aller Erklärungen und Schlussfolgerungen). Code, Bezeichner und Dateipfade bleiben unverändert, aber der gesamte erläuternde Text muss auf Deutsch sein. Antworte nicht auf Englisch.'; - } - return ''; -} From 64c72fb701387ead950add7cc00eb053e980a508 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Thu, 18 Jun 2026 20:05:11 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(pragent):=20=E5=90=8C=E6=AD=A5=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E6=B3=A8=E9=87=8A=20codex=20=E6=8E=A8=E7=90=86?= =?UTF-8?q?=E6=A1=A3=20minimal=E2=86=92low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 #78:本机 CLI 模式下 codex 编排通道改用 model_reasoning_effort=low。 重构后该 env 注释位于 services/agent-orchestrator.ts(dev 侧对应改动在 ipc.ts), 此处同步表述,避免合并 dev 后注释口径不一致。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/main/services/agent-orchestrator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/agent-orchestrator.ts b/apps/desktop/src/main/services/agent-orchestrator.ts index 76659b1..0dfa3ba 100644 --- a/apps/desktop/src/main/services/agent-orchestrator.ts +++ b/apps/desktop/src/main/services/agent-orchestrator.ts @@ -282,7 +282,7 @@ export class AgentOrchestratorService { ...(activeLlm ? buildPragentEnv(activeLlm) : {}), CONFIG__RESPONSE_LANGUAGE: getMainLanguage(), // Agent 编排通道(规划 / 判读 / 收尾 / 对话)是路由 + 轻量综合,非深度代码分析(那在 - // pr-agent /review 里)。本机 CLI 模式下调低推理档(codex: model_reasoning_effort=minimal) + // pr-agent /review 里)。本机 CLI 模式下调低推理档(codex: model_reasoning_effort=low) // 提速;仅作用于本 chat spawn,pr-agent 工具 run 的 env 不含此项 → /review 仍满档推理。 // 非 CLI 模式(API)由 CLI handler 之外的路径处理,该 env 无副作用。 MEEBOX_CLI_REASONING: 'low',