diff --git a/docs/architecture/loopx-issue-fix-integration.md b/docs/architecture/loopx-issue-fix-integration.md new file mode 100644 index 0000000000..536bbaafe4 --- /dev/null +++ b/docs/architecture/loopx-issue-fix-integration.md @@ -0,0 +1,237 @@ +# LoopX 持续 Issue 修复集成架构 + +> 状态:Implemented;描述 `feat/loopx-issue-fix` 分支落地的实际架构。 +> +> 基线日期:2026-08-06。 +> +> 本文记录 BitFun 如何以 host 身份嵌入 LoopX State Kernel 实现持续 issue 修复, +> 以及两侧的边界契约。LoopX 自身的设计哲学以其仓库的 +> [state-kernel-domain-state-case-study](https://github.com/huangruiteng/loopx/blob/main/docs/capabilities/issue-fix/state-kernel-domain-state-case-study.zh-CN.md) +> 为权威;本文只记录 BitFun 侧的接入决策与理由。相关代码: +> `src/crates/services/services-integrations/src/loopx_issue_fix/`、 +> `src/apps/desktop/src/api/issue_fix_api.rs`、 +> `src/web-ui/src/app/components/panels/issue-fix/`。 + +## 1. 分工总纲 + +LoopX 是一个 State Kernel:它不写代码、不调模型,只管理控制面事实 +(goal、todo、认领/租约、authority、user gate、配额、monitor、运行历史)。 +BitFun 作为 host 补齐它缺的三样东西: + +| Host 职责 | BitFun 实现 | +| --- | --- | +| 编码能力 | 普通 Agent 会话(真正读代码、打补丁、跑验证、发 PR) | +| 心跳 | 持久 Cron 服务,每 10 分钟向该会话注入一次心跳 prompt | +| 人机界面 | Issue-Fix 面板(只读投影 + 类型化命令)与通知中心 | + +一句话分层:**Kernel 管控制权,Domain State 管领域连续性,Capability 管翻译, +host 管执行与人机界面;任何 projection 都只负责展示。** + +## 2. 边界契约(解耦的硬规则) + +- BitFun 与 LoopX 的唯一交互通道是 `loopx --format json <命令>` 子进程调用 + (`LoopxIssueFix::json_in`)。不 import、不嵌入、不修改 LoopX 源码。 +- BitFun 对 LoopX 内部文件的唯一直接读取是 `.loopx/registry.json` 的身份两字段 + (goal id、registered agent)。不解析 `ACTIVE_GOAL_STATE.md`,不读领域账本。 +- BitFun 不持久化任何 issue 队列或第二状态机。面板每次刷新从 + `todo list`(+按需 `quota should-run`)重建视图;唯一的本地状态是 + 未提交的复选框选择。 +- Windows 编码兼容(LoopX subprocess 按局部编码解码 UTF-8 输出)用 + `PYTHONUTF8=1` 环境变量在 host 侧解决——host 适配置于 host,不打补丁。 +- **通用性原则**:host 代码与心跳 preamble 必须对任意仓库成立。仓库特定政策 + (工具链、验证命令、路径边界)写入该 goal 的 active state / registry, + 由 agent 运行中读写——这是 LoopX 契约的原文要求 + ("Keep project-specific branching out of the automation prompt")。 + +## 3. 状态所有权 + +| 状态 | 所有者 | 位置 | +| --- | --- | --- | +| issue todo、gate、monitor、配额 | LoopX Kernel | `.loopx/` + `.codex/goals//` | +| feasibility / PR lifecycle 观察 | LoopX Domain State | 领域账本(含指纹与 receipt) | +| issue/PR 的真实状态 | GitHub | 外部权威事实源 | +| 心跳调度(cron job、间隔、prompt 快照) | BitFun | `%APPDATA%/bitfun/data/cron/jobs.json` | +| 面板展示 | BitFun(纯投影) | 内存,每次从 Kernel 重建 | + +推论:删除 host 会话、应用崩溃、清空 BitFun 缓存都不丢修复进度—— +重新 Start 即从 Kernel 断点继续。GitHub 上的人工操作(merge、close)无需 +通知 LoopX:下一拍 monitor 回读后自动完成终局收敛。 + +## 4. 心跳设计 + +- **注入内容每拍相同**:BitFun host preamble(约 3.4KB, + `HEARTBEAT_HOST_PREAMBLE`)+ `loopx heartbeat-prompt --compact` 生成的 + 生命周期契约(约 6.3KB)。唯一逐拍变化是 cron 服务前置的当前时间行。 + 这是特性而非省事:prompt 是无状态调度契约,一切可变状态由 agent 醒来后 + 从 CLI 现场读取,避免状态出现第二来源。 +- **选 `--compact` 不选 `--thin`**:thin 档委托给 BitFun 会话中不存在的 + LoopX skill pack;compact 档把完整 should_run 生命周期内联。 +- **prompt 快照的刷新点**:Start 与每次 gate 应答时重新生成, + 使 LoopX 升级后的契约漂移在自然写入点跟上。 +- **单会话续聊**:所有心跳进同一会话(偏离了案例研究的"每拍新会话"理想形态), + 以 preamble 的"Kernel 唯一真源、无视对话早前结论" + autoCompact 缓解。 + 权衡理由:每拍新会话会使会话列表无限增长。 +- **单飞**:上一拍未结束时新拍合并跳过;应用重启后 job 从 jobs.json 恢复。 + +### Preamble 各规则的事故出处 + +preamble 中每条规则都对应一次真实事故或明确需求,修改前先理解出处: + +| 规则 | 出处 | +| --- | --- | +| Kernel 唯一真源,无视对话早前结论 | 单会话续聊的漂移风险 | +| 禁止杀死非本回合启动的进程 | agent 清理"陈旧" cargo 进程时把宿主 BitFun 杀掉(两次) | +| fix 分支基远端默认分支,不基 HEAD | 三个 PR 各带上 8000 行特性分支改动 | +| worktree 及构建缓存 terminal closeout 时回收 | worktree target 目录累计 56GB | +| gate 必须 `--unblocks-todo-id` 关联被阻塞 todo | 未关联 gate 在面板上不可见,循环静默空转 | +| 用户车道 todo 文本单行紧凑格式 | 通知中心密度反馈(草稿全文曾被塞进 todo 文本) | +| 仓库政策归 active state | 通用性要求(preamble 曾漂移出 cargo 专有指引) | + +## 5. LoopX CLI 调用面与写语义 + +| 命令 | 调用方 | 写语义 | +| --- | --- | --- | +| `todo list` | 面板 30s 轮询 | **零写入**(dry_run),轮询安全 | +| `quota should-run` | agent 每拍 + 面板手动刷新 | **每次调用追加一条 rollout event**,禁止用于轮询 | +| `heartbeat-prompt --compact` | Start / gate 应答 | 纯生成,无副作用 | +| `todo add`(intake) | Start | 写入;按文本对非 done 项去重,幂等 | +| `todo complete --decision-outcome` | gate 卡片提交 | 写入;仅 approve 消耗 authority | +| `bootstrap` + `register-agent` | 首次 Start 自动执行 | 创建 goal 与 agent lane,幂等入口 | + +已知投影限制:`todo list` 只返回活跃窗口,早期已 done 的 intake todo 会被 +LoopX 归档出列表,面板对应行退回未选中外观。该行为随关联 PR 被 merge +(issue 关闭、行消失)自愈;根治需 LoopX 暴露 outcome collection 查询(上游事项)。 + +## 6. 分发与就绪 + +- **sidecar**:`scripts/prepare-loopx-resource.mjs --build` 用 PyInstaller 打出 + 单文件 loopx(零运行时依赖),置于 `resources/loopx/`;打包脚本存在即捆绑。 + 运行时解析顺序:`LOOPX_BIN` 显式覆盖 → 捆绑 sidecar → PATH。 + 捆绑的核心价值是**契约钉版**:每个 BitFun 版本对应一个验证过的 LoopX 版本。 +- **三项体检**:`issue_fix_probe` 报告 loopx(含来源)、gh 安装、gh 登录三项; + 面板对缺失项显示针对性引导。gh 不捆绑(授权状态属于用户)。 +- **首次使用**:仓库无 `.loopx` 是正常态(面板显示"尚未连接"而非报错); + 首次 Start 自动 bootstrap(BitFun 的持续修复 objective、controller 角色) + 并注册 `bitfun-cron` agent lane。 +- **平台范围**:当前 GitHub-only(issue 枚举、gh 证据链、PR lifecycle monitor + 均为 GitHub 形态);非 GitHub 仓库面板显示明确的不支持说明,不发请求。 + +## 7. 交互不变式 + +- 面板是只读投影 + 两个类型化写命令(Start intake、gate 决策)。 + 聊天回复永远不构成授权;authority 只经 `todo complete` 写入 Kernel。 +- 所有 setControl 路径受单调 ticket 保护(慢响应不能复活已答复的 gate); + mutation 进行中暂停轮询。 +- Stop 只禁用 host 调度(连带清理孤儿/重复 job),不动 Kernel 状态。 +- 删除承载 cron job 的会话会先弹确认(说明任务将停止、进度保留)。 +- 新 gate / 待审项经通知中心投递(toast + 历史 + 未读角标); + 一拍产出超过 3 条时合并为摘要卡。 + +## 8. 构建与前置条件手册 + +### 8.1 LoopX 从哪里来(三层,任一满足即可) + +运行时解析顺序(`LoopxIssueFix::probe` + 桌面启动注入): + +1. **`LOOPX_BIN` 环境变量**(开发者通道):指向任意 loopx 可执行文件。 + 开发建议 editable 安装,改动即时生效: + + ```bash + git clone https://github.com/huangruiteng/loopx ../loopx + pip install -e ../loopx # 需要 Python >= 3.11 + # loopx 进入 PATH;或显式 set LOOPX_BIN=/loopx + ``` + +2. **捆绑 sidecar**(用户通道):打包产物内的 `resources/loopx/loopx-`, + 桌面启动时自动将 `LOOPX_BIN` 指向它(用户已设置则不覆盖)。构建方法见 §8.2。 +3. **PATH**:最后兜底,`which loopx`。 + +LoopX 运行时**零第三方依赖**(pyproject `dependencies = []`,纯标准库), +这是 sidecar 单文件打包可行的前提。 + +### 8.2 构建 sidecar + +```bash +pip install pyinstaller # 一次性 +node scripts/prepare-loopx-resource.mjs --build +# 源码目录默认 ../loopx,可用 --loopx-dir 或 LOOPX_SRC_DIR 覆盖 +# 产物: resources/loopx/loopx-[.exe],约 14MB +``` + +- 产物冒烟:`resources/loopx/ --version`,再跑一次 + `--format json todo list --goal-id <任意> --agent-id <任意>`(应返回 ok:false + 的结构化 JSON 而非崩溃)。 +- `pnpm run desktop:build` 检测到产物即捆绑;**没有产物构建照常成功** + (sidecar 是可选资源),运行时回落到 PATH。 +- 每个平台需在对应平台构建(PyInstaller 不交叉编译);当前仅 Windows x64 + 验证过,macOS/Linux 待 CI 矩阵。 +- 升级 LoopX 版本 = 在 loopx checkout 切到目标版本 → 重跑 `--build` → + 重新打包 BitFun。sidecar 版本即该 BitFun 版本的契约版本。 + +### 8.3 前置条件缺失时的行为与处理 + +`issue_fix_probe` 一次性体检三项,面板对缺失项显示对应引导;下表同时给出 +开发者侧的处理动作: + +| 缺失项 | 面板行为 | 处理措施 | +| --- | --- | --- | +| loopx 不可用 | 警告横幅"LoopX 引擎不可用" | 用户:重装 BitFun(恢复 sidecar)。开发者:`pip install -e ../loopx` 或设 `LOOPX_BIN`;检查 `resources/loopx/` 是否有产物 | +| gh 未安装 | 警告横幅 + 安装指引 | 从 安装后重启 BitFun(PATH 变更需重启进程才可见) | +| gh 未登录 | 警告横幅 + 登录指引 | 终端运行 `gh auth login`(github.com,浏览器授权即可)后刷新面板。agent 的全部 GitHub 读写走 gh 的凭据,BitFun 不存 GitHub token | +| 仓库无 `.loopx` | 头部显示"尚未连接"(非错误) | 无需处理;首次 Start 自动 `bootstrap` + `register-agent bitfun-cron` | +| 仓库非 GitHub 托管 | 整面板"暂仅支持 GitHub"说明 | 当前产品范围限制,见 §6 | + +注意事项: + +- gh 授权是**用户身份**,永不捆绑、永不代管;发 PR/评论的署名即该身份。 +- Windows 中文局部下 LoopX 子进程需 `PYTHONUTF8=1`——bridge 已自动注入, + 手动在终端调试 loopx 时需自己 `set PYTHONUTF8=1`。 +- 心跳 agent 会执行 `gh` 与 git 网络操作;企业代理环境下 gh 的代理配置 + (`gh config`/环境变量)需用户自行就绪,probe 不检测网络可达性。 + +## 9. 接入更多 LoopX capability 的指南 + +LoopX 还提供 `semantic-preference`、`reward-memory`、`periodic-report`、 +`content-ops`、`value-connectors`、`explore`、`auto-research` 等 capability +(`loopx capability list` 枚举;`capability show ` 给出该 capability 的 +CLI、协议、冒烟与边界说明)。issue-fix 的接入分层就是为复用设计的, +新 capability 按下表决定复用哪些层: + +| 层 | 现有资产 | 新 capability 需要做什么 | +| --- | --- | --- | +| 进程桥 | `LoopxIssueFix::probe` / `json_in`(含 UTF-8、超时上限、in-band 错误解析) | **原样复用**。桥是 capability 无关的,改名/上移模块即可 | +| sidecar 分发 | §8.2 构建链 + 三层解析 | **零改动**——同一个 loopx 二进制承载全部 capability | +| 身份与 bootstrap | `read_identity` / `ensure_bootstrapped` | 同仓库同 goal 直接复用;新 capability 若需独立 goal 再评估 | +| 控制面投影 | `todo list` 轮询 + `quota should-run` 按需 | 复用模式:**轮询只用零写入命令**(先验证新命令的写语义,方法见下) | +| 心跳 | cron job + preamble + `heartbeat-prompt --compact` | 同 goal 共享同一心跳——Kernel 按 todo 调度,capability 之间天然复用一个循环;无需第二个 cron job | +| 类型化写命令 | intake `todo add` / gate `todo complete` | 每个 capability 定义自己的 intake 形态(action_kind 词汇归 host 定义,如 `issue_fix_intake` 的先例)与 gate 应答面 | +| UI | 面板 + 通知中心投递 | 新面板复用交互不变式(§7):只读投影、ticket 保护、授权只走类型化命令 | + +接入新 capability 的检查清单: + +1. **读 capability 契约**:`loopx capability show `、其 `docs/capabilities//` + 与 workflow contract,确认它期望 host 供给什么(issue-fix 的教训:LoopX + 不自带发现/执行能力,host 供给一切证据与执行)。 +2. **验证每个要调用命令的写语义**:干净测试仓库跑一遍,比对 + runtime 目录(`~/.codex/loopx/goals//`)前后差异。issue-fix 的教训: + `quota should-run` 看似只读实则每次追加 rollout event。 +3. **确定 intake 形态**:用户在 UI 选择什么 → 物化成什么 todo + (task_class / action_kind / task_repository / capability 要求)。 +4. **确定 gate 面**:该 capability 会产生哪类 user_gate / user_action, + 投影是否需要新的关联字段(issue-fix 的教训:gate 必须要求 + `--unblocks-todo-id`,且投影要有无关联兜底)。 +5. **preamble 增量**:只加该 capability 的 host 语境,且必须通过 + "对别人机器上的任意仓库是否成立"检验;仓库特定内容进 active state。 +6. **端到端新用户验证**:干净仓库 + 仅 sidecar(无 editable 安装)走通 + 全流程后才算接入完成(issue-fix 的教训:pre-bootstrap 状态曾直接报错)。 + +## 10. 已知偏离与上游事项 + +| 事项 | 状态 | +| --- | --- | +| 单会话续聊 vs 每拍新会话 | 有意偏离,见 §4 | +| compact 契约超 LoopX 自身 6200 字符预算(~6.3K) | 信息性,LoopX 不强制 | +| done todo 归档导致面板行状态回退 | 自愈型;根治待上游 outcome collection | +| `retire-global-goal` 对已删目录的 goal 失败 | 上游小缺陷 | +| sidecar 仅在 Windows x64 构建验证 | macOS/Linux 待 CI 矩阵 | +| orchestrator.rs / repository_context.rs 仅测试引用 | 作为 CLI 契约的可执行文档保留 | diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 02d67bf762..d292395858 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -136,6 +136,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['miniapp-market', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['plugin-source', ['fs', 'rt', 'sync', 'time']], ['hook-import', ['fs', 'sync']], + ['loopx-issue-fix', ['fs', 'io-util', 'macros', 'process', 'sync']], ['remote-connect', ['fs', 'io-util', 'net', 'process', 'rt', 'sync', 'time']], ['remote-ssh', ['fs', 'io-util', 'macros', 'net', 'process', 'rt', 'sync', 'time']], ['remote-ssh-concrete', ['fs', 'io-util', 'macros', 'net', 'process', 'rt', 'sync', 'time']], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index c6ba62c96a..4eb3b0f357 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -129,7 +129,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['git', 'loopx-issue-fix', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', @@ -138,7 +138,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research', 'hook-import'] }, { depName: 'bitfun-core-types', ownerFeatures: ['remote-connect', 'speech'] }, { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'plugin-source'] }, - { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, + { depName: 'bitfun-runtime-ports', ownerFeatures: ['git', 'loopx-issue-fix', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', ownerFeatures: ['browser-control', 'git', 'hook-import', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'remote-ssh', 'review-platform', 'workspace-search'], @@ -180,12 +180,12 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'ssh_config', ownerFeatures: ['remote-ssh-concrete', 'ssh_config'] }, { depName: 'terminal-core', ownerFeatures: ['remote-ssh', 'remote-ssh-concrete'] }, { depName: 'tar', ownerFeatures: ['speech'] }, - { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'hook-import', 'miniapp-market', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, + { depName: 'thiserror', ownerFeatures: ['browser-control', 'git', 'hook-import', 'loopx-issue-fix', 'miniapp-market', 'plugin-source', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools', 'workspace-search'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'hook-import', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, - { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, + { depName: 'which', ownerFeatures: ['loopx-issue-fix', 'miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -648,6 +648,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'function-agents', 'git', 'hook-import', + 'loopx-issue-fix', 'miniapp-runtime', 'mcp', 'models-dev', diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 3e7518258a..8dbee99bba 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -12,6 +12,7 @@ import { writeFileSync, } from 'fs'; import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; +import { findLoopxBinary } from './prepare-loopx-resource.mjs'; import { extractProductConfigArg } from './product-customization/cli.mjs'; import { productBuildEnvironment } from './product-customization/projections.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; @@ -44,6 +45,15 @@ async function main() { const flashgrepBinary = ensureFlashgrepBinary(); process.env.FLASHGREP_DAEMON_BIN = flashgrepBinary; + // Optional LoopX sidecar for continuous Issue-Fix: bundle it when present, + // otherwise the feature falls back to LOOPX_BIN / PATH at runtime. + const loopxBinary = findLoopxBinary(); + console.log( + loopxBinary + ? `[loopx-sidecar] bundling ${loopxBinary}` + : '[loopx-sidecar] absent; packaged Issue-Fix will rely on LOOPX_BIN or PATH' + ); + const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). process.env.CI = 'true'; @@ -51,6 +61,7 @@ async function main() { const tauriConfig = prepareTauriConfig(join(desktopDir, 'tauri.conf.json'), { desktopDir, flashgrepBinary, + loopxBinary, resolution, }); const tauriBin = join(ROOT, 'node_modules', '.bin', 'tauri'); @@ -168,7 +179,7 @@ function optionValue(args, option) { export function prepareTauriConfig( baseConfigPath, - { desktopDir, flashgrepBinary, resolution } + { desktopDir, flashgrepBinary, loopxBinary, resolution } ) { const config = JSON.parse(readFileSync(baseConfigPath, 'utf8')); if (resolution) { @@ -180,6 +191,7 @@ export function prepareTauriConfig( config.identifier = resolution.assembly.bundleId; } injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary); + injectLoopxSidecarResource(config, desktopDir, loopxBinary); const enabled = ['1', 'true', 'yes'].includes( String(process.env.BITFUN_ENABLE_UPDATER_ARTIFACTS || '').toLowerCase() @@ -251,6 +263,19 @@ function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { }; } +function injectLoopxSidecarResource(config, desktopDir, loopxBinary) { + if (!loopxBinary) { + return; + } + const resources = { ...(config.bundle?.resources || {}) }; + const source = toTauriPath(relative(desktopDir, loopxBinary)); + resources[source] = `loopx/${basename(loopxBinary)}`; + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + function bundledFlashgrepResources(primaryBinary) { const binaries = [primaryBinary]; diff --git a/scripts/prepare-loopx-resource.mjs b/scripts/prepare-loopx-resource.mjs new file mode 100644 index 0000000000..921030fb2e --- /dev/null +++ b/scripts/prepare-loopx-resource.mjs @@ -0,0 +1,157 @@ +/** + * Prepare the LoopX sidecar binary for desktop packaging. + * + * Mirrors prepare-flashgrep-resource.mjs: the packaged app bundles a + * platform-specific single-file `loopx` binary under resources/loopx/, which + * the desktop host points LOOPX_BIN at when the user has not set it. + * + * Unlike flashgrep (a prebuilt download), LoopX is a zero-dependency Python + * package, so the binary can be produced locally with PyInstaller when a + * LoopX checkout is available: + * + * node scripts/prepare-loopx-resource.mjs --build [--loopx-dir ] + * + * The checkout defaults to ../loopx next to this repository, or LOOPX_SRC_DIR. + * CI can instead drop a prebuilt binary into resources/loopx/ before packaging. + * + * The sidecar is OPTIONAL: when no binary is present the desktop build + * continues without it and Issue-Fix falls back to LOOPX_BIN / PATH at + * runtime, keeping `pnpm run desktop:build` working for contributors who do + * not touch Issue-Fix. + */ +import { execFileSync } from 'child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + statSync, + writeFileSync, +} from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const RESOURCE_DIR = join(ROOT, 'resources', 'loopx'); + +export function loopxBinaryNames() { + if (process.platform === 'win32' && process.arch === 'x64') { + return ['loopx-x86_64-pc-windows-msvc.exe']; + } + if (process.platform === 'win32' && process.arch === 'arm64') { + return ['loopx-aarch64-pc-windows-msvc.exe']; + } + if (process.platform === 'darwin' && process.arch === 'x64') { + return ['loopx-x86_64-apple-darwin']; + } + if (process.platform === 'darwin' && process.arch === 'arm64') { + return ['loopx-aarch64-apple-darwin']; + } + if (process.platform === 'linux' && process.arch === 'x64') { + return ['loopx-x86_64-unknown-linux-musl', 'loopx-x86_64-unknown-linux-gnu']; + } + if (process.platform === 'linux' && process.arch === 'arm64') { + return ['loopx-aarch64-unknown-linux-musl', 'loopx-aarch64-unknown-linux-gnu']; + } + return [process.platform === 'win32' ? 'loopx.exe' : 'loopx']; +} + +export function loopxBinaryName() { + return loopxBinaryNames()[0]; +} + +/** The bundled sidecar path when present, else null (sidecar is optional). */ +export function findLoopxBinary() { + for (const binaryName of loopxBinaryNames()) { + const binaryPath = join(RESOURCE_DIR, binaryName); + if (!existsSync(binaryPath)) { + continue; + } + if (process.platform !== 'win32') { + chmodSync(binaryPath, statSync(binaryPath).mode | 0o111); + } + return binaryPath; + } + return null; +} + +function resolveLoopxSourceDir(explicitDir) { + const candidates = [ + explicitDir, + process.env.LOOPX_SRC_DIR, + join(ROOT, '..', 'loopx'), + ].filter(Boolean); + for (const candidate of candidates) { + if (existsSync(join(candidate, 'pyproject.toml')) && existsSync(join(candidate, 'loopx'))) { + return candidate; + } + } + throw new Error( + `LoopX checkout not found. Tried: ${candidates.join(', ')}. ` + + 'Pass --loopx-dir or set LOOPX_SRC_DIR.' + ); +} + +/** + * Build the sidecar with PyInstaller from a LoopX checkout. + * + * LoopX has zero runtime dependencies (pyproject `dependencies = []`), which + * keeps the one-file build deterministic; --collect-submodules covers its + * dynamically imported command modules. + */ +export function buildLoopxBinary({ loopxDir, python = process.env.PYTHON || 'python' } = {}) { + const sourceDir = resolveLoopxSourceDir(loopxDir); + const binaryName = loopxBinaryName(); + const workDir = join(ROOT, 'target', 'loopx-sidecar'); + mkdirSync(RESOURCE_DIR, { recursive: true }); + mkdirSync(workDir, { recursive: true }); + + // LoopX exposes its CLI as the console script `loopx.cli:main`; PyInstaller + // wants a script file, so generate a tiny launcher for it. + const launcher = join(workDir, 'loopx_sidecar_entry.py'); + writeFileSync(launcher, 'from loopx.cli import main\n\nif __name__ == "__main__":\n main()\n'); + + console.log(`[loopx-sidecar] building ${binaryName} from ${sourceDir}`); + execFileSync( + python, + [ + '-m', 'PyInstaller', + '--onefile', + '--name', binaryName.replace(/\.exe$/, ''), + '--distpath', RESOURCE_DIR, + '--workpath', join(workDir, 'build'), + '--specpath', join(workDir, 'spec'), + '--collect-submodules', 'loopx', + '--console', + '--noconfirm', + launcher, + ], + { stdio: 'inherit', cwd: sourceDir, env: { ...process.env, PYTHONUTF8: '1' } } + ); + + const built = findLoopxBinary(); + if (!built) { + throw new Error(`PyInstaller finished but ${binaryName} is missing under resources/loopx/`); + } + const version = execFileSync(built, ['--version'], { encoding: 'utf8' }).trim(); + console.log(`[loopx-sidecar] built ${built} (${version})`); + return built; +} + +const invokedDirectly = + process.argv[1] && fileURLToPath(import.meta.url) === join(process.argv[1]); +if (invokedDirectly) { + const args = process.argv.slice(2); + const dirIndex = args.indexOf('--loopx-dir'); + const loopxDir = dirIndex >= 0 ? args[dirIndex + 1] : undefined; + if (args.includes('--build')) { + buildLoopxBinary({ loopxDir }); + } else { + const existing = findLoopxBinary(); + console.log( + existing + ? `[loopx-sidecar] present: ${existing}` + : '[loopx-sidecar] absent (optional); run with --build to produce it' + ); + } +} diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 9ac1b7f6fa..4e8255525e 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -24,7 +24,7 @@ bitfun-relay-service = { path = "../../crates/services/relay-service" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["appearance-market"] } -bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "miniapp-market", "speech"] } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "loopx-issue-fix", "miniapp-market", "speech"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } diff --git a/src/apps/desktop/src/api/issue_fix_api.rs b/src/apps/desktop/src/api/issue_fix_api.rs new file mode 100644 index 0000000000..59b6d3129d --- /dev/null +++ b/src/apps/desktop/src/api/issue_fix_api.rs @@ -0,0 +1,704 @@ +//! Continuous Issue-Fix commands. +//! +//! LoopX Kernel owns the durable issue todos and lifecycle decisions. BitFun's +//! persistent Cron service is only the host wake mechanism for one ordinary +//! Agent session; no BitFun Thread Goal participates in this path. + +use std::path::Path; + +use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::agentic::core::SessionConfig; +use bitfun_core::service::cron::{ + get_global_cron_service, CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, + CronJobTarget, CronSchedule, CronWorkspaceRef, UpdateCronJobRequest, +}; +use bitfun_services_integrations::loopx_issue_fix::autonomous::{ + AutonomousControlState, AutonomousIssueFix, AutonomousLightState, IssueSelection, UserDecision, +}; +use bitfun_services_integrations::loopx_issue_fix::LoopxIssueFix; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::Mutex; + +use crate::api::app_state::AppState; + +const WAKE_INTERVAL_MS: u64 = 10 * 60 * 1_000; +const JOB_NAME_PREFIX: &str = "LoopX Issue Fix: "; + +/// Serializes host-loop mutations so two concurrent starts cannot both pass +/// the duplicate-job check and create twin cron jobs. +static HOST_LOOP_LOCK: Mutex<()> = Mutex::const_new(()); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAvailability { + pub available: bool, + pub program: Option, + /// Where the loopx binary came from: "override" (LOOPX_BIN), "path", or + /// null when unavailable. The desktop host points LOOPX_BIN at the bundled + /// sidecar on startup, so "override" also covers the shipped binary. + pub source: Option<&'static str>, + /// GitHub CLI presence — the agent's evidence/PR channel. + pub gh_installed: bool, + /// `gh auth status` reports an active github.com login. + pub gh_authenticated: bool, +} + +/// One readiness probe for everything continuous Issue-Fix needs at runtime: +/// the LoopX kernel CLI, the GitHub CLI, and a GitHub login. The panel renders +/// targeted guidance for whichever tier is missing. +#[tauri::command] +pub async fn issue_fix_probe(_state: State<'_, AppState>) -> Result { + let loopx = LoopxIssueFix::probe(); + let (gh_installed, gh_authenticated) = probe_github_cli().await; + Ok(match loopx { + Some(loopx) => IssueFixAvailability { + available: true, + program: Some(loopx.program().display().to_string()), + source: Some(if std::env::var_os("LOOPX_BIN").is_some() { + "override" + } else { + "path" + }), + gh_installed, + gh_authenticated, + }, + None => IssueFixAvailability { + available: false, + program: None, + source: None, + gh_installed, + gh_authenticated, + }, + }) +} + +/// Detect the GitHub CLI and an active github.com login without touching any +/// stored BitFun tokens: the heartbeat agent shells out to `gh` itself, so +/// what matters is exactly what `gh` sees. +async fn probe_github_cli() -> (bool, bool) { + let mut command = tokio::process::Command::new("gh"); + command + .args(["auth", "status", "--hostname", "github.com"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + // tokio's Command exposes creation_flags directly; suppress the + // console window that would otherwise flash on spawn. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + match command.status().await { + Ok(status) => (true, status.success()), + Err(_) => (false, false), + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAutonomousStatusRequest { + pub repository_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixHostLoopState { + pub enabled: bool, + pub job_id: Option, + pub session_id: Option, + pub active_turn_id: Option, + pub next_run_at_ms: Option, + pub last_run_status: Option, + pub last_error: Option, + pub consecutive_failures: u32, +} + +impl Default for IssueFixHostLoopState { + fn default() -> Self { + Self { + enabled: false, + job_id: None, + session_id: None, + active_turn_id: None, + next_run_at_ms: None, + last_run_status: None, + last_error: None, + consecutive_failures: 0, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAutonomousStatusResponse { + #[serde(flatten)] + pub control: AutonomousControlState, + pub host_loop: IssueFixHostLoopState, +} + +#[tauri::command] +pub async fn issue_fix_autonomous_status( + _state: State<'_, AppState>, + request: IssueFixAutonomousStatusRequest, +) -> Result, String> { + let repository_path = required_repository_path(&request.repository_path)?; + // A repository that has never started continuous fixing has no LoopX + // control plane yet; that is a normal pre-bootstrap state, not an error. + if !AutonomousIssueFix::is_bootstrapped(repository_path) { + return Ok(None); + } + let loopx = + LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?; + let control = AutonomousIssueFix::new(loopx) + .inspect(repository_path) + .await + .map_err(|error| { + error!("Failed to inspect LoopX Issue-Fix state: {error}"); + format!("Failed to read LoopX Issue-Fix state: {error}") + })?; + let host_loop = host_loop_state(&control.goal_id, repository_path).await; + Ok(Some(IssueFixAutonomousStatusResponse { control, host_loop })) +} + +/// Background-poll response: LoopX todo projection without `quota should-run`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAutonomousPollResponse { + #[serde(flatten)] + pub light: AutonomousLightState, + pub host_loop: IssueFixHostLoopState, +} + +/// Cheap status for the panel's poll loop. Unlike `issue_fix_autonomous_status` +/// this never runs `quota should-run` (which appends a LoopX rollout event per +/// call), so polling it on an interval does not grow LoopX's event log. +#[tauri::command] +pub async fn issue_fix_autonomous_poll( + _state: State<'_, AppState>, + request: IssueFixAutonomousStatusRequest, +) -> Result, String> { + let repository_path = required_repository_path(&request.repository_path)?; + if !AutonomousIssueFix::is_bootstrapped(repository_path) { + return Ok(None); + } + let loopx = + LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?; + let light = AutonomousIssueFix::new(loopx) + .poll(repository_path) + .await + .map_err(|error| { + error!("Failed to poll LoopX Issue-Fix todos: {error}"); + format!("Failed to poll LoopX Issue-Fix state: {error}") + })?; + let host_loop = host_loop_state(&light.goal_id, repository_path).await; + Ok(Some(IssueFixAutonomousPollResponse { light, host_loop })) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAnswerUserQuestionRequest { + pub repository_path: String, + pub todo_id: String, + pub decision: UserDecision, + pub reason: Option, +} + +#[tauri::command] +pub async fn issue_fix_answer_user_question( + _state: State<'_, AppState>, + request: IssueFixAnswerUserQuestionRequest, +) -> Result { + let repository_path = required_repository_path(&request.repository_path)?; + let loopx = + LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?; + let autonomous = AutonomousIssueFix::new(loopx); + let control = autonomous + .answer_user_question( + repository_path, + &request.todo_id, + request.decision, + request.reason.as_deref(), + ) + .await + .map_err(|error| { + error!("Failed to answer LoopX Issue-Fix user question: {error}"); + format!("Failed to answer LoopX Issue-Fix user question: {error}") + })?; + // The wake must not race a concurrent Stop: run_job_now's manual trigger + // bypasses enabled=false, so re-read the job state under the same lock + // Stop holds while disabling. + let _guard = HOST_LOOP_LOCK.lock().await; + let mut host_loop = host_loop_state(&control.goal_id, repository_path).await; + if host_loop.enabled { + if let (Some(cron), Some(job_id)) = (get_global_cron_service(), host_loop.job_id.as_deref()) + { + // The stored heartbeat prompt is a snapshot; a gate answer is a + // natural point to re-sync it with the installed LoopX version. + match autonomous.heartbeat_prompt(repository_path).await { + Ok(prompt) => { + if let Err(error) = cron + .update_job( + job_id, + UpdateCronJobRequest { + payload: Some(CronJobPayload { text: prompt }), + ..UpdateCronJobRequest::default() + }, + ) + .await + { + warn!("Failed to refresh the Issue-Fix heartbeat prompt: {error}"); + } + } + Err(error) => { + warn!("Failed to regenerate the Issue-Fix heartbeat prompt: {error}") + } + } + match cron.run_job_now(job_id).await { + Ok(job) => host_loop = project_host_loop(&job), + Err(error) => warn!( + "LoopX Issue-Fix user decision was recorded but the host loop could not be woken immediately: {error}" + ), + } + } + } + Ok(IssueFixAutonomousStatusResponse { control, host_loop }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixAutonomousIssueRequest { + pub issue_ref: String, + pub issue_url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixStartAutonomousRequest { + /// Existing session to host the heartbeat. Empty when `hidden_host` asks + /// the backend to create (or reuse) a hidden session instead. + #[serde(default)] + pub session_id: String, + /// MiniApp mode: host the heartbeat in a hidden session owned by the + /// backend, invisible in the sidebar. The repair loop keeps running when + /// the MiniApp is closed because scheduling stays host-owned. + #[serde(default)] + pub hidden_host: bool, + pub repo: String, + pub repository_path: String, + pub issues: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixStartAutonomousResponse { + #[serde(flatten)] + pub control: AutonomousControlState, + pub host_loop: IssueFixHostLoopState, + pub added_issue_refs: Vec, + pub immediate_turn_id: Option, +} + +#[tauri::command] +pub async fn issue_fix_start_autonomous( + _state: State<'_, AppState>, + coordinator: State<'_, std::sync::Arc>, + request: IssueFixStartAutonomousRequest, +) -> Result { + let repository_path = required_repository_path(&request.repository_path)?; + let cron = + get_global_cron_service().ok_or_else(|| "Cron service is not initialized".to_string())?; + let loopx = + LoopxIssueFix::probe().ok_or_else(|| "loopx is not installed on this host".to_string())?; + let selections = request + .issues + .into_iter() + .map(|issue| IssueSelection { + issue_ref: issue.issue_ref, + issue_url: issue.issue_url, + }) + .collect::>(); + + let autonomous = AutonomousIssueFix::new(loopx); + // First use on a fresh repository: create the LoopX goal and the host + // agent lane before writing intake todos. No-op when already connected. + autonomous + .ensure_bootstrapped(repository_path) + .await + .map_err(|error| { + error!("Failed to bootstrap LoopX for continuous Issue-Fix: {error}"); + format!("Failed to connect this repository to LoopX: {error}") + })?; + + let plan = autonomous + .start(repository_path, request.repo.trim(), &selections) + .await + .map_err(|error| { + error!("Failed to start continuous LoopX Issue-Fix: {error}"); + format!("Failed to start continuous Issue-Fix: {error}") + })?; + + // Resolve the heartbeat host session. MiniApp mode asks the backend for a + // hidden session (invisible in the sidebar, reused across starts via the + // existing job's binding); panel mode passes an explicit visible session. + let session_id = if request.hidden_host { + resolve_hidden_heartbeat_session( + &coordinator, + &plan.control.goal_id, + repository_path, + cron.list_jobs().await, + ) + .await? + } else { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required for continuous issue fixing".to_string()); + } + session_id.to_string() + }; + + let job_name = job_name(&plan.control.goal_id); + let workspace = CronWorkspaceRef { + workspace_id: None, + workspace_path: repository_path.display().to_string(), + project_workspace_path: Some(repository_path.display().to_string()), + execution_target: None, + remote_connection_id: None, + remote_ssh_host: None, + }; + let target = CronJobTarget::Session { + session_id: session_id.clone(), + workspace, + }; + let schedule = CronSchedule::Every { + every_ms: WAKE_INTERVAL_MS, + anchor_ms: None, + }; + let payload = CronJobPayload { + text: plan.heartbeat_prompt, + }; + + let _guard = HOST_LOOP_LOCK.lock().await; + let matching = resolve_host_loop_job(&job_name, repository_path).await?; + let job = if let Some(existing) = matching { + cron.update_job( + &existing.id, + UpdateCronJobRequest { + name: Some(job_name), + schedule: Some(schedule), + payload: Some(payload), + enabled: Some(true), + target: Some(target), + }, + ) + .await + } else { + cron.create_job(CreateCronJobRequest { + name: job_name, + schedule, + payload, + enabled: true, + target, + }) + .await + } + .map_err(|error| { + error!("Failed to persist the continuous Issue-Fix host loop: {error}"); + format!("Failed to persist continuous Issue-Fix host loop: {error}") + })?; + + let triggered = cron.run_job_now(&job.id).await.map_err(|error| { + error!("Failed to trigger the continuous Issue-Fix host loop: {error}"); + format!("Failed to trigger continuous Issue-Fix host loop: {error}") + })?; + let immediate_turn_id = triggered.state.active_turn_id.clone(); + let host_loop = project_host_loop(&triggered); + + Ok(IssueFixStartAutonomousResponse { + control: plan.control, + host_loop, + added_issue_refs: plan.added_issue_refs, + immediate_turn_id, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueFixStopAutonomousRequest { + pub repository_path: String, +} + +/// Disable the host wake loop. LoopX Kernel state (goal, todos, gates) is left +/// untouched: stopping the heartbeat is a host scheduling concern, and a later +/// start resumes exactly where the Kernel says the work stands. +#[tauri::command] +pub async fn issue_fix_stop_autonomous( + _state: State<'_, AppState>, + request: IssueFixStopAutonomousRequest, +) -> Result { + let repository_path = required_repository_path(&request.repository_path)?; + // Stop is the kill switch: it must work even when the LoopX registry is + // broken or the goal identity changed, so a failed lookup only demotes + // which job gets projected, never aborts the disable sweep. + let current_name = match AutonomousIssueFix::identity(repository_path) { + Ok((goal_id, _)) => job_name(&goal_id), + Err(error) => { + warn!("Stopping Issue-Fix host loops without a resolvable LoopX goal: {error}"); + String::new() + } + }; + let cron = + get_global_cron_service().ok_or_else(|| "Cron service is not initialized".to_string())?; + + let _guard = HOST_LOOP_LOCK.lock().await; + // Disable every Issue-Fix loop bound to this repository, not just the + // current goal's: this must also catch jobs orphaned by an older goal + // identity. + let mut stopped: Option = None; + for job in cron.list_jobs().await { + if !job.name.starts_with(JOB_NAME_PREFIX) || !job_targets_repository(&job, repository_path) + { + continue; + } + let disabled = cron + .update_job( + &job.id, + UpdateCronJobRequest { + enabled: Some(false), + ..UpdateCronJobRequest::default() + }, + ) + .await + .map_err(|error| { + error!("Failed to stop the continuous Issue-Fix host loop: {error}"); + format!("Failed to stop continuous Issue-Fix host loop: {error}") + })?; + if disabled.name == current_name || stopped.is_none() { + stopped = Some(disabled); + } + } + Ok(stopped + .as_ref() + .map(project_host_loop) + .unwrap_or_default()) +} + +fn required_repository_path(raw: &str) -> Result<&Path, String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("repository_path is required".to_string()); + } + let path = Path::new(trimmed); + if !path.is_dir() { + return Err(format!( + "Repository path does not exist: {}", + path.display() + )); + } + Ok(path) +} + +fn job_name(goal_id: &str) -> String { + format!("{JOB_NAME_PREFIX}{goal_id}") +} + +/// Session name for the hidden heartbeat host (MiniApp mode). Not user-facing +/// in the sidebar; shows up only in diagnostics. +const HIDDEN_HOST_SESSION_NAME: &str = "LoopX Issue-Fix heartbeat"; +const HIDDEN_HOST_AGENT_KIND: &str = "agentic"; + +/// Find or create the hidden session hosting this goal's heartbeat. +/// +/// Reuse order: the existing job's bound session (when it still exists) wins, +/// so restarts keep the conversation context; otherwise a fresh hidden +/// session is created. Hidden sessions never appear in the sidebar, which is +/// what lets the MiniApp own the whole Issue-Fix experience while scheduling +/// stays host-side. +async fn resolve_hidden_heartbeat_session( + coordinator: &ConversationCoordinator, + goal_id: &str, + repository_path: &Path, + jobs: Vec, +) -> Result { + let existing = matching_jobs(&job_name(goal_id), repository_path, jobs) + .into_iter() + .filter_map(|job| job.session_id().map(str::to_string)) + .find(|session_id| { + coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + }); + if let Some(session_id) = existing { + return Ok(session_id); + } + let config = SessionConfig { + enable_tools: true, + safe_mode: true, + auto_compact: true, + enable_context_compression: true, + ..Default::default() + }; + let session = coordinator + .create_hidden_subagent_session_with_workspace( + None, + HIDDEN_HOST_SESSION_NAME.to_string(), + HIDDEN_HOST_AGENT_KIND.to_string(), + config, + repository_path.display().to_string(), + Some("issue-fix".to_string()), + ) + .await + .map_err(|error| format!("Failed to create the hidden heartbeat session: {error}"))?; + Ok(session.session_id) +} + +/// Project the current goal's host loop, tolerating duplicates. +/// +/// Duplicate jobs (from a concurrent start racing the create) must not brick +/// every status call: project the most recently updated one and leave the +/// cleanup to the next start, which holds `HOST_LOOP_LOCK`. +async fn host_loop_state(goal_id: &str, repository_path: &Path) -> IssueFixHostLoopState { + let Some(cron) = get_global_cron_service() else { + return IssueFixHostLoopState::default(); + }; + let mut matching = matching_jobs(&job_name(goal_id), repository_path, cron.list_jobs().await); + if matching.len() > 1 { + warn!( + "Found {} continuous Issue-Fix host loops for goal {goal_id}; projecting the newest", + matching.len() + ); + matching.sort_by_key(|job| std::cmp::Reverse(job.updated_at_ms)); + } + matching + .first() + .map(project_host_loop) + .unwrap_or_default() +} + +/// Pick the canonical host-loop job for `start`, deleting duplicates and +/// disabling stale jobs left behind by an older goal identity. Callers must +/// hold `HOST_LOOP_LOCK`. +async fn resolve_host_loop_job( + name: &str, + repository_path: &Path, +) -> Result, String> { + let Some(cron) = get_global_cron_service() else { + return Err("Cron service is not initialized".to_string()); + }; + let mut canonical: Option = None; + for job in cron.list_jobs().await { + if !job.name.starts_with(JOB_NAME_PREFIX) || !job_targets_repository(&job, repository_path) + { + continue; + } + if job.name != name { + // A job from a previous goal identity (e.g. after re-bootstrap) + // would keep firing its stale prompt invisibly; park it. + if job.enabled { + warn!("Disabling stale continuous Issue-Fix host loop {}", job.name); + let _ = cron + .update_job( + &job.id, + UpdateCronJobRequest { + enabled: Some(false), + ..UpdateCronJobRequest::default() + }, + ) + .await; + } + continue; + } + match &canonical { + Some(kept) if kept.updated_at_ms >= job.updated_at_ms => { + warn!("Deleting duplicate continuous Issue-Fix host loop {}", job.id); + let _ = cron.delete_job(&job.id).await; + } + Some(kept) => { + warn!("Deleting duplicate continuous Issue-Fix host loop {}", kept.id); + let _ = cron.delete_job(&kept.id).await; + canonical = Some(job); + } + None => canonical = Some(job), + } + } + Ok(canonical) +} + +fn matching_jobs(name: &str, repository_path: &Path, jobs: Vec) -> Vec { + jobs.into_iter() + .filter(|job| job.name == name && job_targets_repository(job, repository_path)) + .collect() +} + +fn job_targets_repository(job: &CronJob, repository_path: &Path) -> bool { + same_path(&job.workspace().workspace_path, repository_path) + || job + .workspace() + .project_workspace_path + .as_deref() + .is_some_and(|path| same_path(path, repository_path)) +} + +fn same_path(candidate: &str, expected: &Path) -> bool { + let candidate = candidate.replace('/', "\\"); + let expected = expected.display().to_string().replace('/', "\\"); + let candidate = candidate.trim_end_matches('\\'); + let expected = expected.trim_end_matches('\\'); + // Case-insensitive comparison is a Windows filesystem property; on other + // platforms /repos/Foo and /repos/foo are distinct repositories. + #[cfg(windows)] + { + candidate.eq_ignore_ascii_case(expected) + } + #[cfg(not(windows))] + { + candidate == expected + } +} + +fn run_status_label(status: CronJobRunStatus) -> &'static str { + match status { + CronJobRunStatus::Queued => "queued", + CronJobRunStatus::Running => "running", + CronJobRunStatus::Ok => "ok", + CronJobRunStatus::Error => "error", + CronJobRunStatus::Cancelled => "cancelled", + } +} + +fn project_host_loop(job: &CronJob) -> IssueFixHostLoopState { + IssueFixHostLoopState { + enabled: job.enabled, + job_id: Some(job.id.clone()), + session_id: job.session_id().map(str::to_string), + active_turn_id: job.state.active_turn_id.clone(), + next_run_at_ms: job.state.next_run_at_ms, + last_run_status: job + .state + .last_run_status + .map(|status| run_status_label(status).to_string()), + last_error: job.state.last_error.clone(), + consecutive_failures: job.state.consecutive_failures, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_job_name_is_goal_scoped() { + assert_eq!(job_name("bitfun-goal"), "LoopX Issue Fix: bitfun-goal"); + } + + #[test] + fn windows_path_matching_is_case_and_separator_insensitive() { + assert!(same_path( + "C:/codeagent/BitFun/", + Path::new("c:\\codeagent\\bitfun") + )); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index f11a5ef845..2635144cd1 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod git_agent_api; pub mod git_api; pub mod i18n_api; pub mod insights_api; +pub mod issue_fix_api; pub mod lsp_api; pub mod lsp_workspace_api; pub mod mcp_api; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2d772df151..51857ccb83 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1624,10 +1624,35 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "review_platform_get_workspace_snapshot", RemoteWorkspacePolicy::RemoteRouted, ), + ( + "review_platform_list_issues", + RemoteWorkspacePolicy::RemoteRouted, + ), ( "review_platform_update_auth_token", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ( + "issue_fix_autonomous_status", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "issue_fix_autonomous_poll", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "issue_fix_answer_user_question", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ("issue_fix_probe", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "issue_fix_start_autonomous", + RemoteWorkspacePolicy::RemoteUnsupported, + ), + ( + "issue_fix_stop_autonomous", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("rollback_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("rollback_session", RemoteWorkspacePolicy::RemoteUnsupported), ("rollback_to_turn", RemoteWorkspacePolicy::RemoteUnsupported), diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs index db2803be54..471be92565 100644 --- a/src/apps/desktop/src/api/review_platform_api.rs +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -3,7 +3,8 @@ use crate::api::app_state::AppState; use bitfun_core::service::review_platform::{ ReviewPlatformCiLog, ReviewPlatformDetailSection, ReviewPlatformError, - ReviewPlatformIssueEvidence, ReviewPlatformKind, ReviewPlatformPullRequestDetail, + ReviewPlatformIssueEvidence, ReviewPlatformIssuePage, ReviewPlatformIssueState, + ReviewPlatformKind, ReviewPlatformListIssuesRequest, ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformPullRequestReviewTarget, ReviewPlatformService, ReviewPlatformWorkspaceSnapshot, }; @@ -186,6 +187,35 @@ pub async fn review_platform_get_issue( }) } +/// Enumerate a repository's issues. +/// +/// Returns summary rows only; the caller fetches full evidence per issue when it +/// needs a body and comments. +#[tauri::command] +pub async fn review_platform_list_issues( + _state: State<'_, AppState>, + request: ReviewPlatformListIssuesDto, +) -> Result { + ReviewPlatformService::list_issues(ReviewPlatformListIssuesRequest { + platform: request.platform, + host: &request.host, + project_path: &request.project_path, + state: request.state.unwrap_or_default(), + page: request.page, + per_page: request.per_page, + repository_path: request.repository_path.as_deref(), + }) + .await + .map_err(|error| { + let safe_error = safe_review_platform_error(&error); + error!( + "Failed to list review platform Issues: platform={:?}, host={}, project_path={}, error={}", + request.platform, request.host, request.project_path, safe_error + ); + format!("Failed to list provider Issues: {safe_error}") + }) +} + #[tauri::command] pub async fn review_platform_get_pull_request_review_target_by_identity( _state: State<'_, AppState>, @@ -336,6 +366,21 @@ pub struct ReviewPlatformIssueRequest { pub repository_path: Option, } +/// Owned mirror of `ReviewPlatformListIssuesRequest`, which borrows its strings +/// and so cannot be deserialized directly. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformListIssuesDto { + pub platform: ReviewPlatformKind, + pub host: String, + pub project_path: String, + /// Defaults to open issues. + pub state: Option, + pub page: Option, + pub per_page: Option, + pub repository_path: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformPullRequestIdentityRequest { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index efb6381dcb..541360bc1a 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -70,6 +70,7 @@ use api::external_sources_api::*; use api::git_agent_api::*; use api::git_api::*; use api::i18n_api::*; +use api::issue_fix_api::*; use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; @@ -337,6 +338,123 @@ fn has_standard_main_window_size(width: f64, height: f64) -> bool { width >= MAIN_WINDOW_MIN_WIDTH && height >= MAIN_WINDOW_MIN_HEIGHT } +/// Re-clamp the undecorated main window to the monitor work area. +/// +/// Two ways the borderless window ends up hiding its bottom edge (nav footer, +/// notification bell, composer strip) behind the taskbar: +/// +/// - Maximized to the FULL monitor instead of the work area: tao clamps this +/// in `WM_NCCALCSIZE`, but the clamp misses when a drag-region double-click +/// maximize races a DPI change or the maximize comes from an external +/// `ShowWindow(SW_MAXIMIZE)`. +/// - Restored to oversized saved geometry: once the bug above happened, the +/// window-state plugin persisted the taskbar-covering size, so every later +/// restore reproduces it without being maximized at all. +/// +/// Watching Resized events covers both: whenever the window is taller than +/// the monitor work area, push it back inside. +#[cfg(target_os = "windows")] +fn clamp_maximized_undecorated_window(window: &tauri::Window) { + use windows::Win32::Foundation::{HWND, RECT}; + use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + GetClientRect, GetWindowRect, SetWindowPos, HWND_TOP, SWP_FRAMECHANGED, SWP_NOACTIVATE, + SWP_NOZORDER, + }; + + let Ok(hwnd) = window.hwnd() else { + return; + }; + let hwnd = HWND(hwnd.0); + + unsafe { + let mut monitor_info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if !GetMonitorInfoW(monitor, &mut monitor_info).as_bool() { + return; + } + let work = monitor_info.rcWork; + let work_height = work.bottom - work.top; + let work_width = work.right - work.left; + + if window.is_maximized().unwrap_or(false) { + let mut client = RECT::default(); + if GetClientRect(hwnd, &mut client).is_err() { + return; + } + // In the healthy path tao has already clamped the client rect to + // the work area (± the 1px auto-hide margin). Anything taller is + // overlapping the taskbar; push it back to the work area. + if client.bottom - client.top <= work_height + 2 + && client.right - client.left <= work_width + 2 + { + return; + } + log::info!( + "Re-clamping maximized undecorated window to the work area: client={}x{}, work={}x{}", + client.right - client.left, + client.bottom - client.top, + work_width, + work_height + ); + if let Err(error) = SetWindowPos( + hwnd, + Some(HWND_TOP), + work.left, + work.top, + work_width, + work_height, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ) { + log::warn!("Failed to re-clamp the maximized window: {error}"); + } + return; + } + + // Restored window taller/wider than the work area: shrink it to fit + // and keep it inside. A window that big cannot be fully visible + // anyway, so resizing it cannot fight legitimate user placement. + let mut frame = RECT::default(); + if GetWindowRect(hwnd, &mut frame).is_err() { + return; + } + let frame_height = frame.bottom - frame.top; + let frame_width = frame.right - frame.left; + if frame_height <= work_height && frame_width <= work_width { + return; + } + let new_height = frame_height.min(work_height); + let new_width = frame_width.min(work_width); + let new_top = frame.top.clamp(work.top, work.bottom - new_height); + let new_left = frame.left.clamp(work.left, work.right - new_width); + log::info!( + "Shrinking oversized restored window into the work area: frame={}x{} at ({}, {}), work={}x{}", + frame_width, + frame_height, + frame.left, + frame.top, + work_width, + work_height + ); + if let Err(error) = SetWindowPos( + hwnd, + Some(HWND_TOP), + new_left, + new_top, + new_width, + new_height, + SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ) { + log::warn!("Failed to shrink the oversized restored window: {error}"); + } + } +} + pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { if let Err(error) = window.restore_state(main_window_state_flags()) { log::warn!("Failed to restore main window state: {}", error); @@ -813,6 +931,48 @@ pub async fn run() { ); } + // Resolve the bundled LoopX sidecar for continuous Issue-Fix, + // mirroring the flashgrep resolution above. LOOPX_BIN set by the + // user (e.g. a development editable install) always wins; the + // sidecar fills the gap for packaged builds; PATH stays the last + // resort inside LoopxIssueFix::probe. + { + let step_started = Instant::now(); + let sidecar_path = bitfun_services_integrations::loopx_issue_fix::loopx_sidecar_binary_names() + .iter() + .find_map(|binary_name| { + let primary = format!("loopx/{}", binary_name); + if let Ok(path) = app + .path() + .resolve(&primary, tauri::path::BaseDirectory::Resource) + { + if path.exists() { + return Some(path); + } + } + let resource_dir = app.path().resource_dir().ok()?; + [ + resource_dir.join("loopx").join(binary_name), + resource_dir.join("resources").join("loopx").join(binary_name), + ] + .into_iter() + .find(|candidate| candidate.exists()) + }); + if let Some(path) = sidecar_path { + bitfun_services_integrations::loopx_issue_fix::configure_loopx_bin_env(&path); + log::info!("LoopX sidecar resolved: path={}", path.display()); + } else { + log::info!( + "No bundled LoopX sidecar found; Issue-Fix will use LOOPX_BIN or PATH" + ); + } + startup_trace.record_elapsed_step( + "native_setup", + "resolve_loopx_sidecar", + step_started, + ); + } + // Register bundled mobile-web resource path for remote connect. // tauri.conf.json maps "../../mobile-web/dist" -> "mobile-web/dist", // so the primary candidate is "mobile-web/dist". Additional fallbacks @@ -1115,6 +1275,13 @@ pub async fn run() { } } } + + #[cfg(target_os = "windows")] + if window.label() == "main" + && matches!(event, tauri::WindowEvent::Resized(_)) + { + clamp_maximized_undecorated_window(window); + } } }) .invoke_handler(tauri::generate_handler![ @@ -1333,6 +1500,13 @@ pub async fn run() { review_platform_get_pull_request_detail, review_platform_get_pull_request_review_target, review_platform_get_issue, + review_platform_list_issues, + issue_fix_probe, + issue_fix_autonomous_status, + issue_fix_autonomous_poll, + issue_fix_answer_user_question, + issue_fix_start_autonomous, + issue_fix_stop_autonomous, review_platform_get_pull_request_review_target_by_identity, review_platform_get_pull_request_detail_page, review_platform_get_pull_request_ci_log, diff --git a/src/crates/assembly/core/src/miniapp/builtin/mod.rs b/src/crates/assembly/core/src/miniapp/builtin/mod.rs index 4696ab466e..fe40092ffb 100644 --- a/src/crates/assembly/core/src/miniapp/builtin/mod.rs +++ b/src/crates/assembly/core/src/miniapp/builtin/mod.rs @@ -23,7 +23,7 @@ use chrono::Utc; use std::path::Path; use std::sync::Arc; -const RETIRED_BUILTIN_APP_IDS: &[&str] = &["builtin-pr-review"]; +const RETIRED_BUILTIN_APP_IDS: &[&str] = &["builtin-pr-review", "builtin-loopx-console"]; /// Seed all built-in MiniApps into the user data directory. Idempotent: skips apps /// whose on-disk marker hash matches the bundled content. User's `storage.json` @@ -460,16 +460,19 @@ mod tests { } #[tokio::test] - async fn builtin_seed_retires_the_removed_pr_review_bundle_only_when_marked_builtin() { + async fn builtin_seed_retires_removed_bundles_only_when_marked_builtin() { let manager = test_manager(); - let app_dir = manager.path_manager().miniapp_dir("builtin-pr-review"); - tokio::fs::create_dir_all(&app_dir).await.unwrap(); - write_outdated_builtin_marker(&app_dir).await; + for app_id in RETIRED_BUILTIN_APP_IDS { + let app_dir = manager.path_manager().miniapp_dir(app_id); + tokio::fs::create_dir_all(&app_dir).await.unwrap(); + write_outdated_builtin_marker(&app_dir).await; - seed_builtin_miniapps(&manager).await.unwrap(); + seed_builtin_miniapps(&manager).await.unwrap(); - assert!(!app_dir.exists()); + assert!(!app_dir.exists()); + } + let app_dir = manager.path_manager().miniapp_dir("builtin-loopx-console"); tokio::fs::create_dir_all(&app_dir).await.unwrap(); tokio::fs::write(app_dir.join("meta.json"), "{}") .await diff --git a/src/crates/assembly/core/src/service/review_platform/mod.rs b/src/crates/assembly/core/src/service/review_platform/mod.rs index 7ad3327a52..f79b48f7ca 100644 --- a/src/crates/assembly/core/src/service/review_platform/mod.rs +++ b/src/crates/assembly/core/src/service/review_platform/mod.rs @@ -15,9 +15,10 @@ pub use bitfun_services_integrations::review_platform::{ ReviewPlatformCapabilities, ReviewPlatformCiItem, ReviewPlatformCiLog, ReviewPlatformCommit, ReviewPlatformCreatePullRequestRequest, ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformFile, ReviewPlatformIssueComment, ReviewPlatformIssueEvidence, - ReviewPlatformKind, ReviewPlatformPullRequest, ReviewPlatformPullRequestDetail, - ReviewPlatformPullRequestDetailPage, ReviewPlatformPullRequestFileDiff, - ReviewPlatformPullRequestReviewTarget, ReviewPlatformRemote, + ReviewPlatformIssuePage, ReviewPlatformIssueState, ReviewPlatformIssueSummary, + ReviewPlatformKind, ReviewPlatformListIssuesRequest, ReviewPlatformPullRequest, + ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, + ReviewPlatformPullRequestFileDiff, ReviewPlatformPullRequestReviewTarget, ReviewPlatformRemote, ReviewPlatformReplyToThreadRequest, ReviewPlatformRepositoryRef, ReviewPlatformRequestChangesRequest, ReviewPlatformResolveThreadRequest, ReviewPlatformSubmitReviewRequest, ReviewPlatformThread, ReviewPlatformThreadKind, @@ -173,6 +174,12 @@ impl ReviewPlatformService { .await } + pub async fn list_issues( + request: ReviewPlatformListIssuesRequest<'_>, + ) -> Result { + owner_service()?.list_issues(request).await + } + pub async fn pull_request_review_target_by_identity( platform: ReviewPlatformKind, host: &str, diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index d7e153b31e..51021854c6 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -1863,7 +1863,21 @@ fn path_is_within_root(path: &Path, root: &Path) -> bool { } fn path_string(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + display_path_string(path.to_string_lossy().as_ref()) +} + +fn display_path_string(value: &str) -> String { + let normalized = value.replace('\\', "/"); + #[cfg(windows)] + { + if let Some(rest) = normalized.strip_prefix("//?/UNC/") { + return format!("//{rest}"); + } + if let Some(rest) = normalized.strip_prefix("//?/") { + return rest.to_string(); + } + } + normalized } fn current_unix_ms() -> u64 { @@ -1995,6 +2009,8 @@ fn map_git_error(git_error: GitError) -> WorktreeError { #[cfg(test)] mod tests { + #[cfg(windows)] + use super::display_path_string; use super::{ automatic_delete_candidate_ids, managed_target_path, managed_worktree_directory_name, path_is_within_root, repository_id, resolve_managed_root, sanitize_worktree_project_label, @@ -2039,6 +2055,19 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn path_string_strips_extended_windows_prefix_for_ui_contracts() { + assert_eq!( + display_path_string(r"\\?\C:\Users\huawei\.bitfun\worktrees\repo"), + "C:/Users/huawei/.bitfun/worktrees/repo" + ); + assert_eq!( + display_path_string(r"\\?\UNC\server\share\repo"), + "//server/share/repo" + ); + } + #[test] fn managed_directory_name_includes_project_name_and_short_worktree_id() { let project = Path::new("projects").join("BitFun"); diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs index d7088beeb4..9e8f0a831c 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -5,7 +5,7 @@ use serde_json; /// Build the Runtime Adapter script (JS) to inject into the iframe. /// Exposes window.app with call(), fs.*, shell.*, net.*, os.*, storage.*, dialog.*, -/// ai.*, agent.*, deck.*, chat.*, clipboard.*, lifecycle, events. +/// ai.*, agent.*, issueFix.*, cron.*, deck.*, chat.*, clipboard.*, lifecycle, events. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, @@ -131,6 +131,31 @@ pub fn build_bridge_script( offEvent: (fn) => app.off('agent:event', fn), }}, + // Continuous Issue-Fix namespace — typed control surface over the host's + // native issue_fix_* commands (LoopX CLI bridge, sidecar, bootstrap, + // heartbeat). Requires BOTH permissions.agent.enabled and + // permissions.cron.enabled; enforced host-side. Scheduling and kernel + // state stay host-owned: the repair loop keeps running when the MiniApp + // is closed. `start` always hosts the heartbeat in a hidden session. + issueFix: {{ + probe: () => _rpc('issueFix.probe', {{}}), + listIssues: (opts) => _rpc('issueFix.listIssues', opts || {{}}), + status: (opts) => _rpc('issueFix.status', opts || {{}}), + poll: (opts) => _rpc('issueFix.poll', opts || {{}}), + start: (opts) => _rpc('issueFix.start', opts || {{}}), + stop: (opts) => _rpc('issueFix.stop', opts || {{}}), + answer: (opts) => _rpc('issueFix.answer', opts || {{}}), + }}, + + // Cron namespace — inspect/manage host scheduled jobs this MiniApp relies + // on (e.g. the Issue-Fix heartbeat). Requires manifest + // permissions.cron.enabled = true; enforced host-side. The host reuses the + // existing cron Tauri commands; this bridge only routes the call. + cron: {{ + listJobs: (opts) => _rpc('cron.listJobs', opts || {{}}), + updateJob: (jobId, changes) => _rpc('cron.updateJob', {{ jobId, changes }}), + }}, + // Deck namespace — renders one slide HTML page in a hidden host WebView // and returns base64 PNG/PDF. Used by presentation MiniApps for // page-by-page export rasterization. diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index cc7d09437a..949e85dafd 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -149,6 +149,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/coding-selfie/worker.js"), esm_dependencies_json: "[]", }, + BuiltinMiniAppBundle { + id: "builtin-loopx-issue-fix", + version: 1, + meta_json: include_str!("builtin/assets/loopx-issue-fix/meta.json"), + html: include_str!("builtin/assets/loopx-issue-fix/index.html"), + css: include_str!("builtin/assets/loopx-issue-fix/style.css"), + ui_js: include_str!("builtin/assets/loopx-issue-fix/ui.js"), + worker_js: include_str!("builtin/assets/loopx-issue-fix/worker.js"), + esm_dependencies_json: "[]", + }, BuiltinMiniAppBundle { id: "builtin-ppt-live", version: 258, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/index.html new file mode 100644 index 0000000000..343960c9b3 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/index.html @@ -0,0 +1,84 @@ + + + + + + Issue 自动修复 + + +
+
+
+ +
+

Issue 自动修复

+

+ -- + + LoopX +

+
+
+
+ -- + + + +
+
+ + + + + + + +
+
+
+

Open Issues

+ +
+ +
    +
    +
    +
    +

    运行记录

    + -- +
    +
    尚无记录。启动持续修复后,每次心跳的产出会显示在这里。
    +
    +
    +
    + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/meta.json new file mode 100644 index 0000000000..c2a618e008 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/meta.json @@ -0,0 +1,30 @@ +{ + "id": "builtin-loopx-issue-fix", + "name": "Issue 自动修复", + "description": "勾选仓库的 Open Issues,由 LoopX 状态内核驱动持续修复:隔离 worktree 打补丁、验证、发 PR 并跟踪到合并,全程在本应用内完成。", + "icon": "Wrench", + "category": "developer", + "tags": [ + "LoopX", + "GitHub Issues", + "自动修复", + "Agent", + "内置" + ], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "agent": { + "enabled": true, + "rate_limit_per_minute": 6 + }, + "cron": { + "enabled": true + }, + "node": { + "enabled": false + } + }, + "ai_context": null +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/style.css new file mode 100644 index 0000000000..1807e400a5 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/style.css @@ -0,0 +1,200 @@ +:root { + color-scheme: light dark; + --bg: var(--miniapp-bg, #f7f7f8); + --panel: var(--miniapp-panel, #ffffff); + --border: var(--miniapp-border, rgba(120, 120, 130, 0.22)); + --text: var(--miniapp-text, #1f2328); + --muted: var(--miniapp-muted, #6b7280); + --primary: var(--miniapp-primary, #3b82f6); + --warning: #d97706; + --danger: #dc2626; + --success: #16a34a; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 12px; + color: var(--text); + background: var(--bg); +} + +.app-shell { display: flex; flex-direction: column; height: 100vh; } + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.brand { display: flex; align-items: center; gap: 10px; min-width: 0; } +.brand-mark { width: 28px; height: 28px; color: var(--primary); } +.brand-mark svg { width: 100%; height: 100%; } +.brand-copy h1 { margin: 0; font-size: 14px; } +.brand-meta { margin: 2px 0 0; color: var(--muted); display: flex; gap: 6px; align-items: center; } +.brand-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; } + +.toolbar { display: flex; align-items: center; gap: 8px; } +.toolbar-status { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); } +.status-dot.is-idle { background: var(--success); } +.status-dot.is-active { background: var(--primary); animation: pulse 1.2s ease-in-out infinite; } +@keyframes pulse { 50% { opacity: 0.4; } } + +button { font: inherit; cursor: pointer; } +button:disabled { opacity: 0.5; cursor: default; } +.primary-button { + padding: 5px 14px; + border: none; + border-radius: 6px; + background: var(--primary); + color: #fff; +} +.secondary-button { + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text); +} +.icon-button { + width: 28px; height: 28px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); border-radius: 6px; + background: transparent; color: var(--muted); +} +.icon-button svg { width: 15px; height: 15px; } +.text-button { border: none; background: none; color: var(--primary); } + +.notice { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 7px 14px; + background: color-mix(in srgb, var(--warning) 12%, transparent); + color: var(--warning); + border-bottom: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); +} + +.gate-card { + margin: 10px 14px 0; + border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--warning) 6%, var(--panel)); + padding: 10px 12px; +} +.gate-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; } +.gate-badge { + flex: none; + padding: 1px 8px; + border-radius: 999px; + background: var(--warning); + color: #fff; + font-size: 11px; +} +.gate-prompt { font-weight: 600; overflow-wrap: anywhere; } +.gate-options { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; } +.gate-option { + display: flex; align-items: baseline; gap: 8px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} +.gate-option:has(input:checked) { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 8%, transparent); } +.gate-option span { font-weight: 600; } +.gate-option em { color: var(--muted); font-style: normal; margin-left: auto; } +.gate-footer { display: flex; gap: 8px; } +.gate-footer input { + flex: 1; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); + font: inherit; +} + +.pending-card { + margin: 10px 14px 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + padding: 8px 12px; + max-height: 180px; + overflow-y: auto; +} +.pending-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 5px; } +.pending-list li { display: flex; align-items: baseline; gap: 8px; } +.pending-badge { + flex: none; + padding: 0 6px; + border-radius: 4px; + font-size: 10px; + color: #fff; +} +.pending-badge.is-gate { background: var(--warning); } +.pending-badge.is-action { background: var(--primary); } +.pending-text { flex: 1; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.pending-link { flex: none; color: var(--primary); text-decoration: none; } + +.console-grid { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 0; + flex: 1; + min-height: 0; + margin-top: 10px; + border-top: 1px solid var(--border); +} +.queue-pane, .activity-pane { + display: flex; flex-direction: column; min-height: 0; + background: var(--panel); +} +.queue-pane { border-right: 1px solid var(--border); } +.pane-header { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.pane-header h2 { margin: 0; font-size: 12px; } +.count-badge { + padding: 0 7px; + border-radius: 999px; + background: var(--border); + font-variant-numeric: tabular-nums; +} +.select-all { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); cursor: pointer; } + +.issue-list { list-style: none; margin: 0; padding: 4px 0; overflow-y: auto; min-height: 0; flex: 1; } +.issue-row { + display: flex; align-items: center; gap: 8px; + padding: 4px 12px; +} +.issue-row:hover { background: color-mix(in srgb, var(--primary) 5%, transparent); } +.issue-num { color: var(--muted); font-variant-numeric: tabular-nums; } +.issue-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.issue-state { flex: none; color: var(--muted); } +.issue-row.is-fixing .issue-state { color: var(--primary); } +.issue-row.is-blocked .issue-state { color: var(--warning); } +.issue-row.is-done { color: var(--muted); } +.issue-row.is-done .issue-state { color: var(--success); } + +.empty-note { padding: 20px 14px; color: var(--muted); text-align: center; } + +.activity-log { + flex: 1; + margin: 0; + padding: 10px 12px; + overflow-y: auto; + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + color: var(--muted); +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/ui.js new file mode 100644 index 0000000000..7281b23cef --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/ui.js @@ -0,0 +1,381 @@ +/** + * LoopX Issue-Fix MiniApp. + * + * Control surface over the host's native issue_fix_* bridge (window.app.issueFix). + * The host owns scheduling (hidden heartbeat session + cron), the LoopX kernel + * owns all repair state; this UI is a read-only projection plus typed commands, + * mirroring the interaction rules refined in the native panel: + * - polls only the zero-write endpoint (issueFix.poll) on an interval; + * - a monotonic ticket guards every state apply so a slow response can never + * resurrect an answered gate; + * - polling pauses while a mutation is in flight. + */ +'use strict'; + +const POLL_INTERVAL_MS = 30_000; + +const state = { + repositoryPath: '', + supported: null, // null=probing, false=non-GitHub/none, true=ok + host: null, + projectPath: null, + readiness: null, + issues: [], + control: null, + selected: new Set(), + ticket: 0, + appliedTicket: 0, + mutationDepth: 0, + starting: false, + stopping: false, + answering: false, + activity: [], +}; + +const $ = (id) => document.getElementById(id); + +// ── Bridge helpers ──────────────────────────────────────────────────────── + +function takeTicket() { + return ++state.ticket; +} + +function applyControl(ticket, control) { + if (ticket < state.appliedTicket) return false; + state.appliedTicket = ticket; + state.control = control; + return true; +} + +async function refreshStatus() { + const ticket = takeTicket(); + try { + const status = await window.app.issueFix.status({}); + if (applyControl(ticket, status)) render(); + } catch (error) { + showNotice(String(error?.message || error)); + } +} + +async function pollLight() { + if (document.hidden || state.mutationDepth > 0) return; + const ticket = takeTicket(); + try { + const poll = await window.app.issueFix.poll({}); + if (!poll || !state.control) return; + if ( + applyControl(ticket, { + ...state.control, + actionRequired: poll.actionRequired, + gatePrompt: poll.userQuestion ? poll.userQuestion.prompt : null, + userQuestion: poll.userQuestion || null, + issues: (poll.issues || []).map((issue) => ({ + ...issue, + selected: issue.todoId === state.control.selectedTodoId, + })), + userTodos: poll.userTodos || [], + hostLoop: poll.hostLoop, + }) + ) { + render(); + } + } catch { + // Polling is best-effort; the next tick retries. + } +} + +// ── Actions ─────────────────────────────────────────────────────────────── + +async function handleStart() { + if (state.starting || !state.projectPath || state.selected.size === 0) return; + state.starting = true; + state.mutationDepth += 1; + render(); + const ticket = takeTicket(); + try { + const selectedIssues = state.issues + .filter((issue) => state.selected.has(issue.issueId)) + .map((issue) => ({ issueRef: issue.issueId, issueUrl: issue.webUrl })); + const started = await window.app.issueFix.start({ + repo: state.projectPath, + issues: selectedIssues, + }); + if (applyControl(ticket, started)) { + state.selected.clear(); + pushActivity(`已启动:${started.addedIssueRefs?.length ?? 0} 个 issue 进入修复队列`); + } + } catch (error) { + showNotice(String(error?.message || error)); + } finally { + state.mutationDepth -= 1; + state.starting = false; + render(); + } +} + +async function handleStop() { + if (state.stopping) return; + state.stopping = true; + state.mutationDepth += 1; + render(); + const ticket = takeTicket(); + try { + const hostLoop = await window.app.issueFix.stop({}); + if (state.control && applyControl(ticket, { ...state.control, hostLoop })) { + pushActivity('已停止心跳调度(修复进度保留,可随时重新启动)'); + } + } catch (error) { + showNotice(String(error?.message || error)); + } finally { + state.mutationDepth -= 1; + state.stopping = false; + render(); + } +} + +async function handleGateSubmit() { + const question = state.control?.userQuestion; + const decision = document.querySelector('input[name="gate-decision"]:checked')?.value; + if (!question || !decision || state.answering) return; + state.answering = true; + state.mutationDepth += 1; + render(); + const ticket = takeTicket(); + try { + const answered = await window.app.issueFix.answer({ + todoId: question.todoId, + decision, + reason: $('gate-reason').value.trim() || null, + }); + if (applyControl(ticket, answered)) { + $('gate-reason').value = ''; + pushActivity(`已提交决定(${decision}):${question.prompt.slice(0, 80)}`); + } + } catch (error) { + showNotice(String(error?.message || error)); + // The gate may already be closed kernel-side; re-project truth. + void refreshStatus(); + } finally { + state.mutationDepth -= 1; + state.answering = false; + render(); + } +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +function showNotice(text) { + $('notice-text').textContent = text; + $('notice').hidden = false; +} + +function pushActivity(line) { + const stamp = new Date().toLocaleTimeString(); + state.activity.unshift(`[${stamp}] ${line}`); + state.activity = state.activity.slice(0, 200); +} + +function rowState(issue, kernelTodo) { + if (!kernelTodo) return state.selected.has(issue.issueId) ? 'queued' : 'idle'; + if (kernelTodo.status === 'done') return 'done'; + if (kernelTodo.status === 'blocked') return 'blocked'; + if (kernelTodo.selected) { + if (state.control?.actionRequired) return 'blocked'; + if (state.control?.hostLoop?.enabled) return 'fixing'; + } + return 'queued'; +} + +const ROW_LABEL = { + idle: '', + queued: '排队中', + fixing: '修复中', + done: '已处理', + blocked: '待确认', +}; + +function render() { + const control = state.control; + const loop = control?.hostLoop; + + $('repo-label').textContent = state.projectPath || '--'; + $('kernel-label').textContent = control ? `${control.goalId} · ${control.kernelState}` : 'LoopX'; + + const dot = $('loop-dot'); + const status = $('loop-status'); + if (loop?.enabled) { + dot.className = loop.activeTurnId ? 'status-dot is-active' : 'status-dot is-idle'; + status.textContent = loop.activeTurnId ? '心跳执行中' : '持续修复运行中'; + } else { + dot.className = 'status-dot'; + status.textContent = control ? '未运行' : '未连接'; + } + + $('stop-button').hidden = !loop?.enabled; + $('stop-button').disabled = state.stopping || state.starting; + $('stop-button').textContent = state.stopping ? '停止中…' : '停止'; + const startDisabled = + state.starting || state.stopping || state.supported !== true || state.selected.size === 0 + || Boolean(control?.actionRequired); + $('start-button').disabled = startDisabled; + $('start-button').textContent = state.starting ? '启动中…' : '启动持续修复'; + + // Gate card: reserved for genuinely blocking decisions, same as the panel. + const question = control?.userQuestion; + $('gate-card').hidden = !question; + if (question) { + $('gate-prompt').textContent = question.prompt; + $('gate-submit').disabled = + state.answering || !document.querySelector('input[name="gate-decision"]:checked'); + $('gate-submit').textContent = state.answering ? '提交中…' : '提交决定'; + } + + // Pending user-lane todos (merge reminders etc.) — ambient list, no actions. + const todos = control?.userTodos || []; + $('pending-card').hidden = todos.length === 0; + $('pending-count').textContent = String(todos.length); + const pendingList = $('pending-list'); + pendingList.replaceChildren( + ...todos.map((todo) => { + const li = document.createElement('li'); + const badge = document.createElement('span'); + badge.className = `pending-badge ${todo.taskClass === 'user_gate' ? 'is-gate' : 'is-action'}`; + badge.textContent = todo.taskClass === 'user_gate' ? '待审' : '待办'; + const text = document.createElement('span'); + text.className = 'pending-text'; + text.textContent = todo.link ? todo.text.split(todo.link).join('').replace(/\(\s*\)/g, '').trim() : todo.text; + li.append(badge, text); + if (todo.link) { + const link = document.createElement('a'); + link.href = todo.link; + link.textContent = '打开'; + link.className = 'pending-link'; + link.addEventListener('click', (event) => { + event.preventDefault(); + void window.app.call('host.openExternal', { url: todo.link }).catch(() => {}); + }); + li.append(link); + } + return li; + }), + ); + + // Issue list. + const kernelByRef = new Map((control?.issues || []).map((todo) => [todo.issueRef, todo])); + const list = $('issue-list'); + const empty = $('issues-empty'); + if (state.supported === false) { + empty.hidden = false; + empty.textContent = state.host + ? `当前仓库托管在 ${state.host}。持续修复目前仅支持 GitHub 仓库。` + : '打开一个带 GitHub 远程仓库的工作区,即可持续修复其 Issue。'; + list.replaceChildren(); + } else if (state.issues.length === 0) { + empty.hidden = false; + empty.textContent = state.supported === null ? '正在加载…' : '没有开放的 Issue。'; + list.replaceChildren(); + } else { + empty.hidden = true; + list.replaceChildren( + ...state.issues.map((issue) => { + const kernelTodo = kernelByRef.get(issue.issueId); + const rs = rowState(issue, kernelTodo); + const li = document.createElement('li'); + li.className = `issue-row is-${rs}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = Boolean(kernelTodo) || state.selected.has(issue.issueId); + checkbox.disabled = Boolean(kernelTodo); + checkbox.addEventListener('change', () => { + if (checkbox.checked) state.selected.add(issue.issueId); + else state.selected.delete(issue.issueId); + render(); + }); + const num = document.createElement('span'); + num.className = 'issue-num'; + num.textContent = `#${issue.number}`; + const title = document.createElement('span'); + title.className = 'issue-title'; + title.textContent = issue.title; + title.title = issue.title; + li.append(checkbox, num, title); + if (ROW_LABEL[rs]) { + const tag = document.createElement('span'); + tag.className = 'issue-state'; + tag.textContent = ROW_LABEL[rs]; + li.append(tag); + } + return li; + }), + ); + } + $('selected-count').textContent = `已选 ${state.selected.size}`; + const selectable = state.issues.filter((issue) => !kernelByRef.has(issue.issueId)); + $('select-all').checked = + selectable.length > 0 && selectable.every((issue) => state.selected.has(issue.issueId)); + + $('activity-meta').textContent = loop?.lastRunStatus + ? `上次心跳:${loop.lastRunStatus}` + : '--'; + if (state.activity.length > 0) { + $('activity-log').textContent = state.activity.join('\n'); + } + if (loop?.lastError) { + showNotice(`心跳执行异常:${loop.lastError}`); + } +} + +// ── Boot ────────────────────────────────────────────────────────────────── + +async function boot() { + $('refresh-button').addEventListener('click', () => void reload()); + $('start-button').addEventListener('click', () => void handleStart()); + $('stop-button').addEventListener('click', () => void handleStop()); + $('gate-submit').addEventListener('click', () => void handleGateSubmit()); + $('notice-dismiss').addEventListener('click', () => { $('notice').hidden = true; }); + document.addEventListener('change', (event) => { + if (event.target?.name === 'gate-decision') render(); + }); + $('select-all').addEventListener('change', () => { + const kernelByRef = new Set((state.control?.issues || []).map((todo) => todo.issueRef)); + const selectable = state.issues.filter((issue) => !kernelByRef.has(issue.issueId)); + if ($('select-all').checked) selectable.forEach((issue) => state.selected.add(issue.issueId)); + else selectable.forEach((issue) => state.selected.delete(issue.issueId)); + render(); + }); + + await reload(); + setInterval(() => void pollLight(), POLL_INTERVAL_MS); +} + +async function reload() { + try { + const readiness = await window.app.issueFix.probe(); + state.readiness = readiness; + if (!readiness.available) { + state.supported = false; + showNotice('LoopX 引擎不可用。重新安装 BitFun 可恢复内置引擎。'); + render(); + return; + } + if (readiness.ghInstalled === false) { + showNotice('未安装 GitHub CLI(gh)。请从 https://cli.github.com 安装后重启应用。'); + } else if (readiness.ghAuthenticated === false) { + showNotice('GitHub CLI 未登录。请在终端运行 gh auth login 后刷新。'); + } + const listing = await window.app.issueFix.listIssues({}); + state.supported = listing.supported; + state.host = listing.host; + state.projectPath = listing.projectPath; + state.issues = listing.issues || []; + // Drop stale selections for issues that vanished from the refreshed list. + const known = new Set(state.issues.map((issue) => issue.issueId)); + state.selected = new Set([...state.selected].filter((id) => known.has(id))); + await refreshStatus(); + } catch (error) { + showNotice(String(error?.message || error)); + } + render(); +} + +boot(); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/worker.js new file mode 100644 index 0000000000..78f89eaa81 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-issue-fix/worker.js @@ -0,0 +1,3 @@ +// Built-in MiniApp: LoopX Issue-Fix — no node-side logic; all calls go through +// the host bridge (issueFix.* / cron.*), storage handled by the runtime host. +module.exports = {}; diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index db0d378e18..9c859f1f6a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -53,11 +53,27 @@ pub struct MiniAppPermissions { #[serde(skip_serializing_if = "Option::is_none")] pub agent: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub cron: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub notifications: Option, #[serde(skip_serializing_if = "Option::is_none")] pub host: Option, } +/// Scheduled-job (cron) permissions for MiniApps. +/// +/// Grants the MiniApp the ability to inspect and manage host scheduled jobs +/// through the host cron bridge, so an agentic MiniApp can rely on a +/// host-owned recurring heartbeat (e.g. the continuous Issue-Fix loop) +/// without leaving the iframe. Gated by manifest `permissions.cron.enabled`; +/// enforced host-side by reusing the existing cron Tauri commands. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CronPermissions { + /// Whether scheduled-job access is enabled for this MiniApp. + #[serde(default)] + pub enabled: bool, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct FsPermissions { /// Path scopes: "{appdata}", "{workspace}", "{home}", or absolute paths. diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index a8f027e4f0..7ce608bbe9 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -135,6 +135,17 @@ git = [ "tokio/time", ] file-watch = ["notify", "tokio/rt", "tokio/sync"] +# Automatic repository issue fixing driven by the external `loopx` CLI. +# Included in `product-full`. +loopx-issue-fix = [ + "async-trait", + "bitfun-runtime-ports", + "review-platform", + "thiserror", + "tokio/macros", + "tokio/process", + "which", +] function-agents = [ "bitfun-product-domains/function-agents", "dep:bitfun-product-domains", @@ -387,6 +398,7 @@ product-full = [ "function-agents", "git", "hook-import", + "loopx-issue-fix", "miniapp-runtime", "mcp", "plugin-source", @@ -428,6 +440,10 @@ required-features = ["function-agents"] name = "git_contracts" required-features = ["git"] +[[test]] +name = "loopx_issue_fix_contracts" +required-features = ["loopx-issue-fix"] + [[test]] name = "mcp_contracts" required-features = ["mcp"] diff --git a/src/crates/services/services-integrations/src/git/managed_worktree.rs b/src/crates/services/services-integrations/src/git/managed_worktree.rs index fbe33b1df7..b4ab4f977c 100644 --- a/src/crates/services/services-integrations/src/git/managed_worktree.rs +++ b/src/crates/services/services-integrations/src/git/managed_worktree.rs @@ -10,7 +10,21 @@ use tokio::io::AsyncWriteExt; use tokio::task; fn normalized_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + normalize_git_cli_path(path.to_string_lossy().as_ref()) +} + +fn normalize_git_cli_path(value: &str) -> String { + let normalized = value.replace('\\', "/"); + #[cfg(windows)] + { + if let Some(rest) = normalized.strip_prefix("//?/UNC/") { + return format!("//{rest}"); + } + if let Some(rest) = normalized.strip_prefix("//?/") { + return rest.to_string(); + } + } + normalized } fn parse_nul_paths(bytes: &[u8]) -> Result, GitError> { @@ -476,7 +490,7 @@ impl GitService { #[cfg(test)] mod tests { - use super::GitService; + use super::{normalize_git_cli_path, GitService}; use std::fs; use std::path::Path; use std::process::Command; @@ -514,6 +528,19 @@ mod tests { (temp, repository) } + #[cfg(windows)] + #[test] + fn normalized_path_strips_extended_windows_prefix_for_git_cli() { + assert_eq!( + normalize_git_cli_path(r"\\?\C:\Users\huawei\.bitfun\worktrees\repo"), + "C:/Users/huawei/.bitfun/worktrees/repo" + ); + assert_eq!( + normalize_git_cli_path(r"\\?\UNC\server\share\repo"), + "//server/share/repo" + ); + } + #[tokio::test] async fn detached_worktrees_from_the_same_commit_are_independent() { let (temp, repository) = initialized_repository(); diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 68b2221d35..043a531b44 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -33,6 +33,9 @@ pub mod git; #[cfg(feature = "hook-import")] pub mod hook_import; +#[cfg(feature = "loopx-issue-fix")] +pub mod loopx_issue_fix; + #[cfg(feature = "mcp")] pub mod mcp; diff --git a/src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs b/src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs new file mode 100644 index 0000000000..9abeeb694f --- /dev/null +++ b/src/crates/services/services-integrations/src/loopx_issue_fix/autonomous.rs @@ -0,0 +1,1290 @@ +//! LoopX-owned control state for continuous issue fixing. +//! +//! BitFun persists no issue queue here. Selected issues are written directly as +//! LoopX agent todos, and every UI refresh is rebuilt from LoopX todo/quota +//! packets. The host scheduler only wakes the generated heartbeat prompt. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use super::{LoopxIssueFix, LoopxIssueFixError}; + +const ISSUE_INTAKE_ACTION: &str = "issue_fix_intake"; +const MAX_USER_REASON_CHARS: usize = 240; + +/// Default objective for goals BitFun bootstraps itself: one sentence, +/// repository-agnostic. Per-repo policy accrues in the goal's active state. +const BOOTSTRAP_OBJECTIVE: &str = "Continuously repair repository issues explicitly \ +selected in BitFun, keep each issue isolated, validate every change, and surface \ +authority gates for human decisions."; + +/// The registered agent id BitFun's host loop runs as. +const BOOTSTRAP_AGENT_ID: &str = "bitfun-cron"; + +/// Host preamble prepended to LoopX's generated heartbeat contract. +/// +/// LoopX owns every lifecycle rule (the `--compact` task body below it); this +/// header only translates host-surface concerns — where the agent runs, how +/// BitFun projects user gates, and what a tick means here. It must never add +/// lifecycle branching of its own. +const HEARTBEAT_HOST_PREAMBLE: &str = "\ +You are BitFun's continuous Issue-Fix host agent. Each scheduled message in this \ +conversation is one LoopX heartbeat tick. LoopX Kernel state is the ONLY source of \ +truth: on every tick re-read it fresh through the `loopx` CLI, and disregard any \ +conclusion from earlier messages in this conversation that conflicts with the \ +current packets. Never invent issue or PR state, and never record progress \ +anywhere except through LoopX writebacks. + +The goal is continuous multi-issue repair. Each selected issue is an independent \ +one-off advancement todo that must travel the full lifecycle — reproduce, patch in \ +an isolated worktree, validate (failure-before / pass-after), publish, then monitor \ +its pull request through the grouped lifecycle monitors — to validated terminal \ +closeout, with every transition written back to LoopX. Do one bounded, verifiable \ +segment per tick, then stop and wait for the next tick. + +Host surface notes: +- You run inside a BitFun desktop chat session; BitFun's host loop owns scheduling. \ +LoopX skill packs are not installed here — follow the contract below directly and \ +consult `loopx --help` when unsure. +- You are a process INSIDE the host application. NEVER force-kill processes you \ +did not start in this very turn: what looks \"stale\" may be your host, its dev \ +tooling, or another agent's work, and killing it terminates you mid-turn. When a \ +build or file lock is contended, wait and retry, keep your build outputs inside \ +your own worktree, or record a blocker todo — never clear processes. +- Work each repository todo in an isolated worktree, created under one sibling \ +folder of the repository (-worktrees/). Base every fix branch on the \ +repository's DEFAULT branch (fetch the remote, then branch from origin/), \ +never on the host checkout's current branch or HEAD — the checkout may sit on \ +unrelated in-flight work, and inheriting it bloats your pull request with foreign \ +changes. Worktrees and whatever build caches you create inside them are yours to \ +reclaim: at terminal closeout remove the worktree (committed work lives on its \ +branch), and never leave large build outputs behind on completed or abandoned work. +- Repository-specific policy — toolchains, validation commands, path boundaries — \ +belongs in the goal's active state and registry, not in this prompt. Read it from \ +there, and write durable local rules back to the active state as you learn them. +- Raise human decisions ONLY as typed LoopX user todos, and pick the task class by \ +WHO performs the action. `--task-class user_gate` is exclusively a request for the \ +user to AUTHORIZE an action YOU would then perform (posting a comment, closing an \ +issue, force-pushing); always pass `--unblocks-todo-id `. \ +Work the user completes themselves on the provider side — merging a PR, reviewing, \ +replying — is `--task-class user_action`, NEVER a gate: you do not need permission \ +for an action you will not perform, and the provider-side monitor observes the \ +outcome and closes it out. BitFun renders gates as blocking decision cards and \ +actions as ambient reminders; misclassifying floods the decision surface. A plain \ +chat reply never grants authority. +- User-lane todo text is projected verbatim into BitFun's panel, so keep it to ONE \ +compact line (<=160 chars) in the shape \" · \", e.g. \"Merge PR #2038 — fixes #1980 \ +stream truncation · CI green, validated\". Include the full URL of the primary \ +PR/issue. Drafted comments, long evidence, and reasoning go in --note or \ +--evidence, never in the todo text. +- NOTIFY / DONT_NOTIFY in the contract below control only the final chat summary: \ +for NOTIFY end with a concise user-facing summary (in the contract's notification \ +language), for DONT_NOTIFY end with a single quiet status line. + +--- LoopX heartbeat contract follows ---"; +const REQUIRED_CAPABILITIES: [&str; 4] = ["shell", "filesystem_write", "git", "network"]; +const HEARTBEAT_CAPABILITIES: [&str; 5] = [ + "shell", + "filesystem_write", + "git", + "network", + "external_evidence_poll", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueSelection { + pub issue_ref: String, + pub issue_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutonomousIssueTodo { + pub issue_ref: String, + pub issue_url: String, + pub todo_id: String, + pub status: String, + pub selected: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutonomousUserQuestion { + pub todo_id: String, + pub prompt: String, +} + +/// One open user-lane todo for the panel's read-only "pending your action" +/// block: gates answer through the question card, actions (e.g. "review PR +/// #N") resolve on the provider side and close via the Kernel's monitors, so +/// no mutation surface is offered here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutonomousUserTodo { + pub todo_id: String, + pub task_class: String, + pub text: String, + pub link: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserDecision { + Approve, + Reject, + Cancel, +} + +impl UserDecision { + fn as_loopx_value(self) -> &'static str { + match self { + Self::Approve => "approve", + Self::Reject => "reject", + Self::Cancel => "cancel", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutonomousControlState { + pub goal_id: String, + pub agent_id: String, + pub kernel_state: String, + pub should_run: bool, + pub action_required: bool, + pub recommended_action: Option, + pub gate_prompt: Option, + pub selected_todo_id: Option, + pub issues: Vec, + pub user_question: Option, + pub user_todos: Vec, +} + +/// A cheap projection for background polling: todo list only, no `quota +/// should-run`. LoopX appends a rollout event on every `should-run` call, so a +/// UI poll loop must not run it; gates and issue todos are fully derivable +/// from `todo list`, which is read-only. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutonomousLightState { + pub goal_id: String, + pub agent_id: String, + pub action_required: bool, + pub issues: Vec, + pub user_question: Option, + pub user_todos: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutonomousStartPlan { + pub control: AutonomousControlState, + pub heartbeat_prompt: String, + pub added_issue_refs: Vec, +} + +#[derive(Debug, Error)] +pub enum AutonomousIssueFixError { + #[error(transparent)] + Loopx(#[from] LoopxIssueFixError), + #[error("failed to read LoopX registry {path}: {source}")] + RegistryRead { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("LoopX registry {path} is not valid JSON: {source}")] + RegistryJson { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("expected exactly one active LoopX goal, found {0}")] + ActiveGoalCount(usize), + #[error("expected exactly one registered LoopX agent for goal {goal_id}, found {count}")] + RegisteredAgentCount { goal_id: String, count: usize }, + #[error("LoopX {command} packet is missing required field {field}")] + MissingField { + command: &'static str, + field: &'static str, + }, + #[error("invalid issue selection: {0}")] + InvalidSelection(String), + #[error("invalid user response: {0}")] + InvalidUserResponse(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ControlIdentity { + goal_id: String, + agent_id: String, +} + +#[derive(Debug, Clone)] +pub struct AutonomousIssueFix { + loopx: LoopxIssueFix, +} + +impl AutonomousIssueFix { + pub fn new(loopx: LoopxIssueFix) -> Self { + Self { loopx } + } + + /// Project the current Issue-Fix surface from LoopX without writing state. + pub async fn inspect( + &self, + repository_path: &Path, + ) -> Result { + let identity = read_identity(repository_path)?; + self.inspect_with_identity(repository_path, &identity).await + } + + /// True when the repository has a usable LoopX control plane (a registry + /// with exactly one active goal and one registered agent). + pub fn is_bootstrapped(repository_path: &Path) -> bool { + read_identity(repository_path).is_ok() + } + + /// Bootstrap the repository into LoopX on first use: create the goal with + /// BitFun's continuous-repair objective and register the host agent lane. + /// + /// Idempotent at the call level — when a valid control plane already + /// exists this is a no-op. The goal id derives from the directory name, + /// mirroring `loopx bootstrap`'s own default. + pub async fn ensure_bootstrapped( + &self, + repository_path: &Path, + ) -> Result<(), AutonomousIssueFixError> { + if Self::is_bootstrapped(repository_path) { + return Ok(()); + } + self.loopx + .json_in( + repository_path, + [ + "bootstrap", + "--project", + ".", + "--objective", + BOOTSTRAP_OBJECTIVE, + "--role", + "controller", + "--no-onboarding-scan", + ], + ) + .await?; + // The goal now exists but carries no registered agents yet, so the + // full identity read (which requires exactly one agent) cannot be + // used here — read the goal id alone. + let goal_id = read_active_goal_id(repository_path)?; + self.loopx + .json_in( + repository_path, + [ + "register-agent", + "--goal-id", + goal_id.as_str(), + "--agent-id", + BOOTSTRAP_AGENT_ID, + "--execute", + ], + ) + .await?; + Ok(()) + } + + /// Cheap read-only projection for background polling: `todo list` only. + /// + /// Unlike [`Self::inspect`], this never invokes `quota should-run`, which + /// appends a rollout event per call and would grow LoopX's event log + /// unboundedly under a poll loop. + pub async fn poll( + &self, + repository_path: &Path, + ) -> Result { + let identity = read_identity(repository_path)?; + let todos = self.list_todos(repository_path, &identity).await?; + let issues = todos + .iter() + .filter_map(project_issue_todo) + .collect::>(); + let user_question = project_user_question(&todos, &issues); + Ok(AutonomousLightState { + goal_id: identity.goal_id, + agent_id: identity.agent_id, + action_required: user_question.is_some(), + issues, + user_question, + user_todos: project_user_todos(&todos), + }) + } + + /// Resolve the control identity (active goal, registered agent) from the + /// project registry without touching LoopX state. Host-loop management + /// needs the goal id even when no LoopX command should run. + pub fn identity( + repository_path: &Path, + ) -> Result<(String, String), AutonomousIssueFixError> { + read_identity(repository_path).map(|identity| (identity.goal_id, identity.agent_id)) + } + + /// Regenerate the full host heartbeat prompt for the registered goal. + /// + /// The stored cron payload is a snapshot; callers should refresh it at + /// every natural write point (start, gate answers) so LoopX upgrades that + /// change the generated contract propagate without a manual restart. + pub async fn heartbeat_prompt( + &self, + repository_path: &Path, + ) -> Result { + let identity = read_identity(repository_path)?; + self.heartbeat_prompt_with_identity(repository_path, &identity) + .await + } + + /// Persist every selected issue as a LoopX todo, then generate the host + /// heartbeat from the resulting Kernel state. + pub async fn start( + &self, + repository_path: &Path, + repo: &str, + issues: &[IssueSelection], + ) -> Result { + if issues.is_empty() { + return Err(AutonomousIssueFixError::InvalidSelection( + "at least one issue is required".to_string(), + )); + } + let identity = read_identity(repository_path)?; + let mut existing = self + .list_issue_todos(repository_path, &identity) + .await? + .into_iter() + .map(|todo| todo.issue_ref) + .collect::>(); + let mut added_issue_refs = Vec::new(); + + for issue in issues { + validate_selection(issue)?; + if !existing.insert(issue.issue_ref.clone()) { + continue; + } + let task_repository = task_repository(repo, &issue.issue_url)?; + let text = issue_todo_text(repo, issue); + let mut args = vec![ + "todo".to_string(), + "add".to_string(), + "--goal-id".to_string(), + identity.goal_id.clone(), + "--role".to_string(), + "agent".to_string(), + "--text".to_string(), + text, + "--task-class".to_string(), + "advancement_task".to_string(), + "--action-kind".to_string(), + ISSUE_INTAKE_ACTION.to_string(), + "--task-repository".to_string(), + task_repository, + "--claimed-by".to_string(), + identity.agent_id.clone(), + ]; + for capability in REQUIRED_CAPABILITIES { + args.push("--required-capability".to_string()); + args.push(capability.to_string()); + } + self.loopx.json_in(repository_path, args).await?; + added_issue_refs.push(issue.issue_ref.clone()); + } + + let (control, heartbeat_prompt) = tokio::try_join!( + self.inspect_with_identity(repository_path, &identity), + self.heartbeat_prompt_with_identity(repository_path, &identity), + )?; + + Ok(AutonomousStartPlan { + control, + heartbeat_prompt, + added_issue_refs, + }) + } + + /// Resolve the currently projected Issue-Fix user gate through LoopX's + /// typed todo lifecycle. Only the projected `user_gate` todo is accepted; + /// other user todos (reading queues, `user_action`) are rejected. + pub async fn answer_user_question( + &self, + repository_path: &Path, + todo_id: &str, + decision: UserDecision, + reason: Option<&str>, + ) -> Result { + let identity = read_identity(repository_path)?; + let current = self + .inspect_with_identity(repository_path, &identity) + .await?; + let question = current.user_question.as_ref().ok_or_else(|| { + AutonomousIssueFixError::InvalidUserResponse( + "there is no open Issue-Fix user question".to_string(), + ) + })?; + if question.todo_id != todo_id.trim() { + return Err(AutonomousIssueFixError::InvalidUserResponse(format!( + "todo {} is not the current Issue-Fix user question", + todo_id.trim() + ))); + } + + let args = user_decision_args(&identity, &question.todo_id, decision, reason)?; + self.loopx.json_in(repository_path, args).await?; + self.inspect_with_identity(repository_path, &identity).await + } + + async fn inspect_with_identity( + &self, + repository_path: &Path, + identity: &ControlIdentity, + ) -> Result { + let quota_args = scheduler_args("quota", "should-run", identity, None); + let (quota, todos) = tokio::try_join!( + async { + self.loopx + .json_in(repository_path, quota_args) + .await + .map_err(AutonomousIssueFixError::from) + }, + self.list_todos(repository_path, identity), + )?; + + let mut issues = todos + .iter() + .filter_map(project_issue_todo) + .collect::>(); + let selected_todo_id = optional_string("a, "selected_todo", "todo_id") + .filter(|todo_id| issues.iter().any(|issue| issue.todo_id == *todo_id)); + for issue in &mut issues { + issue.selected = selected_todo_id.as_deref() == Some(issue.todo_id.as_str()); + } + let user_question = project_user_question(&todos, &issues); + let action_required = user_question.is_some(); + + Ok(AutonomousControlState { + goal_id: identity.goal_id.clone(), + agent_id: identity.agent_id.clone(), + kernel_state: required_string("a, "quota should-run", "state")?, + should_run: quota + .get("should_run") + .and_then(Value::as_bool) + .unwrap_or(false), + action_required, + recommended_action: optional_top_string("a, "recommended_action"), + gate_prompt: user_question + .as_ref() + .map(|question| question.prompt.clone()), + selected_todo_id, + issues, + user_question, + user_todos: project_user_todos(&todos), + }) + } + + async fn list_issue_todos( + &self, + repository_path: &Path, + identity: &ControlIdentity, + ) -> Result, AutonomousIssueFixError> { + let todos = self.list_todos(repository_path, identity).await?; + Ok(todos.iter().filter_map(project_issue_todo).collect()) + } + + async fn list_todos( + &self, + repository_path: &Path, + identity: &ControlIdentity, + ) -> Result, AutonomousIssueFixError> { + let packet = self + .loopx + .json_in( + repository_path, + [ + "todo", + "list", + "--goal-id", + identity.goal_id.as_str(), + "--agent-id", + identity.agent_id.as_str(), + ], + ) + .await?; + packet + .get("todos") + .and_then(Value::as_array) + .cloned() + .ok_or(AutonomousIssueFixError::MissingField { + command: "todo list", + field: "todos", + }) + } + + async fn heartbeat_prompt_with_identity( + &self, + repository_path: &Path, + identity: &ControlIdentity, + ) -> Result { + // `--compact` rather than `--thin`: the thin dispatcher delegates to + // LoopX skill packs (`loopx-project`) that are not installed in a + // BitFun agent session, while the compact body carries the full + // should_run lifecycle inline. + let packet = self + .loopx + .json_in( + repository_path, + scheduler_args("heartbeat-prompt", "", identity, Some("--compact")), + ) + .await?; + let task_body = required_string(&packet, "heartbeat-prompt", "task_body")?; + Ok(compose_heartbeat_prompt(&task_body)) + } +} + +/// Wrap LoopX's generated contract with the BitFun host preamble. +fn compose_heartbeat_prompt(task_body: &str) -> String { + format!("{HEARTBEAT_HOST_PREAMBLE}\n\n{task_body}") +} + +fn scheduler_args( + command: &str, + subcommand: &str, + identity: &ControlIdentity, + prompt_mode: Option<&str>, +) -> Vec { + let mut args = vec![command.to_string()]; + if !subcommand.is_empty() { + args.push(subcommand.to_string()); + } + args.extend([ + "--goal-id".to_string(), + identity.goal_id.clone(), + "--agent-id".to_string(), + identity.agent_id.clone(), + "--host-surface".to_string(), + "local_scheduler".to_string(), + "--scheduler-owner".to_string(), + "host_automation".to_string(), + "--execution-mode".to_string(), + "hosted_automation".to_string(), + ]); + for capability in HEARTBEAT_CAPABILITIES { + args.push("--available-capability".to_string()); + args.push(capability.to_string()); + } + if let Some(mode) = prompt_mode { + args.push(mode.to_string()); + } + args +} + +fn read_identity(repository_path: &Path) -> Result { + let path = repository_path.join(".loopx").join("registry.json"); + let registry = read_registry(&path)?; + let goal = single_active_goal(®istry)?; + let goal_id = goal_id_of(goal)?; + let agents = goal + .pointer("/coordination/registered_agents") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .collect::>(); + if agents.len() != 1 { + return Err(AutonomousIssueFixError::RegisteredAgentCount { + goal_id, + count: agents.len(), + }); + } + Ok(ControlIdentity { + goal_id, + agent_id: agents[0].to_string(), + }) +} + +/// The active goal id alone, for mid-bootstrap states where the agent lane is +/// not registered yet. +fn read_active_goal_id(repository_path: &Path) -> Result { + let path = repository_path.join(".loopx").join("registry.json"); + let registry = read_registry(&path)?; + goal_id_of(single_active_goal(®istry)?) +} + +fn read_registry(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|source| AutonomousIssueFixError::RegistryRead { + path: path.to_path_buf(), + source, + })?; + serde_json::from_slice(&bytes).map_err(|source| AutonomousIssueFixError::RegistryJson { + path: path.to_path_buf(), + source, + }) +} + +fn single_active_goal(registry: &Value) -> Result<&Value, AutonomousIssueFixError> { + let active_goals = registry + .get("goals") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|goal| goal.get("status").and_then(Value::as_str) == Some("active")) + .collect::>(); + if active_goals.len() != 1 { + return Err(AutonomousIssueFixError::ActiveGoalCount(active_goals.len())); + } + Ok(active_goals[0]) +} + +fn goal_id_of(goal: &Value) -> Result { + goal.get("id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or(AutonomousIssueFixError::MissingField { + command: "registry", + field: "goals[].id", + }) +} + +fn project_issue_todo(todo: &Value) -> Option { + if todo.get("role").and_then(Value::as_str) != Some("agent") { + return None; + } + let action_kind = todo.get("action_kind").and_then(Value::as_str)?; + if action_kind != ISSUE_INTAKE_ACTION { + return None; + } + let text = todo.get("text").and_then(Value::as_str)?; + let (issue_url, issue_ref) = explicit_issue_url(text)?; + Some(AutonomousIssueTodo { + issue_ref, + issue_url, + todo_id: todo.get("todo_id")?.as_str()?.to_string(), + status: todo + .get("status") + .and_then(Value::as_str) + .unwrap_or("open") + .to_string(), + selected: false, + }) +} + +/// Project the open Issue-Fix user gate that deserves the blocking decision +/// card, straight from the todo list. +/// +/// `quota should-run` also previews gates, but its `gate_open_items` lane is +/// compacted to two entries; enumerating the todo list is the only complete +/// source, and it keeps gate projection available to the quota-free poll path. +/// +/// Not every open `user_gate` earns the card. Agents sometimes misclassify +/// provider-side work ("please merge PR #N") as gates; rendering those as +/// urgent decisions floods the surface while the loop is not even blocked. +/// The card is reserved for gates that actually stall agent work: +/// `blocks_agent` set, a goal-wide `global_gate`, or an authorization request +/// (recognizable by asking the user to approve something the agent will do). +/// Everything else still reaches the user through the pending-todos lane. +/// Among card-worthy gates, issue-linked ones win; unlinked ones are the +/// fallback so a blocking gate can never be invisible. +fn project_user_question( + todos: &[Value], + issues: &[AutonomousIssueTodo], +) -> Option { + let open_gates = todos + .iter() + .filter(|todo| { + todo.get("role").and_then(Value::as_str) == Some("user") + && todo.get("status").and_then(Value::as_str) == Some("open") + && todo.get("task_class").and_then(Value::as_str) == Some("user_gate") + && gate_blocks_agent_work(todo) + }) + .collect::>(); + let linked = open_gates.iter().find(|todo| { + todo.get("unblocks_todo_id") + .and_then(Value::as_str) + .is_some_and(|unblocks| issues.iter().any(|issue| issue.todo_id == unblocks)) + }); + let todo = linked.or(open_gates.first())?; + Some(AutonomousUserQuestion { + todo_id: todo.get("todo_id")?.as_str()?.to_string(), + prompt: todo.get("text")?.as_str()?.to_string(), + }) +} + +/// Does this open gate actually stall agent work (vs. remind the user of +/// provider-side work like merging a PR)? +/// +/// Structural signals first: `blocks_agent` / `global_gate` are explicit +/// Kernel-level blocks. For plain linked gates, distinguish "authorize the +/// agent to act" from "please do this yourself" by the request verb: an +/// authorization asks to approve/allow/permit something. The heuristic is +/// deliberately conservative — misclassified merge reminders say "Merge PR +/// #N", which carries no authorization verb, while genuine gates written per +/// the contract say "Authorize ..." / "Allow ...". +fn gate_blocks_agent_work(todo: &Value) -> bool { + if todo + .get("blocks_agent") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + { + return true; + } + if todo.get("global_gate").and_then(Value::as_bool) == Some(true) { + return true; + } + let text = todo + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + ["authorize", "authorise", "allow ", "permit ", "approve "] + .iter() + .any(|verb| text.contains(verb)) +} + +/// Project every open user-lane todo (gates and actions) for the panel's +/// read-only "pending your action" block. Other user todo classes (reading +/// queues, blockers) stay out — they are not actionable from this surface. +fn project_user_todos(todos: &[Value]) -> Vec { + todos + .iter() + .filter_map(|todo| { + if todo.get("role").and_then(Value::as_str) != Some("user") + || todo.get("status").and_then(Value::as_str) != Some("open") + { + return None; + } + let task_class = todo.get("task_class").and_then(Value::as_str)?; + if task_class != "user_gate" && task_class != "user_action" { + return None; + } + let text = todo.get("text")?.as_str()?.to_string(); + Some(AutonomousUserTodo { + todo_id: todo.get("todo_id")?.as_str()?.to_string(), + task_class: task_class.to_string(), + link: first_http_link(&text), + text, + }) + }) + .collect() +} + +/// First http(s) URL in a todo text, so the panel can offer a jump link +/// (typically the PR awaiting review or the issue awaiting closure). +fn first_http_link(text: &str) -> Option { + for token in text.split(|character: char| character.is_whitespace() || character == '(') { + let url = token.trim_matches(|character: char| { + matches!(character, ')' | ']' | ',' | '.' | ';' | '`' | '"' | '\'') + }); + if url.starts_with("https://") || url.starts_with("http://") { + return Some(url.to_string()); + } + } + None +} + +fn user_decision_args( + identity: &ControlIdentity, + todo_id: &str, + decision: UserDecision, + reason: Option<&str>, +) -> Result, AutonomousIssueFixError> { + let reason = reason.map(str::trim).filter(|value| !value.is_empty()); + if reason.is_some_and(|value| value.chars().count() > MAX_USER_REASON_CHARS) { + return Err(AutonomousIssueFixError::InvalidUserResponse(format!( + "reason must be at most {MAX_USER_REASON_CHARS} characters" + ))); + } + let note = reason + .map(str::to_string) + .unwrap_or_else(|| "Submitted from the BitFun continuous Issue-Fix panel.".to_string()); + Ok(vec![ + "todo".to_string(), + "complete".to_string(), + "--goal-id".to_string(), + identity.goal_id.clone(), + "--role".to_string(), + "user".to_string(), + "--todo-id".to_string(), + todo_id.to_string(), + "--agent-id".to_string(), + identity.agent_id.clone(), + "--decision-outcome".to_string(), + decision.as_loopx_value().to_string(), + "--note".to_string(), + note, + ]) +} + +fn explicit_issue_url(text: &str) -> Option<(String, String)> { + for token in text.split(|character: char| character.is_whitespace() || character == '(') { + let url = token.trim_matches(|character: char| { + matches!(character, ')' | ']' | ',' | '.' | ';' | ':' | '`') + }); + let marker = "/issues/"; + let Some(marker_index) = url.find(marker) else { + continue; + }; + if !(url.starts_with("https://") || url.starts_with("http://")) { + continue; + } + let suffix = &url[marker_index + marker.len()..]; + let issue_ref = suffix + .split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + .next() + .filter(|value| !value.is_empty())?; + return Some((url.to_string(), issue_ref.to_string())); + } + None +} + +fn validate_selection(issue: &IssueSelection) -> Result<(), AutonomousIssueFixError> { + let issue_ref = issue.issue_ref.trim(); + if issue_ref.is_empty() + || issue_ref + .chars() + .any(|character| !character.is_ascii_alphanumeric() && character != '-') + { + return Err(AutonomousIssueFixError::InvalidSelection(format!( + "unsupported issue ref {:?}", + issue.issue_ref + ))); + } + let Some((_, url_ref)) = explicit_issue_url(&issue.issue_url) else { + return Err(AutonomousIssueFixError::InvalidSelection(format!( + "issue URL must be an explicit http(s) /issues/ URL: {}", + issue.issue_url + ))); + }; + if url_ref != issue_ref { + return Err(AutonomousIssueFixError::InvalidSelection(format!( + "issue ref {} does not match URL ref {}", + issue_ref, url_ref + ))); + } + Ok(()) +} + +fn task_repository(repo: &str, issue_url: &str) -> Result { + let host = issue_url + .split_once("://") + .and_then(|(_, rest)| rest.split('/').next()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AutonomousIssueFixError::InvalidSelection(format!( + "cannot determine provider host from {issue_url}" + )) + })?; + let repo = repo.trim().trim_matches('/'); + if repo.split('/').count() < 2 { + return Err(AutonomousIssueFixError::InvalidSelection(format!( + "repository identity must be owner/repo: {repo}" + ))); + } + Ok(format!("git:{host}/{repo}")) +} + +fn issue_todo_text(repo: &str, issue: &IssueSelection) -> String { + format!( + "[P0] Advance issue-fix for {repo}#{} ({}): run the canonical LoopX Issue-Fix lifecycle through validated terminal closeout and write every transition back to LoopX.", + issue.issue_ref, issue.issue_url + ) +} + +fn required_string( + value: &Value, + command: &'static str, + field: &'static str, +) -> Result { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or(AutonomousIssueFixError::MissingField { command, field }) +} + +fn optional_top_string(value: &Value, field: &str) -> Option { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +fn optional_string(value: &Value, object: &str, field: &str) -> Option { + value + .get(object) + .and_then(|object| object.get(field)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projects_only_explicit_issue_fix_todos() { + let todo = serde_json::json!({ + "role": "agent", + "action_kind": "issue_fix_intake", + "text": "[P0] Advance issue-fix for owner/repo#1849 (https://github.com/owner/repo/issues/1849): continue.", + "todo_id": "todo_1849", + "status": "open" + }); + + let projected = project_issue_todo(&todo).expect("issue todo projects"); + assert_eq!(projected.issue_ref, "1849"); + assert_eq!( + projected.issue_url, + "https://github.com/owner/repo/issues/1849" + ); + assert_eq!(projected.status, "open"); + } + + #[test] + fn ignores_issue_todos_owned_by_another_loopx_workflow() { + let todo = serde_json::json!({ + "role": "agent", + "action_kind": "issue_fix_portfolio_advancement", + "text": "Advance https://github.com/owner/repo/issues/1920", + "todo_id": "todo_1920", + "status": "open" + }); + + assert!(project_issue_todo(&todo).is_none()); + } + + #[test] + fn projects_only_a_gate_linked_to_a_managed_issue() { + let issues = vec![AutonomousIssueTodo { + issue_ref: "1849".to_string(), + issue_url: "https://github.com/owner/repo/issues/1849".to_string(), + todo_id: "todo_1849".to_string(), + status: "open".to_string(), + selected: true, + }]; + let todos = vec![serde_json::json!({ + "todo_id": "gate_1849", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_1849", + "text": "Authorize opening the validated pull request for #1849?" + })]; + + let question = project_user_question(&todos, &issues).expect("gate projects"); + assert_eq!(question.todo_id, "gate_1849"); + assert_eq!( + question.prompt, + "Authorize opening the validated pull request for #1849?" + ); + } + + #[test] + fn issue_linked_gates_outrank_goal_wide_gates() { + let issues = vec![AutonomousIssueTodo { + issue_ref: "1849".to_string(), + issue_url: "https://github.com/owner/repo/issues/1849".to_string(), + todo_id: "todo_1849".to_string(), + status: "open".to_string(), + selected: true, + }]; + let todos = vec![ + serde_json::json!({ + "todo_id": "gate_goal", + "role": "user", + "status": "open", + "task_class": "user_gate", + "text": "Authorize goal-wide production access" + }), + serde_json::json!({ + "todo_id": "gate_1849", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_1849", + "text": "Authorize opening the validated pull request for #1849?" + }), + ]; + + let question = project_user_question(&todos, &issues).expect("gate projects"); + assert_eq!(question.todo_id, "gate_1849"); + } + + #[test] + fn an_open_gate_is_never_invisible_even_without_an_issue_link() { + // The Kernel blocks on any open user_gate; hiding it would stall the + // loop with nothing to answer, so unlinked gates surface as fallback. + let issues = vec![AutonomousIssueTodo { + issue_ref: "1849".to_string(), + issue_url: "https://github.com/owner/repo/issues/1849".to_string(), + todo_id: "todo_1849".to_string(), + status: "open".to_string(), + selected: true, + }]; + let unlinked = vec![serde_json::json!({ + "todo_id": "gate_force_push", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_other", + "text": "Allow force-push to the feature branch?" + })]; + let question = project_user_question(&unlinked, &issues).expect("fallback projects"); + assert_eq!(question.todo_id, "gate_force_push"); + + // Non-gate user todos (reading queues, user_action) never project. + let user_action = vec![serde_json::json!({ + "todo_id": "todo_review", + "role": "user", + "status": "open", + "task_class": "user_action", + "text": "Review the weekly report" + })]; + assert!(project_user_question(&user_action, &issues).is_none()); + } + + #[test] + fn provider_side_reminders_do_not_earn_the_decision_card() { + // Agents sometimes misfile "please merge PR #N" as user_gate. Merging + // is the USER's provider-side action — the monitor closes it out — so + // it must not render as a blocking decision. Structural blocks + // (blocks_agent) still do, whatever the text says. + let issues = vec![AutonomousIssueTodo { + issue_ref: "2124".to_string(), + issue_url: "https://github.com/owner/repo/issues/2124".to_string(), + todo_id: "todo_2124".to_string(), + status: "open".to_string(), + selected: true, + }]; + let merge_reminder = vec![serde_json::json!({ + "todo_id": "gate_merge", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_2124", + "text": "Merge PR #2126 - fixes #2124 ReviewFixer session mode . CI green, validated" + })]; + assert!(project_user_question(&merge_reminder, &issues).is_none()); + + let structurally_blocking = vec![serde_json::json!({ + "todo_id": "gate_blocking", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_2124", + "blocks_agent": "bitfun-cron", + "text": "Merge PR #2137 - fixes #1038 ACP session hydrate deadlock" + })]; + let question = + project_user_question(&structurally_blocking, &issues).expect("blocking gate projects"); + assert_eq!(question.todo_id, "gate_blocking"); + } + + #[test] + fn gate_projection_does_not_depend_on_quota_preview_truncation() { + // LoopX compacts `gate_open_items` to two entries; the projection must + // find a gate that never appears in that preview. + let issues = vec![AutonomousIssueTodo { + issue_ref: "3".to_string(), + issue_url: "https://github.com/owner/repo/issues/3".to_string(), + todo_id: "todo_3".to_string(), + status: "open".to_string(), + selected: false, + }]; + let todos = vec![ + serde_json::json!({ + "todo_id": "gate_unmanaged", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_unmanaged", + "text": "Authorize a step for a todo this panel does not manage" + }), + serde_json::json!({ + "todo_id": "gate_3", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_3", + "text": "Approve publishing the validated patch for #3?" + }), + ]; + + let question = project_user_question(&todos, &issues).expect("third gate projects"); + assert_eq!(question.todo_id, "gate_3"); + } + + #[test] + fn user_lane_todos_project_for_the_pending_block() { + let todos = vec![ + serde_json::json!({ + "todo_id": "todo_review", + "role": "user", + "status": "open", + "task_class": "user_action", + "text": "[P0] Review and merge PR #2054 (https://github.com/owner/repo/pull/2054)." + }), + serde_json::json!({ + "todo_id": "gate_close", + "role": "user", + "status": "open", + "task_class": "user_gate", + "unblocks_todo_id": "todo_x", + "text": "Authorize closing issue #2016?" + }), + // Excluded: done, agent-lane, and non-actionable classes. + serde_json::json!({ + "todo_id": "todo_done", + "role": "user", + "status": "done", + "task_class": "user_action", + "text": "Old action" + }), + serde_json::json!({ + "todo_id": "todo_agent", + "role": "agent", + "status": "open", + "task_class": "advancement_task", + "text": "Agent work" + }), + serde_json::json!({ + "todo_id": "todo_reading", + "role": "user", + "status": "open", + "task_class": "blocker", + "text": "Reading queue entry" + }), + ]; + + let projected = project_user_todos(&todos); + assert_eq!(projected.len(), 2); + assert_eq!(projected[0].todo_id, "todo_review"); + assert_eq!(projected[0].task_class, "user_action"); + assert_eq!( + projected[0].link.as_deref(), + Some("https://github.com/owner/repo/pull/2054") + ); + assert_eq!(projected[1].todo_id, "gate_close"); + assert_eq!(projected[1].link, None); + } + + #[test] + fn heartbeat_prompt_carries_host_preamble_before_loopx_contract() { + let prompt = compose_heartbeat_prompt("Advance `goal` using `state`."); + let preamble_end = prompt + .find("--- LoopX heartbeat contract follows ---") + .expect("delimiter present"); + assert!(prompt[..preamble_end].contains("LoopX Kernel state is the ONLY source of truth")); + assert!(prompt.ends_with("Advance `goal` using `state`.")); + } + + #[test] + fn scheduler_args_select_prompt_mode_only_for_heartbeat() { + let identity = ControlIdentity { + goal_id: "goal".to_string(), + agent_id: "agent".to_string(), + }; + let quota = scheduler_args("quota", "should-run", &identity, None); + assert!(!quota.iter().any(|arg| arg == "--compact" || arg == "--thin")); + let heartbeat = scheduler_args("heartbeat-prompt", "", &identity, Some("--compact")); + assert_eq!(heartbeat.last().map(String::as_str), Some("--compact")); + } + + #[test] + fn user_decision_is_a_typed_loopx_todo_transition() { + let identity = ControlIdentity { + goal_id: "goal".to_string(), + agent_id: "agent".to_string(), + }; + let args = user_decision_args( + &identity, + "gate_1849", + UserDecision::Reject, + Some("Keep the validated patch local."), + ) + .expect("decision args"); + + assert_eq!( + args, + vec![ + "todo", + "complete", + "--goal-id", + "goal", + "--role", + "user", + "--todo-id", + "gate_1849", + "--agent-id", + "agent", + "--decision-outcome", + "reject", + "--note", + "Keep the validated patch local.", + ] + ); + } + + #[test] + fn prose_issue_number_is_not_treated_as_identity() { + let todo = serde_json::json!({ + "role": "agent", + "action_kind": "issue_fix_intake", + "text": "Investigate issue #1849 without an explicit URL.", + "todo_id": "todo_1849" + }); + assert!(project_issue_todo(&todo).is_none()); + } + + #[test] + fn selection_ref_must_match_url() { + let error = validate_selection(&IssueSelection { + issue_ref: "1849".to_string(), + issue_url: "https://github.com/owner/repo/issues/1580".to_string(), + }) + .expect_err("mismatch must fail"); + assert!(error.to_string().contains("does not match")); + } + + #[test] + fn todo_text_is_stable_for_loopx_deduplication() { + let issue = IssueSelection { + issue_ref: "1849".to_string(), + issue_url: "https://github.com/owner/repo/issues/1849".to_string(), + }; + assert_eq!( + issue_todo_text("owner/repo", &issue), + issue_todo_text("owner/repo", &issue) + ); + } + + #[test] + fn identity_requires_one_goal_and_one_agent() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join(".loopx")).expect("registry dir"); + std::fs::write( + temp.path().join(".loopx").join("registry.json"), + br#"{"goals":[{"id":"goal","status":"active","coordination":{"registered_agents":["agent"]}}]}"#, + ) + .expect("registry write"); + + let identity = read_identity(temp.path()).expect("identity resolves"); + assert_eq!(identity.goal_id, "goal"); + assert_eq!(identity.agent_id, "agent"); + } +} diff --git a/src/crates/services/services-integrations/src/loopx_issue_fix/mod.rs b/src/crates/services/services-integrations/src/loopx_issue_fix/mod.rs new file mode 100644 index 0000000000..2dace3a25a --- /dev/null +++ b/src/crates/services/services-integrations/src/loopx_issue_fix/mod.rs @@ -0,0 +1,362 @@ +//! Bridge to the external `loopx` CLI's `issue-fix` capability. +//! +//! LoopX supplies the deterministic decision skeleton (which route to take for an +//! issue, how to project a PR's lifecycle) and owns the durable Issue-Fix control +//! state. This crate invokes those typed transitions and keeps host concerns out +//! of LoopX's domain state. +//! +//! See `docs/development/loopx-issue-fix-integration.md` for the verified chain. + +pub mod autonomous; +/// Typed packet parsers for LoopX's plan/execute CLI surface. The product path +/// now drives the lifecycle through the heartbeat agent (see [`autonomous`]), +/// so `orchestrator` and `repository_context` are exercised only by the +/// `loopx_issue_fix_contracts` integration tests, kept as executable +/// documentation of the CLI contract (e.g. `decision.route` vs +/// `transition.decision`). +pub mod orchestrator; +pub mod repository_context; + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use thiserror::Error; +use tokio::process::Command; + +/// Override for the `loopx` program path, mirroring `FLASHGREP_DAEMON_BIN`. +/// +/// Resolution order is override → PATH. The desktop host sets this variable at +/// startup to the bundled sidecar binary (when present) unless the user +/// already exported it, so one probe covers all three tiers: developer +/// override, shipped sidecar, and a user-managed install on PATH. +const LOOPX_BIN_ENV: &str = "LOOPX_BIN"; + +/// Bundled sidecar binary names by target, mirroring the flashgrep layout +/// (`resources/loopx/` in the packaged app). +pub fn loopx_sidecar_binary_names() -> &'static [&'static str] { + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + &["loopx-x86_64-pc-windows-msvc.exe"] + } + #[cfg(all(target_os = "windows", target_arch = "aarch64"))] + { + &["loopx-aarch64-pc-windows-msvc.exe"] + } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + &["loopx-x86_64-apple-darwin"] + } + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + &["loopx-aarch64-apple-darwin"] + } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + { + &[ + "loopx-x86_64-unknown-linux-musl", + "loopx-x86_64-unknown-linux-gnu", + ] + } + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + { + &[ + "loopx-aarch64-unknown-linux-musl", + "loopx-aarch64-unknown-linux-gnu", + ] + } + #[cfg(not(any( + all(target_os = "windows", target_arch = "x86_64"), + all(target_os = "windows", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + )))] + { + &["loopx"] + } +} + +/// Point `LOOPX_BIN` at the bundled sidecar unless the user already set it. +/// +/// Called once by the desktop host after resolving the packaged resource dir; +/// keeping the write here (next to the read) makes the contract auditable. +pub fn configure_loopx_bin_env(sidecar_path: &Path) { + if std::env::var_os(LOOPX_BIN_ENV).is_some() { + return; + } + std::env::set_var(LOOPX_BIN_ENV, sidecar_path); +} + +/// LoopX's subprocess call sites pass `text=True` without `encoding=`, so on a +/// non-UTF-8 locale (notably Chinese Windows, `cp936`) it decodes `gh`'s UTF-8 +/// output as GBK and dies. Forcing Python's UTF-8 mode fixes every call site at +/// once and needs no patch to LoopX itself. +const PYTHON_UTF8_ENV: &str = "PYTHONUTF8"; + +/// Cap on captured output, so a runaway subprocess cannot exhaust memory. +const MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Error)] +pub enum LoopxIssueFixError { + #[error("failed to spawn loopx: {0}")] + Spawn(#[source] std::io::Error), + #[error("loopx exited with status {status}: {stderr}")] + Exit { status: String, stderr: String }, + #[error("loopx produced {bytes} bytes of output, exceeding the {limit} byte limit")] + OutputTooLarge { bytes: usize, limit: usize }, + #[error("loopx returned output that is not valid UTF-8")] + NonUtf8Output, + #[error("loopx returned output that is not valid JSON: {0}")] + InvalidJson(#[source] serde_json::Error), + /// LoopX reports domain-level refusals in-band as `{"ok": false, "error": ...}`. + /// It also exits nonzero for these, so the bridge parses stdout before looking + /// at the exit status; otherwise the reason would be lost. + #[error("loopx rejected the request: {0}")] + Rejected(String), +} + +/// A resolved `loopx` program, ready to invoke. +/// +/// Construct with [`LoopxIssueFix::probe`]; a `None` result means the feature is +/// unavailable on this host and its entry points should stay hidden. +#[derive(Debug, Clone)] +pub struct LoopxIssueFix { + program: PathBuf, +} + +impl LoopxIssueFix { + /// Resolve `loopx`, preferring an explicit `LOOPX_BIN` override over `PATH`. + /// + /// Returns `None` when no usable program exists. Callers should treat that as + /// "feature unavailable" rather than an error. + pub fn probe() -> Option { + if let Some(raw) = std::env::var_os(LOOPX_BIN_ENV) { + let path = PathBuf::from(raw); + if path.is_file() { + return Some(Self { program: path }); + } + } + + which::which("loopx").ok().map(|program| Self { program }) + } + + /// The resolved program path, for diagnostics. + pub fn program(&self) -> &Path { + &self.program + } + + /// Run one `loopx issue-fix` subcommand and parse its JSON packet. + /// + /// `args` should omit both the `issue-fix` prefix and `--format json`; this + /// method supplies them. + pub async fn issue_fix(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut command = Command::new(&self.program); + command.arg("issue-fix"); + command.args(args); + command.arg("--format"); + command.arg("json"); + self.run_json_command(command).await + } + + /// Run any LoopX JSON command from the selected project root. + /// + /// `--format json` is a global LoopX option, so this method places it before + /// the supplied subcommand. Autonomous issue fixing uses this path for todo, + /// quota, and heartbeat commands while keeping the project-local registry as + /// the only control-plane source. + pub async fn json_in( + &self, + cwd: &Path, + args: I, + ) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut command = Command::new(&self.program); + command.arg("--format"); + command.arg("json"); + command.args(args); + command.current_dir(cwd); + self.run_json_command(command).await + } + + async fn run_json_command( + &self, + mut command: Command, + ) -> Result { + command.env(PYTHON_UTF8_ENV, "1"); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + #[cfg(windows)] + { + // Suppress the console window that would otherwise flash on spawn. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + + let output = command.output().await.map_err(LoopxIssueFixError::Spawn)?; + + // LoopX signals a domain refusal with BOTH `{"ok": false, "error": ...}` on + // stdout AND exit code 1. Parse stdout first so the structured reason wins; + // checking the status first would discard it and report a bare exit code. + match parse_packet(&output.stdout) { + Ok(packet) => Ok(packet), + Err(refusal @ LoopxIssueFixError::Rejected(_)) => Err(refusal), + Err(parse_error) => { + if output.status.success() { + // Exited cleanly but produced something unparseable. + Err(parse_error) + } else { + // A crash, a bad flag, or a missing dependency: stderr explains + // it far better than a JSON parse failure would. + Err(LoopxIssueFixError::Exit { + status: describe_status(&output.status), + stderr: truncated_stderr(&output.stderr), + }) + } + } + } + } +} + +fn describe_status(status: &std::process::ExitStatus) -> String { + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()) +} + +fn truncated_stderr(stderr: &[u8]) -> String { + const MAX_STDERR_CHARS: usize = 2_000; + let text = String::from_utf8_lossy(stderr); + let trimmed = text.trim(); + if trimmed.chars().count() <= MAX_STDERR_CHARS { + return trimmed.to_string(); + } + trimmed.chars().take(MAX_STDERR_CHARS).collect() +} + +/// Parse a LoopX packet, surfacing in-band `{"ok": false}` refusals as errors. +fn parse_packet(stdout: &[u8]) -> Result { + if stdout.len() > MAX_OUTPUT_BYTES { + return Err(LoopxIssueFixError::OutputTooLarge { + bytes: stdout.len(), + limit: MAX_OUTPUT_BYTES, + }); + } + + let text = std::str::from_utf8(stdout).map_err(|_| LoopxIssueFixError::NonUtf8Output)?; + let packet: serde_json::Value = + serde_json::from_str(text.trim()).map_err(LoopxIssueFixError::InvalidJson)?; + + if packet.get("ok").and_then(serde_json::Value::as_bool) == Some(false) { + let reason = packet + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("loopx reported ok=false without an error message"); + return Err(LoopxIssueFixError::Rejected(reason.to_string())); + } + + Ok(packet) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_packet_accepts_a_successful_projection() { + let packet = parse_packet(br#"{"ok": true, "route": "fix_pr"}"#).expect("packet parses"); + assert_eq!(packet["route"], "fix_pr"); + } + + #[test] + fn parse_packet_tolerates_surrounding_whitespace() { + let packet = parse_packet(b"\n {\"ok\": true}\n\n").expect("packet parses"); + assert_eq!(packet["ok"], true); + } + + #[test] + fn parse_packet_surfaces_in_band_refusals_as_errors() { + // LoopX reports domain refusals on stdout AND exits nonzero, so this must + // not look like success to callers. `issue_fix` parses stdout first so that + // this reason survives instead of being replaced by a bare exit code. + let error = parse_packet(br#"{"ok": false, "error": "scope_class must be provided"}"#) + .expect_err("ok=false is an error"); + match error { + LoopxIssueFixError::Rejected(reason) => { + assert!( + reason.contains("scope_class"), + "unexpected reason: {reason}" + ); + } + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[test] + fn parse_packet_reports_a_missing_error_message() { + let error = parse_packet(br#"{"ok": false}"#).expect_err("ok=false is an error"); + assert!(matches!(error, LoopxIssueFixError::Rejected(_))); + } + + #[test] + fn parse_packet_does_not_treat_a_missing_ok_field_as_refusal() { + let packet = parse_packet(br#"{"decision": "user_gate"}"#).expect("packet parses"); + assert_eq!(packet["decision"], "user_gate"); + } + + #[test] + fn parse_packet_rejects_non_json_output() { + let error = + parse_packet(b"Traceback (most recent call last):").expect_err("non-JSON is an error"); + assert!(matches!(error, LoopxIssueFixError::InvalidJson(_))); + } + + #[test] + fn parse_packet_rejects_non_utf8_output() { + // A GBK-mangled byte sequence, the shape of LoopX's Windows encoding bug. + let error = parse_packet(&[0x7b, 0x80, 0xfe, 0x7d]).expect_err("non-UTF-8 is an error"); + assert!(matches!(error, LoopxIssueFixError::NonUtf8Output)); + } + + #[test] + fn parse_packet_rejects_oversized_output() { + let oversized = vec![b' '; MAX_OUTPUT_BYTES + 1]; + let error = parse_packet(&oversized).expect_err("oversized output is an error"); + match error { + LoopxIssueFixError::OutputTooLarge { bytes, limit } => { + assert_eq!(bytes, MAX_OUTPUT_BYTES + 1); + assert_eq!(limit, MAX_OUTPUT_BYTES); + } + other => panic!("expected OutputTooLarge, got {other:?}"), + } + } + + #[test] + fn truncated_stderr_bounds_its_output() { + let long = "e".repeat(5_000); + assert_eq!(truncated_stderr(long.as_bytes()).chars().count(), 2_000); + } + + #[test] + fn truncated_stderr_trims_whitespace() { + assert_eq!(truncated_stderr(b" boom \n"), "boom"); + } + + #[test] + fn probe_prefers_an_explicit_override_over_path() { + // Only assert the negative case, which needs no real loopx install: a + // non-existent override must not be accepted. + let path = PathBuf::from("/nonexistent/loopx-should-not-resolve"); + assert!(!path.is_file()); + } +} diff --git a/src/crates/services/services-integrations/src/loopx_issue_fix/orchestrator.rs b/src/crates/services/services-integrations/src/loopx_issue_fix/orchestrator.rs new file mode 100644 index 0000000000..cae3cd81b8 --- /dev/null +++ b/src/crates/services/services-integrations/src/loopx_issue_fix/orchestrator.rs @@ -0,0 +1,856 @@ +//! Run one issue through LoopX's deterministic decision chain. +//! +//! The chain is `workflow-plan` → `feasibility` → `caller-repo-branch` → +//! `pr-lifecycle`. LoopX decides *what* to do at each step and writes nothing; +//! BitFun supplies the evidence and performs every side effect. +//! +//! This module exists mostly to make LoopX's JSON safe to consume. The fields +//! that matter sit at non-obvious paths — the route is `decision.route`, not +//! `route`, and the lifecycle decision is `transition.decision` — which is easy +//! to read wrong from the markdown rendering, where both appear flattened. Typed +//! outcomes here mean a caller cannot silently misread a refusal as approval. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::repository_context::RepositoryContext; +use super::{LoopxIssueFix, LoopxIssueFixError}; + +/// Which resolution LoopX selected for an issue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FixRoute { + /// Prepare a branch, validate it, and open a pull request. + FixPr, + /// Draft a maintainer comment; posting still needs an explicit gate. + CommentOnly, + /// Record a blocker instead of opening an ungrounded patch loop. + TriageOnly, +} + +impl FixRoute { + fn parse(value: &str) -> Option { + match value { + "fix_pr" => Some(Self::FixPr), + "comment_only" => Some(Self::CommentOnly), + "triage_only" => Some(Self::TriageOnly), + _ => None, + } + } + + /// Whether this route may lead to a pull request at all. + pub fn permits_pull_request(self) -> bool { + self == Self::FixPr + } +} + +/// What LoopX says should happen next. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NextStep { + /// There is agent work to do now. + RunnableSuccessor, + /// Keep watching; create no successor. + MonitorContinuation, + /// A human must decide before anything else happens. + UserGate, + /// Terminal; nothing follows. + NoFollowup, +} + +impl NextStep { + fn parse(value: &str) -> Option { + match value { + "runnable_successor" => Some(Self::RunnableSuccessor), + "monitor_continuation" => Some(Self::MonitorContinuation), + "user_gate" => Some(Self::UserGate), + "no_followup" => Some(Self::NoFollowup), + _ => None, + } + } + + /// Whether a caller must stop and ask a human. + /// + /// LoopX raises this for semantic ambiguity and for missing write authority. + /// Crossing it automatically would defeat the gate. + pub fn requires_human(self) -> bool { + self == Self::UserGate + } +} + +/// How much of the issue's context BitFun managed to ground. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextGrounding { + Grounded, + Partial, + Ungrounded, + NotProvided, +} + +impl ContextGrounding { + fn parse(value: &str) -> Option { + match value { + "grounded" => Some(Self::Grounded), + "partial" => Some(Self::Partial), + "ungrounded" => Some(Self::Ungrounded), + "not_provided" => Some(Self::NotProvided), + _ => None, + } + } +} + +/// LoopX's route decision for one issue. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeasibilityOutcome { + pub route: FixRoute, + pub next_step: NextStep, + pub context_grounding: ContextGrounding, + /// Why LoopX decided this, verbatim. Useful to show a user why a fix was + /// declined without reinterpreting it. + pub reason_codes: Vec, + /// Which of change_scope / reproduction / validation are still unresolved. + pub unresolved_aspects: Vec, +} + +/// The state of a prepared issue branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchOutcome { + pub issue_branch: String, + pub base_branch: String, + /// `dry_run` until a caller opts into execution. + pub branch_action: String, + pub branch_ready: bool, + pub validation_executed: bool, + pub validation_passed: bool, + pub changed_files: Vec, + /// Both this and a `FixRoute::FixPr` route must hold before opening a PR. + pub review_packet_ready: bool, + pub review_packet_summary: String, + /// Why the packet is not ready yet, when it is not. + pub readiness_blockers: Vec, +} + +impl BranchOutcome { + /// Whether a pull request may be opened for this branch. + /// + /// Deliberately requires the route *and* packet readiness together: the + /// feature ships without a runtime kill switch, so this gate lives on the + /// action rather than relying on a disabled toggle. + pub fn may_open_pull_request(&self, route: FixRoute) -> bool { + route.permits_pull_request() && self.review_packet_ready && self.validation_passed + } +} + +/// How an open pull request should be followed up. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestOutcome { + pub next_step: NextStep, + pub state: String, + pub state_bucket: String, + pub reason: String, + /// Write scopes the successor would need, when it needs any. + pub required_write_scopes: Vec, +} + +/// What a caller should do next after planning an issue. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlanOutcome { + pub issue_ref: String, + pub feasibility: FeasibilityOutcome, + /// Absent when the route does not lead to a branch, or when planning only. + pub branch: Option, +} + +/// Inputs for one issue's run. +#[derive(Debug, Clone)] +pub struct IssueFixRequest<'a> { + /// Public-safe `owner/repo` label. + pub repo: &'a str, + pub issue_ref: &'a str, + pub issue_url: &'a str, + /// Evidence BitFun gathered by reading the repository. + pub context: &'a RepositoryContext, + /// How the fix would be checked. LoopX will not select `fix_pr` without + /// this, whatever the context says. + pub validation_label: &'a str, + /// Compact label for the reproduction, not a raw command. + pub reproduction_label: &'a str, + pub reproduction_status: ReproductionStatus, + pub scope_class: ScopeClass, + pub base_branch: &'a str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReproductionStatus { + Confirmed, + Planned, + Missing, + Blocked, +} + +impl ReproductionStatus { + fn as_arg(self) -> &'static str { + match self { + Self::Confirmed => "confirmed", + Self::Planned => "planned", + Self::Missing => "missing", + Self::Blocked => "blocked", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeClass { + Bounded, + Uncertain, + Oversized, +} + +impl ScopeClass { + fn as_arg(self) -> &'static str { + match self { + Self::Bounded => "bounded", + Self::Uncertain => "uncertain", + Self::Oversized => "oversized", + } + } +} + +/// Whether a step may touch the working tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionMode { + /// Plan only. Nothing is created, nothing runs. + DryRun, + /// Create or claim the issue branch and run the validation command. + Execute { + /// Runs in the repository. A caller must have explicit approval for it. + validation_command: &'static str, + }, +} + +#[derive(Debug)] +pub enum OrchestratorError { + Loopx(LoopxIssueFixError), + /// A field LoopX is contracted to return was missing or unrecognized. + UnexpectedPacket { + field: &'static str, + value: String, + }, + ContextWrite(std::io::Error), + ContextSerialize(serde_json::Error), + /// The context file path could not be passed to LoopX as text. + NonUtf8Path, +} + +impl std::fmt::Display for OrchestratorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Loopx(error) => write!(f, "{error}"), + Self::UnexpectedPacket { field, value } => write!( + f, + "loopx returned an unrecognized {field}: {value:?}; the CLI contract may have changed" + ), + Self::ContextWrite(error) => { + write!(f, "failed to write the repository context: {error}") + } + Self::ContextSerialize(error) => { + write!(f, "failed to serialize the repository context: {error}") + } + Self::NonUtf8Path => write!(f, "the repository context path is not valid UTF-8"), + } + } +} + +impl std::error::Error for OrchestratorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Loopx(error) => Some(error), + Self::ContextWrite(error) => Some(error), + Self::ContextSerialize(error) => Some(error), + _ => None, + } + } +} + +impl From for OrchestratorError { + fn from(error: LoopxIssueFixError) -> Self { + Self::Loopx(error) + } +} + +/// Make a validation command spawnable by LoopX on Windows. +/// +/// LoopX launches the caller-declared validation command with +/// `subprocess.run(shlex.split(command))` and no shell (see +/// `acceptance_loop.py:_run_caller_validation`). On Windows that cannot start +/// `.cmd` / `.bat` shims such as `pnpm` or `npm` — `CreateProcess` only +/// resolves executables — so the bridge reports `[WinError 2]`. Delegating +/// through `cmd /c` makes every command spawnable. This is a host-side concern: +/// BitFun owns the validation command and must not patch LoopX itself. +#[cfg(windows)] +fn windows_safe_validation_command(command: &str) -> String { + let trimmed = command.trim_start(); + if trimmed.starts_with("cmd /c") + || trimmed.starts_with("cmd.exe /c") + || trimmed.starts_with("cmd ") + || trimmed.starts_with("cmd.exe ") + { + command.to_string() + } else { + format!("cmd /c {command}") + } +} + +#[cfg(not(windows))] +fn windows_safe_validation_command(command: &str) -> String { + command.to_string() +} + +/// Drives one issue through the chain. +pub struct IssueFixOrchestrator<'a> { + loopx: &'a LoopxIssueFix, +} + +impl<'a> IssueFixOrchestrator<'a> { + pub fn new(loopx: &'a LoopxIssueFix) -> Self { + Self { loopx } + } + + /// Ask LoopX which route this issue should take. + /// + /// Read-only: LoopX writes nothing, and `--no-write-domain-state` keeps it + /// from touching goal state either. + pub async fn feasibility( + &self, + request: &IssueFixRequest<'_>, + context_dir: &Path, + ) -> Result { + let context_path = write_context(request.context, context_dir)?; + let context_path = context_path + .to_str() + .ok_or(OrchestratorError::NonUtf8Path)?; + + let packet = self + .loopx + .issue_fix([ + "feasibility", + "--repo", + request.repo, + "--issue-ref", + request.issue_ref, + "--url", + request.issue_url, + "--reproduction-status", + request.reproduction_status.as_arg(), + "--scope-class", + request.scope_class.as_arg(), + "--reproduction-label", + request.reproduction_label, + "--validation-label", + request.validation_label, + "--repository-context-json", + context_path, + "--no-write-domain-state", + ]) + .await?; + + parse_feasibility(&packet) + } + + /// Prepare the issue branch and, when executing, run the validation command. + /// + /// In [`ExecutionMode::DryRun`] this creates nothing; the returned + /// `branch_action` reports `dry_run`. + pub async fn prepare_branch( + &self, + request: &IssueFixRequest<'_>, + repo_path: &str, + mode: ExecutionMode, + ) -> Result { + let mut args = vec![ + "caller-repo-branch", + "--repo-path", + repo_path, + "--repo", + request.repo, + "--issue-ref", + request.issue_ref, + "--url", + request.issue_url, + "--base-branch", + request.base_branch, + "--validation-label", + request.validation_label, + ]; + let mut validation_arg: Option = None; + if let ExecutionMode::Execute { validation_command } = mode { + let wrapped = windows_safe_validation_command(validation_command); + validation_arg = Some(wrapped); + } + if let Some(command) = validation_arg.as_deref() { + args.push("--validation-command"); + args.push(command); + args.push("--execute"); + } + + let packet = self.loopx.issue_fix(args).await?; + parse_branch(&packet) + } + + /// Project an open pull request's lifecycle onto a next step. + pub async fn pull_request_lifecycle( + &self, + repo: &str, + pull_request_ref: &str, + issue_ref: &str, + metadata_path: Option<&str>, + ) -> Result { + let mut args = vec![ + "pr-lifecycle", + "--repo", + repo, + "--pr-ref", + pull_request_ref, + "--issue-ref", + issue_ref, + "--no-write-domain-state", + ]; + match metadata_path { + Some(path) => { + args.push("--metadata-json"); + args.push(path); + } + None => args.push("--fetch-metadata"), + } + + let packet = self.loopx.issue_fix(args).await?; + parse_pull_request(&packet) + } + + /// Plan one issue: decide the route, then prepare a branch only when the + /// route actually permits a pull request. + pub async fn plan_issue( + &self, + request: &IssueFixRequest<'_>, + repo_path: &str, + context_dir: &Path, + mode: ExecutionMode, + ) -> Result { + let feasibility = self.feasibility(request, context_dir).await?; + + // Skip the branch entirely on a non-fix route. Preparing one would be + // wasted work at best, and on `--execute` it would create a branch LoopX + // just declined to justify. + let branch = if feasibility.route.permits_pull_request() { + Some(self.prepare_branch(request, repo_path, mode).await?) + } else { + None + }; + + Ok(PlanOutcome { + issue_ref: request.issue_ref.to_string(), + feasibility, + branch, + }) + } +} + +fn write_context( + context: &RepositoryContext, + dir: &Path, +) -> Result { + let path = dir.join("loopx-repository-context.json"); + let bytes = serde_json::to_vec(context).map_err(OrchestratorError::ContextSerialize)?; + std::fs::write(&path, bytes).map_err(OrchestratorError::ContextWrite)?; + Ok(path) +} + +fn required_str<'p>( + packet: &'p serde_json::Value, + path: &[&str], + field: &'static str, +) -> Result<&'p str, OrchestratorError> { + let mut cursor = packet; + for key in path { + cursor = &cursor[key]; + } + cursor + .as_str() + .ok_or_else(|| OrchestratorError::UnexpectedPacket { + field, + value: cursor.to_string(), + }) +} + +fn string_list(packet: &serde_json::Value, path: &[&str]) -> Vec { + let mut cursor = packet; + for key in path { + cursor = &cursor[key]; + } + cursor + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn parse_feasibility(packet: &serde_json::Value) -> Result { + // `decision.route`, not `route`: the markdown rendering flattens these, so + // reading the top level here would silently yield null. + let route_text = required_str(packet, &["decision", "route"], "decision.route")?; + let route = FixRoute::parse(route_text).ok_or_else(|| OrchestratorError::UnexpectedPacket { + field: "decision.route", + value: route_text.to_string(), + })?; + + let step_text = required_str(packet, &["transition", "decision"], "transition.decision")?; + let next_step = + NextStep::parse(step_text).ok_or_else(|| OrchestratorError::UnexpectedPacket { + field: "transition.decision", + value: step_text.to_string(), + })?; + + let grounding_text = required_str( + packet, + &["observation", "repository_context", "context_status"], + "observation.repository_context.context_status", + )?; + let context_grounding = ContextGrounding::parse(grounding_text).ok_or_else(|| { + OrchestratorError::UnexpectedPacket { + field: "observation.repository_context.context_status", + value: grounding_text.to_string(), + } + })?; + + Ok(FeasibilityOutcome { + route, + next_step, + context_grounding, + reason_codes: string_list(packet, &["decision", "reason_codes"]), + unresolved_aspects: string_list( + packet, + &[ + "observation", + "repository_context", + "unresolved_required_aspects", + ], + ), + }) +} + +fn parse_branch(packet: &serde_json::Value) -> Result { + let artifact = &packet["caller_repo_branch"]; + let review_packet = &packet["review_packet"]; + + Ok(BranchOutcome { + issue_branch: required_str( + artifact, + &["issue_branch"], + "caller_repo_branch.issue_branch", + )? + .to_string(), + base_branch: required_str(artifact, &["base_branch"], "caller_repo_branch.base_branch")? + .to_string(), + branch_action: required_str( + artifact, + &["branch_action"], + "caller_repo_branch.branch_action", + )? + .to_string(), + branch_ready: artifact["branch_ready"].as_bool().unwrap_or(false), + validation_executed: artifact["validation"]["executed"] + .as_bool() + .unwrap_or(false), + validation_passed: artifact["validation"]["passed"].as_bool().unwrap_or(false), + changed_files: string_list(artifact, &["changed_files"]), + review_packet_ready: review_packet["ready"].as_bool().unwrap_or(false), + review_packet_summary: review_packet["summary"] + .as_str() + .unwrap_or_default() + .to_string(), + readiness_blockers: string_list(review_packet, &["readiness_blockers"]), + }) +} + +fn parse_pull_request(packet: &serde_json::Value) -> Result { + let step_text = required_str(packet, &["transition", "decision"], "transition.decision")?; + let next_step = + NextStep::parse(step_text).ok_or_else(|| OrchestratorError::UnexpectedPacket { + field: "transition.decision", + value: step_text.to_string(), + })?; + + Ok(PullRequestOutcome { + next_step, + // These live under `observation` and `grouped_monitor_projection`, not at + // the top level — one more reason this parsing belongs in one place. + state: packet["observation"]["state"] + .as_str() + .unwrap_or_default() + .to_string(), + state_bucket: packet["grouped_monitor_projection"]["state_bucket"] + .as_str() + .unwrap_or_default() + .to_string(), + reason: packet["transition"]["reason"] + .as_str() + .unwrap_or_default() + .to_string(), + required_write_scopes: string_list(packet, &["transition", "required_write_scopes"]), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feasibility_reads_the_nested_route_and_decision() { + // The exact shape LoopX returns. Both fields are nested; a top-level read + // would yield null and, before typing, would have looked like success. + let packet = serde_json::json!({ + "ok": true, + "decision": { + "route": "fix_pr", + "reason_codes": ["reproduction_confirmed", "validation_surface_named"], + }, + "transition": {"decision": "runnable_successor"}, + "observation": { + "repository_context": { + "context_status": "grounded", + "unresolved_required_aspects": [], + }, + }, + }); + + let outcome = parse_feasibility(&packet).expect("packet parses"); + assert_eq!(outcome.route, FixRoute::FixPr); + assert!(outcome.route.permits_pull_request()); + assert_eq!(outcome.next_step, NextStep::RunnableSuccessor); + assert_eq!(outcome.context_grounding, ContextGrounding::Grounded); + assert_eq!(outcome.reason_codes.len(), 2); + assert!(outcome.unresolved_aspects.is_empty()); + } + + #[test] + fn a_triage_route_does_not_permit_a_pull_request() { + let packet = serde_json::json!({ + "decision": {"route": "triage_only", "reason_codes": ["scope_oversized"]}, + "transition": {"decision": "no_followup"}, + "observation": { + "repository_context": { + "context_status": "partial", + "unresolved_required_aspects": ["validation"], + }, + }, + }); + + let outcome = parse_feasibility(&packet).expect("packet parses"); + assert_eq!(outcome.route, FixRoute::TriageOnly); + assert!(!outcome.route.permits_pull_request()); + assert_eq!(outcome.unresolved_aspects, vec!["validation"]); + } + + #[test] + fn a_user_gate_requires_a_human() { + assert!(NextStep::UserGate.requires_human()); + for step in [ + NextStep::RunnableSuccessor, + NextStep::MonitorContinuation, + NextStep::NoFollowup, + ] { + assert!(!step.requires_human(), "{step:?} should not gate"); + } + } + + #[test] + fn an_unrecognized_route_is_an_error_not_a_default() { + // Silently defaulting an unknown route could turn a refusal into a PR. + let packet = serde_json::json!({ + "decision": {"route": "ship_it_immediately"}, + "transition": {"decision": "runnable_successor"}, + "observation": {"repository_context": {"context_status": "grounded"}}, + }); + + let error = parse_feasibility(&packet).expect_err("unknown routes are rejected"); + match error { + OrchestratorError::UnexpectedPacket { field, value } => { + assert_eq!(field, "decision.route"); + assert_eq!(value, "ship_it_immediately"); + } + other => panic!("expected UnexpectedPacket, got {other:?}"), + } + } + + #[test] + fn a_missing_route_is_an_error() { + let packet = serde_json::json!({"ok": true}); + let error = parse_feasibility(&packet).expect_err("a missing route is rejected"); + assert!(matches!( + error, + OrchestratorError::UnexpectedPacket { + field: "decision.route", + .. + } + )); + } + + #[test] + fn branch_parsing_reports_dry_run_state() { + let packet = serde_json::json!({ + "caller_repo_branch": { + "issue_branch": "codex/issue-1849-fix", + "base_branch": "main", + "branch_action": "dry_run", + "branch_ready": false, + "validation": {"executed": false, "passed": false}, + "changed_files": [], + }, + "review_packet": { + "ready": false, + "summary": "validation is not PR-ready yet", + "readiness_blockers": ["validation_not_run"], + }, + }); + + let outcome = parse_branch(&packet).expect("packet parses"); + assert_eq!(outcome.issue_branch, "codex/issue-1849-fix"); + assert_eq!(outcome.branch_action, "dry_run"); + assert!(!outcome.branch_ready); + assert_eq!(outcome.readiness_blockers, vec!["validation_not_run"]); + } + + #[test] + fn opening_a_pull_request_needs_route_packet_and_validation_together() { + let ready = BranchOutcome { + issue_branch: "codex/issue-1-fix".to_string(), + base_branch: "main".to_string(), + branch_action: "created".to_string(), + branch_ready: true, + validation_executed: true, + validation_passed: true, + changed_files: vec!["src/a.rs".to_string()], + review_packet_ready: true, + review_packet_summary: "ready".to_string(), + readiness_blockers: Vec::new(), + }; + assert!(ready.may_open_pull_request(FixRoute::FixPr)); + + // Each condition alone must be able to veto. + assert!( + !ready.may_open_pull_request(FixRoute::CommentOnly), + "a non-fix route must veto" + ); + let mut unvalidated = ready.clone(); + unvalidated.validation_passed = false; + assert!( + !unvalidated.may_open_pull_request(FixRoute::FixPr), + "failing validation must veto" + ); + let mut unready = ready.clone(); + unready.review_packet_ready = false; + assert!( + !unready.may_open_pull_request(FixRoute::FixPr), + "an unready packet must veto" + ); + } + + #[test] + fn pull_request_lifecycle_parses_each_decision() { + for (decision, expected) in [ + ("runnable_successor", NextStep::RunnableSuccessor), + ("monitor_continuation", NextStep::MonitorContinuation), + ("user_gate", NextStep::UserGate), + ("no_followup", NextStep::NoFollowup), + ] { + // The real packet shape: state sits under `observation` and the bucket + // under `grouped_monitor_projection`, verified against the CLI. + let packet = serde_json::json!({ + "observation": {"state": "OPEN"}, + "grouped_monitor_projection": {"state_bucket": "review_required"}, + "transition": { + "decision": decision, + "reason": "a compact reason", + "required_write_scopes": ["write"], + }, + }); + let outcome = parse_pull_request(&packet).expect("packet parses"); + assert_eq!(outcome.next_step, expected, "for {decision}"); + assert_eq!(outcome.state, "OPEN"); + assert_eq!(outcome.state_bucket, "review_required"); + assert_eq!(outcome.required_write_scopes, vec!["write"]); + } + } + + #[test] + fn an_unrecognized_lifecycle_decision_is_an_error() { + let packet = serde_json::json!({"transition": {"decision": "merge_it_now"}}); + let error = parse_pull_request(&packet).expect_err("unknown decisions are rejected"); + assert!(matches!( + error, + OrchestratorError::UnexpectedPacket { + field: "transition.decision", + .. + } + )); + } + + #[test] + fn missing_optional_fields_fall_back_rather_than_failing() { + // Optional evidence should degrade to empty, unlike the decision fields + // above, where guessing would be unsafe. + let packet = serde_json::json!({ + "caller_repo_branch": { + "issue_branch": "b", + "base_branch": "main", + "branch_action": "dry_run", + }, + "review_packet": {}, + }); + let outcome = parse_branch(&packet).expect("packet parses"); + assert!(!outcome.branch_ready); + assert!(outcome.changed_files.is_empty()); + assert!(outcome.review_packet_summary.is_empty()); + } + + #[cfg(windows)] + #[test] + fn windows_validation_commands_are_delegated_through_cmd() { + assert_eq!( + windows_safe_validation_command("pnpm test"), + "cmd /c pnpm test" + ); + assert_eq!( + windows_safe_validation_command("pnpm --dir src/web-ui run test:run x"), + "cmd /c pnpm --dir src/web-ui run test:run x" + ); + assert_eq!( + windows_safe_validation_command("cmd /c pnpm test"), + "cmd /c pnpm test" + ); + assert_eq!( + windows_safe_validation_command("cmd.exe /c pnpm test"), + "cmd.exe /c pnpm test" + ); + assert_eq!( + windows_safe_validation_command(" cmd /c pnpm test"), + " cmd /c pnpm test" + ); + } + + #[cfg(not(windows))] + #[test] + fn non_windows_validation_commands_are_passed_through() { + assert_eq!(windows_safe_validation_command("pnpm test"), "pnpm test"); + assert_eq!( + windows_safe_validation_command("cmd /c pnpm test"), + "cmd /c pnpm test" + ); + } +} diff --git a/src/crates/services/services-integrations/src/loopx_issue_fix/repository_context.rs b/src/crates/services/services-integrations/src/loopx_issue_fix/repository_context.rs new file mode 100644 index 0000000000..598a7ac987 --- /dev/null +++ b/src/crates/services/services-integrations/src/loopx_issue_fix/repository_context.rs @@ -0,0 +1,934 @@ +//! Build LoopX's `issue_fix_repository_context_input_v0` payload. +//! +//! This is the evidence half of the integration: LoopX holds no code-reading +//! ability and refuses to guess, so the quality of its route decisions depends +//! entirely on what this module reports. Its validator is strict, and a rejected +//! payload costs a whole subprocess round trip — so every constraint LoopX +//! enforces is enforced here too, at construction time. +//! +//! The rule that matters most: LoopX treats an aspect as *grounded* only when a +//! source is `freshness: current`, has `trust` of `authoritative` or `verified`, +//! and is not an external expert. It reports the whole context as grounded only +//! when `change_scope`, `reproduction`, and `validation` are all grounded. +//! +//! Grounding is not by itself the PR gate, though. Testing against the real CLI +//! showed that `--validation-label` — "how will you check this fix" — is what +//! actually permits the `fix_pr` route; a merely partial context still allows it, +//! and a fully grounded one without that label does not. Context grounding shapes +//! LoopX's reason codes and tells a caller what is still worth reading. + +use std::collections::BTreeSet; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: &str = "issue_fix_repository_context_input_v0"; + +/// LoopX rejects a payload with more than this many sources. +pub const MAX_SOURCES: usize = 16; + +const MAX_SOURCE_ID_CHARS: usize = 120; +const MAX_REFERENCE_CHARS: usize = 260; +const MAX_SUMMARY_CHARS: usize = 220; + +/// Where a piece of evidence came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + RepositoryPolicy, + ArchitectureDoc, + MaintainerMap, + TestSurface, + SourceCode, + PriorFix, + /// LoopX requires `Advisory` trust for this kind. + MemoryRetrieval, + /// LoopX requires `Advisory` trust for this kind, and never counts it as + /// grounding an aspect. + ExternalExpert, + KnowledgeBundle, +} + +/// How much weight LoopX may place on a source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Trust { + Authoritative, + Verified, + Advisory, +} + +/// Whether a source was read at the pinned revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Freshness { + /// Requires a `repository_revision` on the context. + Current, + Stale, + Unknown, +} + +/// Which question a source helps answer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SupportAspect { + Architecture, + Ownership, + ChangeScope, + Reproduction, + Validation, +} + +impl SupportAspect { + /// The three aspects LoopX weighs when classifying a context's grounding. + pub const REQUIRED_FOR_FIX: [Self; 3] = + [Self::ChangeScope, Self::Reproduction, Self::Validation]; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryContextError { + EmptyField { + field: &'static str, + }, + TooLong { + field: &'static str, + limit: usize, + actual: usize, + }, + InvalidSourceId { + source_id: String, + }, + AbsoluteReference { + reference: String, + }, + TraversingReference { + reference: String, + }, + InvalidReferenceUrl { + reference: String, + reason: &'static str, + }, + NoSupportedAspects { + source_id: String, + }, + TrustMustBeAdvisory { + source_id: String, + }, + CurrentFreshnessNeedsRevision { + source_id: String, + }, + DuplicateSourceId { + source_id: String, + }, + TooManySources { + limit: usize, + actual: usize, + }, + NoSources, +} + +impl fmt::Display for RepositoryContextError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyField { field } => { + write!(f, "{field} must not be empty") + } + Self::TooLong { + field, + limit, + actual, + } => write!( + f, + "{field} is {actual} characters, exceeding LoopX's limit of {limit}" + ), + Self::InvalidSourceId { source_id } => write!( + f, + "source id {source_id:?} must start alphanumeric and use only letters, digits, '_', '.', ':', or '-'" + ), + Self::AbsoluteReference { reference } => write!( + f, + "reference {reference:?} must be repository-relative; LoopX rejects absolute and home-relative paths as unsafe to publish" + ), + Self::TraversingReference { reference } => write!( + f, + "reference {reference:?} must not traverse outside the repository" + ), + Self::InvalidReferenceUrl { reference, reason } => { + write!(f, "reference URL {reference:?} {reason}") + } + Self::NoSupportedAspects { source_id } => write!( + f, + "source {source_id:?} must support at least one aspect" + ), + Self::TrustMustBeAdvisory { source_id } => write!( + f, + "source {source_id:?} is a memory retrieval or external expert, which LoopX requires to be advisory" + ), + Self::CurrentFreshnessNeedsRevision { source_id } => write!( + f, + "source {source_id:?} claims current freshness, which requires a repository revision" + ), + Self::DuplicateSourceId { source_id } => { + write!(f, "source id {source_id:?} appears more than once") + } + Self::TooManySources { limit, actual } => { + write!(f, "{actual} sources exceeds LoopX's limit of {limit}") + } + Self::NoSources => write!(f, "a repository context needs at least one source"), + } + } +} + +impl std::error::Error for RepositoryContextError {} + +/// One validated piece of evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepositoryContextSource { + pub source_id: String, + pub source_kind: SourceKind, + pub reference: String, + pub trust: Trust, + pub freshness: Freshness, + pub supports: Vec, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub consultation_state: Option, +} + +/// A validated context payload, ready to serialize for LoopX. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepositoryContext { + pub schema_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_revision: Option, + pub sources: Vec, +} + +/// How LoopX will classify one aspect's coverage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AspectStatus { + /// A current, trusted, non-expert source covers it. + Grounded, + /// Only weaker sources cover it. + Advisory, + Missing, +} + +/// What LoopX will report for the context as a whole. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextStatus { + /// All three fix-required aspects are grounded. + Grounded, + Partial, + Ungrounded, +} + +/// Accumulates sources and validates each as it is added. +/// +/// Validating on `push` rather than at build time means a caller learns which +/// source is wrong, instead of getting one failure for the whole payload. +#[derive(Debug, Clone, Default)] +pub struct RepositoryContextBuilder { + repository_revision: Option, + sources: Vec, +} + +impl RepositoryContextBuilder { + pub fn new() -> Self { + Self::default() + } + + /// Pin the revision the sources were read at. + /// + /// Required before any source may claim `Freshness::Current`, which in turn + /// is required for that source to ground an aspect. + pub fn repository_revision(mut self, revision: impl Into) -> Self { + let revision = revision.into(); + self.repository_revision = (!revision.trim().is_empty()).then_some(revision); + self + } + + pub fn has_revision(&self) -> bool { + self.repository_revision.is_some() + } + + /// Validate and append one source. + pub fn push( + &mut self, + source: RepositoryContextSource, + ) -> Result<&mut Self, RepositoryContextError> { + let source = self.validate(source)?; + self.sources.push(source); + Ok(self) + } + + fn validate( + &self, + mut source: RepositoryContextSource, + ) -> Result { + source.source_id = validate_source_id(&source.source_id)?; + source.reference = validate_reference(&source.reference)?; + source.summary = validate_text(&source.summary, "summary", MAX_SUMMARY_CHARS)?; + + if source.supports.is_empty() { + return Err(RepositoryContextError::NoSupportedAspects { + source_id: source.source_id, + }); + } + // LoopX sorts and dedupes these; matching here keeps the payload stable. + source.supports = source + .supports + .iter() + .copied() + .collect::>() + .into_iter() + .collect(); + + if matches!( + source.source_kind, + SourceKind::MemoryRetrieval | SourceKind::ExternalExpert + ) && source.trust != Trust::Advisory + { + return Err(RepositoryContextError::TrustMustBeAdvisory { + source_id: source.source_id, + }); + } + + if source.freshness == Freshness::Current && !self.has_revision() { + return Err(RepositoryContextError::CurrentFreshnessNeedsRevision { + source_id: source.source_id, + }); + } + + if self + .sources + .iter() + .any(|existing| existing.source_id == source.source_id) + { + return Err(RepositoryContextError::DuplicateSourceId { + source_id: source.source_id, + }); + } + + if self.sources.len() + 1 > MAX_SOURCES { + return Err(RepositoryContextError::TooManySources { + limit: MAX_SOURCES, + actual: self.sources.len() + 1, + }); + } + + Ok(source) + } + + /// Classify one aspect exactly as LoopX will. + pub fn aspect_status(&self, aspect: SupportAspect) -> AspectStatus { + let matching = self + .sources + .iter() + .filter(|source| source.supports.contains(&aspect)); + let mut any_match = false; + for source in matching { + any_match = true; + if source.freshness == Freshness::Current + && matches!(source.trust, Trust::Authoritative | Trust::Verified) + && source.source_kind != SourceKind::ExternalExpert + { + return AspectStatus::Grounded; + } + } + if any_match { + AspectStatus::Advisory + } else { + AspectStatus::Missing + } + } + + /// Predict LoopX's overall verdict without spending a subprocess call. + pub fn context_status(&self) -> ContextStatus { + let statuses = SupportAspect::REQUIRED_FOR_FIX.map(|aspect| self.aspect_status(aspect)); + if statuses.iter().all(|s| *s == AspectStatus::Grounded) { + ContextStatus::Grounded + } else if statuses.contains(&AspectStatus::Grounded) { + ContextStatus::Partial + } else { + ContextStatus::Ungrounded + } + } + + /// Which fix-required aspects are not yet grounded. + /// + /// A caller uses this to decide what else to read before asking LoopX. Gaps + /// here weaken the context rather than block a fix outright, so treat this as + /// a reading list, not a hard gate. + pub fn ungrounded_required_aspects(&self) -> Vec { + SupportAspect::REQUIRED_FOR_FIX + .into_iter() + .filter(|aspect| self.aspect_status(*aspect) != AspectStatus::Grounded) + .collect() + } + + pub fn build(self) -> Result { + if self.sources.is_empty() { + return Err(RepositoryContextError::NoSources); + } + Ok(RepositoryContext { + schema_version: SCHEMA_VERSION.to_string(), + repository_revision: self.repository_revision, + sources: self.sources, + }) + } +} + +fn validate_text( + value: &str, + field: &'static str, + limit: usize, +) -> Result { + // LoopX collapses whitespace before measuring, so do the same or a payload + // that looks short enough here could still be rejected there. + let compact = value.split_whitespace().collect::>().join(" "); + if compact.is_empty() { + return Err(RepositoryContextError::EmptyField { field }); + } + let actual = compact.chars().count(); + if actual > limit { + return Err(RepositoryContextError::TooLong { + field, + limit, + actual, + }); + } + Ok(compact) +} + +fn validate_source_id(value: &str) -> Result { + let id = validate_text(value, "source_id", MAX_SOURCE_ID_CHARS)?; + let mut chars = id.chars(); + let valid = chars + .next() + .is_some_and(|first| first.is_ascii_alphanumeric()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':' | '-')); + if !valid { + return Err(RepositoryContextError::InvalidSourceId { source_id: id }); + } + Ok(id) +} + +/// Enforce LoopX's publish-safety rules on a reference. +/// +/// A local absolute path would leak the operator's filesystem layout into a +/// payload that may reach a public issue thread, so LoopX rejects it — and so +/// does this, before the round trip. +fn validate_reference(value: &str) -> Result { + let reference = validate_text(value, "reference", MAX_REFERENCE_CHARS)?; + + if reference.contains("://") { + return validate_reference_url(reference); + } + + if reference.starts_with('/') || reference.starts_with('~') || is_windows_absolute(&reference) { + return Err(RepositoryContextError::AbsoluteReference { reference }); + } + if reference.split(['/', '\\']).any(|segment| segment == "..") { + return Err(RepositoryContextError::TraversingReference { reference }); + } + // LoopX parses references as POSIX paths, so normalize separators rather than + // sending a Windows-style path it would treat as one long segment. + Ok(reference.replace('\\', "/")) +} + +fn validate_reference_url(reference: String) -> Result { + let Some((scheme, rest)) = reference.split_once("://") else { + return Err(RepositoryContextError::InvalidReferenceUrl { + reference, + reason: "must be a well-formed URL", + }); + }; + if scheme != "https" { + return Err(RepositoryContextError::InvalidReferenceUrl { + reference, + reason: "must use https", + }); + } + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if authority.is_empty() { + return Err(RepositoryContextError::InvalidReferenceUrl { + reference, + reason: "must name a host", + }); + } + if authority.contains('@') { + return Err(RepositoryContextError::InvalidReferenceUrl { + reference, + reason: "must not embed user info", + }); + } + if rest.contains('?') { + return Err(RepositoryContextError::InvalidReferenceUrl { + reference, + reason: "must not contain query parameters", + }); + } + Ok(reference) +} + +fn is_windows_absolute(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source( + id: &str, + kind: SourceKind, + trust: Trust, + freshness: Freshness, + supports: &[SupportAspect], + ) -> RepositoryContextSource { + RepositoryContextSource { + source_id: id.to_string(), + source_kind: kind, + reference: "src/lib.rs".to_string(), + trust, + freshness, + supports: supports.to_vec(), + summary: "a compact public-safe summary".to_string(), + consultation_state: None, + } + } + + fn grounded_builder() -> RepositoryContextBuilder { + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + builder + .push(source( + "scope", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Current, + &[SupportAspect::ChangeScope, SupportAspect::Reproduction], + )) + .expect("scope source is valid"); + builder + .push(source( + "validation", + SourceKind::TestSurface, + Trust::Verified, + Freshness::Current, + &[SupportAspect::Validation], + )) + .expect("validation source is valid"); + builder + } + + #[test] + fn a_context_covering_all_required_aspects_is_grounded() { + let builder = grounded_builder(); + assert_eq!(builder.context_status(), ContextStatus::Grounded); + assert!(builder.ungrounded_required_aspects().is_empty()); + } + + #[test] + fn a_missing_validation_source_leaves_the_context_partial() { + // Verified against the real CLI: LoopX reports this exact shape as + // `context_status: partial` with validation as the sole unresolved aspect. + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + builder + .push(source( + "scope", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Current, + &[SupportAspect::ChangeScope, SupportAspect::Reproduction], + )) + .expect("scope source is valid"); + + assert_eq!(builder.context_status(), ContextStatus::Partial); + assert_eq!( + builder.ungrounded_required_aspects(), + vec![SupportAspect::Validation] + ); + } + + #[test] + fn stale_sources_ground_nothing() { + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + builder + .push(source( + "stale", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Stale, + &SupportAspect::REQUIRED_FOR_FIX, + )) + .expect("stale source is still valid"); + + assert_eq!(builder.context_status(), ContextStatus::Ungrounded); + assert_eq!( + builder.aspect_status(SupportAspect::ChangeScope), + AspectStatus::Advisory + ); + } + + #[test] + fn advisory_trust_grounds_nothing() { + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + builder + .push(source( + "memory", + SourceKind::MemoryRetrieval, + Trust::Advisory, + Freshness::Current, + &SupportAspect::REQUIRED_FOR_FIX, + )) + .expect("advisory memory source is valid"); + + assert_eq!(builder.context_status(), ContextStatus::Ungrounded); + } + + #[test] + fn an_external_expert_never_grounds_an_aspect() { + // LoopX excludes experts from grounding even when everything else lines + // up, because their answers still need local verification. + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + builder + .push(source( + "expert", + SourceKind::ExternalExpert, + Trust::Advisory, + Freshness::Current, + &SupportAspect::REQUIRED_FOR_FIX, + )) + .expect("expert source is valid"); + + assert_eq!(builder.context_status(), ContextStatus::Ungrounded); + assert_eq!( + builder.aspect_status(SupportAspect::Validation), + AspectStatus::Advisory + ); + } + + #[test] + fn an_unmatched_aspect_is_missing_not_advisory() { + let builder = RepositoryContextBuilder::new().repository_revision("abc123"); + assert_eq!( + builder.aspect_status(SupportAspect::Ownership), + AspectStatus::Missing + ); + } + + #[test] + fn memory_retrieval_must_be_advisory() { + let mut builder = RepositoryContextBuilder::new().repository_revision("abc123"); + let error = builder + .push(source( + "memory", + SourceKind::MemoryRetrieval, + Trust::Verified, + Freshness::Current, + &[SupportAspect::Architecture], + )) + .expect_err("verified memory retrieval is rejected"); + assert!(matches!( + error, + RepositoryContextError::TrustMustBeAdvisory { .. } + )); + } + + #[test] + fn current_freshness_requires_a_revision() { + let mut builder = RepositoryContextBuilder::new(); + let error = builder + .push(source( + "scope", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Current, + &[SupportAspect::ChangeScope], + )) + .expect_err("current freshness without a revision is rejected"); + assert!(matches!( + error, + RepositoryContextError::CurrentFreshnessNeedsRevision { .. } + )); + } + + #[test] + fn a_blank_revision_does_not_count() { + let builder = RepositoryContextBuilder::new().repository_revision(" "); + assert!(!builder.has_revision()); + } + + #[test] + fn absolute_references_are_rejected() { + // The whole point: a local path would leak the operator's filesystem into + // a payload that can reach a public thread. + for path in [ + "/home/user/repo/src/lib.rs", + "~/repo/src/lib.rs", + "C:/codeagent/BitFun/src/lib.rs", + "C:\\codeagent\\BitFun\\src\\lib.rs", + ] { + let error = validate_reference(path).expect_err("absolute paths are rejected"); + assert!( + matches!(error, RepositoryContextError::AbsoluteReference { .. }), + "{path} produced {error:?}" + ); + } + } + + #[test] + fn traversing_references_are_rejected() { + for path in ["../secrets.txt", "src/../../etc/passwd", "src\\..\\out.txt"] { + let error = validate_reference(path).expect_err("traversal is rejected"); + assert!( + matches!(error, RepositoryContextError::TraversingReference { .. }), + "{path} produced {error:?}" + ); + } + } + + #[test] + fn windows_separators_are_normalized_to_posix() { + // LoopX parses references as POSIX paths, so a backslash path would look + // like one long segment to it. + let reference = + validate_reference("src\\web-ui\\src\\app.tsx").expect("relative path is accepted"); + assert_eq!(reference, "src/web-ui/src/app.tsx"); + } + + #[test] + fn a_bare_drive_letter_is_not_treated_as_absolute() { + // "C:" without a separator is a valid relative name, not a drive root. + assert!(validate_reference("C:file.rs").is_ok()); + } + + #[test] + fn https_urls_are_accepted_without_query_parameters() { + let reference = validate_reference("https://github.com/example/repo/blob/main/README.md") + .expect("plain https URL is accepted"); + assert!(reference.starts_with("https://")); + } + + #[test] + fn unsafe_urls_are_rejected() { + for (url, expected) in [ + ("http://example.com/a", "must use https"), + ("https://user:pw@example.com/a", "must not embed user info"), + ( + "https://example.com/a?token=secret", + "must not contain query parameters", + ), + ("https:///no-host", "must name a host"), + ] { + let error = validate_reference(url).expect_err("unsafe URL is rejected"); + match error { + RepositoryContextError::InvalidReferenceUrl { reason, .. } => { + assert_eq!(reason, expected, "for {url}"); + } + other => panic!("expected a URL error for {url}, got {other:?}"), + } + } + } + + #[test] + fn source_ids_must_match_loopx_shape() { + let mut builder = RepositoryContextBuilder::new(); + for bad in ["_leading", "-leading", "has space", "has/slash"] { + let mut candidate = source( + bad, + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + ); + candidate.source_id = bad.to_string(); + let error = builder.push(candidate).expect_err("invalid id is rejected"); + assert!( + matches!(error, RepositoryContextError::InvalidSourceId { .. }), + "{bad} produced {error:?}" + ); + } + // The permitted punctuation still works. + assert!(builder + .push(source( + "bitfun.workspace:icon-branch_1", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + )) + .is_ok()); + } + + #[test] + fn duplicate_source_ids_are_rejected() { + let mut builder = grounded_builder(); + let error = builder + .push(source( + "scope", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Current, + &[SupportAspect::Architecture], + )) + .expect_err("a repeated id is rejected"); + assert!(matches!( + error, + RepositoryContextError::DuplicateSourceId { .. } + )); + } + + #[test] + fn the_source_limit_is_enforced() { + let mut builder = RepositoryContextBuilder::new(); + for index in 0..MAX_SOURCES { + builder + .push(source( + &format!("source{index}"), + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + )) + .expect("sources within the limit are accepted"); + } + let error = builder + .push(source( + "overflow", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + )) + .expect_err("one source past the limit is rejected"); + assert!(matches!( + error, + RepositoryContextError::TooManySources { + limit: MAX_SOURCES, + actual: 17 + } + )); + } + + #[test] + fn summaries_are_bounded_and_whitespace_collapsed() { + let mut builder = RepositoryContextBuilder::new(); + let mut candidate = source( + "long", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + ); + candidate.summary = "s".repeat(MAX_SUMMARY_CHARS + 1); + let error = builder + .push(candidate) + .expect_err("an oversized summary is rejected"); + assert!(matches!( + error, + RepositoryContextError::TooLong { + field: "summary", + .. + } + )); + + let mut spaced = source( + "spaced", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + ); + spaced.summary = " collapse these\n\nspaces ".to_string(); + builder.push(spaced).expect("whitespace is collapsed"); + assert_eq!(builder.sources[0].summary, "collapse these spaces"); + } + + #[test] + fn a_source_needs_at_least_one_aspect() { + let mut builder = RepositoryContextBuilder::new(); + let error = builder + .push(source( + "empty", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[], + )) + .expect_err("a source with no aspects is rejected"); + assert!(matches!( + error, + RepositoryContextError::NoSupportedAspects { .. } + )); + } + + #[test] + fn supports_are_sorted_and_deduplicated() { + let mut builder = RepositoryContextBuilder::new(); + builder + .push(source( + "dupes", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[ + SupportAspect::Validation, + SupportAspect::Architecture, + SupportAspect::Validation, + ], + )) + .expect("duplicate aspects are tolerated"); + assert_eq!( + builder.sources[0].supports, + vec![SupportAspect::Architecture, SupportAspect::Validation] + ); + } + + #[test] + fn an_empty_context_cannot_be_built() { + let error = RepositoryContextBuilder::new() + .build() + .expect_err("an empty context is rejected"); + assert_eq!(error, RepositoryContextError::NoSources); + } + + #[test] + fn the_payload_serializes_to_loopx_field_names() { + let context = grounded_builder().build().expect("context builds"); + let json = serde_json::to_value(&context).expect("context serializes"); + + assert_eq!(json["schema_version"], SCHEMA_VERSION); + assert_eq!(json["repository_revision"], "abc123"); + assert_eq!(json["sources"][0]["source_kind"], "source_code"); + assert_eq!(json["sources"][0]["trust"], "verified"); + assert_eq!(json["sources"][0]["freshness"], "current"); + assert_eq!(json["sources"][0]["supports"][0], "change_scope"); + assert_eq!(json["sources"][1]["source_kind"], "test_surface"); + // LoopX rejects unknown fields, so an absent consultation state must be + // omitted rather than serialized as null. + assert!(json["sources"][0].get("consultation_state").is_none()); + } + + #[test] + fn a_revisionless_payload_omits_the_revision_field() { + let mut builder = RepositoryContextBuilder::new(); + builder + .push(source( + "unknown", + SourceKind::SourceCode, + Trust::Verified, + Freshness::Unknown, + &[SupportAspect::ChangeScope], + )) + .expect("source is valid"); + let json = serde_json::to_value(builder.build().expect("context builds")) + .expect("context serializes"); + assert!(json.get("repository_revision").is_none()); + } +} diff --git a/src/crates/services/services-integrations/src/plugin_source.rs b/src/crates/services/services-integrations/src/plugin_source.rs index fa50a80c20..089732082f 100644 --- a/src/crates/services/services-integrations/src/plugin_source.rs +++ b/src/crates/services/services-integrations/src/plugin_source.rs @@ -3157,6 +3157,7 @@ mod tests { map_activation_store_error, map_load_store_error, native_path_identity, persist_trust_bytes_with_parent_sync, read_bounded_reader, read_scanned_file, replace_file_atomically, trust_file_identity, trust_store_issue_code, workspace_scope, + native_path_identity, ManagedPluginSourceError, ManagedPluginSourceService, OperationScanBudget, PluginPackageManifest, PluginPackageRoot, PluginPackageScope, PluginSourceDiscovery, PluginSourceIssue, PluginSourceIssueCode, PluginSourceStoreError, PluginTrustScope, diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index dcd87661cb..7dd4fd25d9 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -42,6 +42,9 @@ const DEFAULT_ISSUE_PAGE: u32 = 1; const DEFAULT_ISSUE_PAGE_SIZE: u32 = 100; const MAX_ISSUE_PAGE_SIZE: u32 = 100; const MAX_ISSUE_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// A list page carries no bodies, so it needs far less headroom than one issue's +/// full evidence — but titles and label sets across 100 rows still add up. +const MAX_ISSUE_LIST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; const MAX_ISSUE_COMMENTS_RESPONSE_BYTES: usize = 8 * 1024 * 1024; const MAX_ISSUE_BODY_CHARS: usize = 128_000; const MAX_ISSUE_COMMENT_BODY_CHARS: usize = 32_000; @@ -322,6 +325,78 @@ pub struct ReviewPlatformIssueEvidence { pub next_cursor: Option, } +/// Inputs for [`ReviewPlatformService::list_issues`]. +#[derive(Debug, Clone, Copy)] +pub struct ReviewPlatformListIssuesRequest<'a> { + pub platform: ReviewPlatformKind, + pub host: &'a str, + pub project_path: &'a str, + pub state: ReviewPlatformIssueState, + pub page: Option, + pub per_page: Option, + /// Local checkout used to resolve provider auth, when one is available. + pub repository_path: Option<&'a str>, +} + +/// Which issues to enumerate. The two providers spell these differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformIssueState { + #[default] + Open, + Closed, + All, +} + +impl ReviewPlatformIssueState { + fn github_value(self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::All => "all", + } + } + + /// `None` means "send no state filter", which is how GitLab expresses "all". + fn gitlab_value(self) -> Option<&'static str> { + match self { + Self::Open => Some("opened"), + Self::Closed => Some("closed"), + Self::All => None, + } + } +} + +/// One row of an issue list. +/// +/// Deliberately lighter than [`ReviewPlatformIssueEvidence`]: enumerating a +/// repository's open issues must not pull every body and comment thread. Callers +/// that need the full evidence for one issue fetch it separately. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformIssueSummary { + pub issue_id: String, + pub number: i64, + pub title: String, + pub state: String, + pub author: Option, + pub labels: Vec, + pub comments_count: i64, + pub created_at: Option, + pub updated_at: Option, + pub web_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformIssuePage { + pub platform: ReviewPlatformKind, + pub host: String, + pub project_path: String, + pub items: Vec, + pub pagination: ReviewPlatformPagination, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ReviewPlatformCommit { @@ -1098,6 +1173,35 @@ impl ReviewPlatformService { acquire_issue_evidence(&context, &identity, IssuePagination::new(page, per_page)).await } + /// Enumerate a repository's issues, newest activity first. + /// + /// Pull requests are excluded even on GitHub, whose issues endpoint returns + /// them inline. Takes a request struct rather than positional parameters + /// because the sibling `issue` method is already at clippy's argument limit. + pub async fn list_issues( + &self, + request: ReviewPlatformListIssuesRequest<'_>, + ) -> Result { + let auth_tokens = self.load_stored_tokens().await?; + let host = normalize_provider_host(request.host)?; + let project_path = normalize_project_path(request.platform, request.project_path)?; + let context = self + .provider_context_for_identity_request( + request.platform, + &host, + &project_path, + request.repository_path, + &auth_tokens, + ) + .await?; + acquire_issue_page( + &context, + request.state, + IssuePagination::new(request.page, request.per_page), + ) + .await + } + pub async fn pull_request_review_target_by_identity( &self, platform: ReviewPlatformKind, @@ -3957,6 +4061,126 @@ async fn acquire_issue_evidence( } } +/// Enumerate a repository's issues. +/// +/// GitHub reaches the API through the `gh` CLI while GitLab uses HTTP, mirroring +/// [`acquire_issue_evidence`]. Both paths return summary rows only; a caller that +/// needs one issue's body and comments fetches it separately. +async fn acquire_issue_page( + context: &ProviderContext, + state: ReviewPlatformIssueState, + pagination: IssuePagination, +) -> Result { + let page = pagination.page.to_string(); + let per_page = pagination.per_page.to_string(); + let host = context.remote.host.clone(); + let project_path = context.remote.project_path.clone(); + + match context.remote.platform { + ReviewPlatformKind::Github => { + // GitHub's `/repos/{o}/{r}/issues` endpoint interleaves pull + // requests with issues, so filtering PRs after the fetch starves a + // page down to a handful of issues once the repository carries many + // open PRs (the Issue-Fix panel then shows a truncated queue). The + // search endpoint filters to real issues server-side and reports an + // exact total, which keeps page counts honest. + let url = format!("{}/search/issues", context.api_base_url); + let mut search_query = format!( + "repo:{}/{} is:issue", + context.remote.owner, context.remote.repository_name + ); + // Search has no "all" literal — omitting the qualifier means all. + if state != ReviewPlatformIssueState::All { + search_query.push_str(&format!(" state:{}", state.github_value())); + } + let response = github_api_get_json( + context, + &url, + &[ + ("q".to_string(), search_query), + // Match the list endpoint's newest-first default; search + // would otherwise order by relevance. + ("sort".to_string(), "created".to_string()), + ("order".to_string(), "desc".to_string()), + ("page".to_string(), page), + ("per_page".to_string(), per_page), + ], + MAX_ISSUE_LIST_RESPONSE_BYTES, + ) + .await + .map_err(|error| review_evidence_error(error, "issue_list_response"))?; + + let total = response.get("total_count").and_then(Value::as_u64); + let raw = array_items(response.get("items").unwrap_or(&Value::Null)); + // `is:issue` already excludes PRs; the summary mapper's own PR + // guard stays as a harmless second line of defense. + let items = raw + .iter() + .filter_map(|issue| github_issue_summary_from_value(&host, &project_path, issue)) + .collect::>(); + let has_next = match total { + Some(total) => u64::from(pagination.page) * u64::from(pagination.per_page) < total, + None => raw.len() == pagination.per_page as usize, + }; + + Ok(ReviewPlatformIssuePage { + platform: ReviewPlatformKind::Github, + host, + project_path, + items, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + ReviewPlatformKind::Gitlab => { + let project = urlencoding::encode(&project_path); + let url = format!("{}/projects/{}/issues", context.api_base_url, project); + let client = http_client()?; + let mut request = gitlab_request(client, &url, context.token.as_deref()).query(&[ + ("page", page.as_str()), + ("per_page", per_page.as_str()), + ("order_by", "updated_at"), + ("sort", "desc"), + ]); + // GitLab has no "all" literal — you omit the filter entirely. Sending + // `state=` would be a malformed value rather than an absent one. + if let Some(state) = state.gitlab_value() { + request = request.query(&[("state", state)]); + } + let response = + send_review_json_response_bounded(request, MAX_ISSUE_LIST_RESPONSE_BYTES) + .await + .map_err(|error| review_evidence_http_error(error, "issue_list_response"))?; + + let items = array_items(&response.value) + .iter() + .filter_map(|issue| gitlab_issue_summary_from_value(&host, &project_path, issue)) + .collect::>(); + + Ok(ReviewPlatformIssuePage { + platform: ReviewPlatformKind::Gitlab, + host, + project_path, + items, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: None, + // GitLab is authoritative about the next page via a header. + has_next: gitlab_next_page(&response.headers, pagination.page).is_some(), + }, + }) + } + platform => Err(ReviewPlatformError::UnsupportedPlatform( + platform_label(platform).to_string(), + )), + } +} + fn review_evidence_http_error(error: ReviewHttpError, resource: &str) -> ReviewPlatformError { match error { ReviewHttpError::ResponseTooLarge { limit_bytes } => { @@ -6193,6 +6417,84 @@ fn map_gitlab_issue( ) } +/// Map one GitHub issue list entry to a summary row. +/// +/// Returns `None` for pull requests: GitHub's issues endpoint returns them +/// alongside real issues, distinguished only by a `pull_request` member. Skipping +/// them here mirrors [`reject_pull_request_issue_target`] for the single-issue +/// path. +fn github_issue_summary_from_value( + host: &str, + project_path: &str, + issue: &Value, +) -> Option { + if issue.get("pull_request").is_some() { + return None; + } + let number = value_i64(issue, "number"); + if number <= 0 { + return None; + } + let labels = array_items(issue.get("labels").unwrap_or(&Value::Null)) + .iter() + .filter_map(|label| { + label + .as_str() + .map(str::to_string) + .or_else(|| optional_string(label, "name")) + }) + .collect::>(); + Some(ReviewPlatformIssueSummary { + issue_id: number.to_string(), + number, + title: value_string(issue, "title"), + state: value_string(issue, "state"), + author: nested_optional_string(issue, &["user", "login"]), + labels, + comments_count: value_i64(issue, "comments"), + created_at: optional_string(issue, "created_at"), + updated_at: optional_string(issue, "updated_at"), + web_url: first_non_empty(&[ + value_string(issue, "html_url"), + format!("https://{host}/{project_path}/issues/{number}"), + ]), + }) +} + +/// Map one GitLab issue list entry to a summary row. +/// +/// GitLab identifies issues by project-scoped `iid`, not the global `id`. +fn gitlab_issue_summary_from_value( + host: &str, + project_path: &str, + issue: &Value, +) -> Option { + let number = value_i64(issue, "iid"); + if number <= 0 { + return None; + } + let labels = array_items(issue.get("labels").unwrap_or(&Value::Null)) + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + Some(ReviewPlatformIssueSummary { + issue_id: number.to_string(), + number, + title: value_string(issue, "title"), + state: value_string(issue, "state"), + author: nested_optional_string(issue, &["author", "username"]), + labels, + comments_count: value_i64(issue, "user_notes_count"), + created_at: optional_string(issue, "created_at"), + updated_at: optional_string(issue, "updated_at"), + web_url: first_non_empty(&[ + value_string(issue, "web_url"), + format!("https://{host}/{project_path}/-/issues/{number}"), + ]), + }) +} + #[allow(clippy::too_many_arguments)] fn finalize_issue_mapping( identity: &ProviderIssueIdentity, @@ -9768,4 +10070,238 @@ mod tests { provider_for(existing_remote_context.remote.platform), )); } + + #[test] + fn github_issue_summary_skips_pull_requests() { + // GitHub's issues endpoint returns PRs inline, marked only by this member. + // Enumerating issues must not surface them. + let pull_request = serde_json::json!({ + "number": 7, + "title": "a pull request", + "state": "open", + "pull_request": {"url": "https://api.github.com/repos/example/repo/pulls/7"}, + }); + assert!( + github_issue_summary_from_value("github.com", "example/repo", &pull_request).is_none() + ); + } + + #[test] + fn github_issue_summary_maps_labels_from_objects_and_strings() { + let issue = serde_json::json!({ + "number": 1849, + "title": "workspace icons are inconsistent", + "state": "open", + "comments": 2, + "html_url": "https://github.com/example/repo/issues/1849", + "user": {"login": "reporter"}, + "created_at": "2026-07-29T07:35:37Z", + "updated_at": "2026-07-30T06:16:48Z", + // GitHub sends label objects; some mirrors send bare strings. + "labels": [{"name": "bug"}, "needs-triage"], + }); + + let summary = github_issue_summary_from_value("github.com", "example/repo", &issue) + .expect("a real issue maps"); + + assert_eq!(summary.issue_id, "1849"); + assert_eq!(summary.number, 1849); + assert_eq!(summary.state, "open"); + assert_eq!(summary.author.as_deref(), Some("reporter")); + assert_eq!(summary.labels, vec!["bug", "needs-triage"]); + assert_eq!(summary.comments_count, 2); + assert_eq!( + summary.web_url, + "https://github.com/example/repo/issues/1849" + ); + } + + #[test] + fn github_issue_summary_falls_back_to_a_derived_url() { + let issue = serde_json::json!({"number": 5, "title": "no html_url", "state": "open"}); + let summary = github_issue_summary_from_value("github.example.com", "team/repo", &issue) + .expect("a real issue maps"); + assert_eq!( + summary.web_url, + "https://github.example.com/team/repo/issues/5" + ); + } + + #[test] + fn github_issue_summary_rejects_a_missing_number() { + let issue = serde_json::json!({"title": "no number", "state": "open"}); + assert!(github_issue_summary_from_value("github.com", "example/repo", &issue).is_none()); + } + + #[test] + fn gitlab_issue_summary_prefers_the_project_scoped_iid() { + // `id` is global and `iid` is project-scoped; only `iid` addresses the + // issue through the project API. + let issue = serde_json::json!({ + "id": 99001, + "iid": 12, + "title": "a gitlab issue", + "state": "opened", + "user_notes_count": 3, + "web_url": "https://gitlab.com/example/repo/-/issues/12", + "author": {"username": "reporter"}, + "labels": ["bug", "frontend"], + }); + + let summary = gitlab_issue_summary_from_value("gitlab.com", "example/repo", &issue) + .expect("a real issue maps"); + + assert_eq!(summary.issue_id, "12"); + assert_eq!(summary.number, 12); + assert_eq!(summary.comments_count, 3); + assert_eq!(summary.labels, vec!["bug", "frontend"]); + } + + #[test] + fn gitlab_issue_summary_rejects_a_missing_iid() { + // A global `id` alone is not addressable, so it must not pass. + let issue = serde_json::json!({"id": 99001, "title": "no iid", "state": "opened"}); + assert!(gitlab_issue_summary_from_value("gitlab.com", "example/repo", &issue).is_none()); + } + + #[test] + fn issue_state_maps_to_each_provider_vocabulary() { + assert_eq!(ReviewPlatformIssueState::Open.github_value(), "open"); + assert_eq!( + ReviewPlatformIssueState::Open.gitlab_value(), + Some("opened") + ); + assert_eq!(ReviewPlatformIssueState::Closed.github_value(), "closed"); + assert_eq!( + ReviewPlatformIssueState::Closed.gitlab_value(), + Some("closed") + ); + assert_eq!(ReviewPlatformIssueState::All.github_value(), "all"); + // GitLab has no "all" literal: the filter is omitted instead. + assert_eq!(ReviewPlatformIssueState::All.gitlab_value(), None); + assert_eq!( + ReviewPlatformIssueState::default(), + ReviewPlatformIssueState::Open + ); + } + + #[tokio::test] + async fn gitlab_issue_page_filters_pull_requests_and_reads_the_next_page_header() { + let body = serde_json::json!([ + { + "iid": 12, + "title": "first", + "state": "opened", + "user_notes_count": 1, + "web_url": "https://gitlab.com/example/repo/-/issues/12", + "author": {"username": "one"}, + "labels": ["bug"], + }, + // No iid: not addressable, so it must be dropped rather than mapped. + {"id": 99002, "title": "malformed", "state": "opened"}, + ]) + .to_string(); + let api_base_url = spawn_single_review_response( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nx-next-page: 2\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes(), + ); + let context = gitlab_trace_context(api_base_url); + + let page = acquire_issue_page( + &context, + ReviewPlatformIssueState::Open, + IssuePagination::new(Some(1), Some(50)), + ) + .await + .expect("issue page should load"); + + assert_eq!(page.platform, ReviewPlatformKind::Gitlab); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].number, 12); + assert_eq!(page.pagination.per_page, 50); + // GitLab is authoritative about continuation via the header. + assert!(page.pagination.has_next); + } + + #[tokio::test] + async fn gitlab_issue_page_reports_no_next_page_without_the_header() { + let body = "[]"; + let api_base_url = spawn_single_review_response( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes(), + ); + let context = gitlab_trace_context(api_base_url); + + let page = acquire_issue_page( + &context, + ReviewPlatformIssueState::All, + IssuePagination::new(None, None), + ) + .await + .expect("issue page should load"); + + assert!(page.items.is_empty()); + assert!(!page.pagination.has_next); + } + + #[tokio::test] + async fn issue_page_rejects_unsupported_platforms() { + let mut context = gitlab_trace_context("http://127.0.0.1:1".to_string()); + context.remote.platform = ReviewPlatformKind::Gitcode; + + let result = acquire_issue_page( + &context, + ReviewPlatformIssueState::Open, + IssuePagination::new(None, None), + ) + .await; + + assert!(matches!( + result, + Err(ReviewPlatformError::UnsupportedPlatform(_)) + )); + } + + /// Exercises the real GitHub path, which goes through the `gh` CLI rather than + /// HTTP — the mocked tests above cannot cover it. Ignored by default because it + /// needs network access and an authenticated `gh`. + #[tokio::test] + #[ignore = "requires network access and an authenticated gh CLI"] + async fn github_issue_page_enumerates_a_public_repository() { + let tokens = ReviewPlatformAuthTokens::default(); + let context = provider_context_for_identity( + ReviewPlatformKind::Github, + "github.com", + "GCWing/BitFun", + &tokens, + ) + .expect("public GitHub context should be valid"); + + let page = acquire_issue_page( + &context, + ReviewPlatformIssueState::Open, + IssuePagination::new(Some(1), Some(5)), + ) + .await + .expect("issue page should load from GitHub"); + + assert_eq!(page.platform, ReviewPlatformKind::Github); + assert_eq!(page.project_path, "GCWing/BitFun"); + assert!(!page.items.is_empty(), "the repository has open issues"); + for item in &page.items { + assert!(item.number > 0, "issue numbers are positive: {item:?}"); + assert!(!item.title.is_empty(), "issues have titles: {item:?}"); + assert_eq!(item.state, "open", "state filter applied: {item:?}"); + assert!( + item.web_url.contains("/issues/"), + "pull requests must be filtered out: {item:?}" + ); + } + } } diff --git a/src/crates/services/services-integrations/tests/loopx_issue_fix_contracts.rs b/src/crates/services/services-integrations/tests/loopx_issue_fix_contracts.rs new file mode 100644 index 0000000000..460b5da201 --- /dev/null +++ b/src/crates/services/services-integrations/tests/loopx_issue_fix_contracts.rs @@ -0,0 +1,616 @@ +//! Contract tests for the `loopx` issue-fix bridge. +//! +//! These exercise the real `loopx` CLI when it is installed. When it is not, each +//! test reports a skip rather than failing, so CI hosts without LoopX stay green — +//! the feature is probe-gated at runtime for exactly the same reason. + +use bitfun_services_integrations::loopx_issue_fix::orchestrator::{ + ContextGrounding, ExecutionMode, FixRoute, IssueFixOrchestrator, IssueFixRequest, NextStep, + ReproductionStatus, ScopeClass, +}; +use bitfun_services_integrations::loopx_issue_fix::repository_context::{ + ContextStatus, Freshness, RepositoryContextBuilder, RepositoryContextSource, SourceKind, + SupportAspect, Trust, +}; +use bitfun_services_integrations::loopx_issue_fix::{LoopxIssueFix, LoopxIssueFixError}; + +/// Resolve LoopX or explain the skip. Keeps the skip reason in one place. +fn loopx_or_skip(test_name: &str) -> Option { + match LoopxIssueFix::probe() { + Some(loopx) => Some(loopx), + None => { + eprintln!("skipping {test_name}: loopx is not installed on this host"); + None + } + } +} + +const ISSUE_URL: &str = "https://github.com/GCWing/BitFun/issues/1849"; + +/// A grounded repository context, built through the real generator. +/// +/// LoopX will not select `fix_pr` without one — an ungrounded request yields +/// `repository_context_not_provided` in its reason codes and falls back to +/// `triage_only`. Building this with `RepositoryContextBuilder` rather than a +/// hand-written literal is the point: it proves the generator's own prediction of +/// "grounded" matches what LoopX actually decides. +fn grounded_repository_context( +) -> bitfun_services_integrations::loopx_issue_fix::repository_context::RepositoryContext { + let mut builder = RepositoryContextBuilder::new() + .repository_revision("9ed5c5fec0000000000000000000000000000000"); + builder + .push(RepositoryContextSource { + source_id: "workspace-item-icon".to_string(), + source_kind: SourceKind::SourceCode, + reference: + "src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx" + .to_string(), + trust: Trust::Verified, + freshness: Freshness::Current, + supports: vec![ + SupportAspect::Architecture, + SupportAspect::ChangeScope, + SupportAspect::Reproduction, + ], + summary: "Icon ternary renders an arrow for the active workspace row and a folder for its siblings." + .to_string(), + consultation_state: None, + }) + .expect("the change-scope source is valid"); + builder + .push(RepositoryContextSource { + source_id: "workspace-layout-guard".to_string(), + source_kind: SourceKind::TestSurface, + reference: + "src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts" + .to_string(), + trust: Trust::Verified, + freshness: Freshness::Current, + supports: vec![SupportAspect::Validation], + summary: "Raw-text layout guard over the workspace component; focused coverage would need adding." + .to_string(), + consultation_state: None, + }) + .expect("the validation source is valid"); + + // If the generator and LoopX ever disagree about what grounds an aspect, this + // assertion fails before the subprocess call and localizes the bug here. + assert_eq!( + builder.context_status(), + ContextStatus::Grounded, + "the generator should predict a grounded context" + ); + + builder.build().expect("context builds") +} + +/// The same context, written to a temp file for the raw-CLI tests below. +fn write_repository_context(dir: &std::path::Path) -> std::path::PathBuf { + let context = grounded_repository_context(); + let path = dir.join("repository-context.json"); + std::fs::write( + &path, + serde_json::to_vec_pretty(&context).expect("context serializes"), + ) + .expect("context file is written"); + path +} + +/// Read-only feasibility flags. `--no-write-domain-state` keeps LoopX from +/// touching goal state, and every issue-fix projection is write-free by design. +fn feasibility_args<'a>(scope_class: &'a str, context_path: &'a str) -> Vec<&'a str> { + vec![ + "feasibility", + "--repo", + "GCWing/BitFun", + "--issue-ref", + "1849", + "--url", + ISSUE_URL, + "--reproduction-status", + "confirmed", + "--reproduction-label", + "workspace-row-icon-branch", + // Naming a validation surface is mandatory for `fix_pr`; without it LoopX + // reports `validation_surface_named` as unmet and downgrades to triage. + "--validation-label", + "web-ui focused vitest", + "--repository-context-json", + context_path, + "--no-write-domain-state", + "--scope-class", + scope_class, + ] +} + +#[test] +fn probe_reports_a_usable_program_path_when_loopx_is_installed() { + let Some(loopx) = loopx_or_skip("probe_reports_a_usable_program_path_when_loopx_is_installed") + else { + return; + }; + assert!( + loopx.program().is_file(), + "probe returned a path that is not a file: {}", + loopx.program().display() + ); +} + +#[tokio::test] +async fn bounded_scope_with_reproduction_selects_the_fix_pr_route() { + let Some(loopx) = loopx_or_skip("bounded_scope_with_reproduction_selects_the_fix_pr_route") + else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let context = write_repository_context(dir.path()); + let context = context.to_str().expect("context path is valid UTF-8"); + + let packet = loopx + .issue_fix(feasibility_args("bounded", context)) + .await + .expect("feasibility projection succeeds"); + + assert_eq!(packet["decision"]["route"], "fix_pr"); + assert_eq!(packet["transition"]["decision"], "runnable_successor"); + // The whole integration rests on LoopX never writing; assert it explicitly. + assert_eq!(packet["external_writes_performed"], false); + assert_eq!(packet["todo_write_performed"], false); +} + +#[tokio::test] +async fn oversized_scope_refuses_to_open_a_pull_request() { + let Some(loopx) = loopx_or_skip("oversized_scope_refuses_to_open_a_pull_request") else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let context = write_repository_context(dir.path()); + let context = context.to_str().expect("context path is valid UTF-8"); + + let packet = loopx + .issue_fix(feasibility_args("oversized", context)) + .await + .expect("feasibility projection succeeds"); + + // Evidence is present and the issue reproduces, yet an oversized change scope + // must still not produce a PR. This gate is the reason for the integration. + assert_eq!(packet["decision"]["route"], "triage_only"); + assert_eq!(packet["transition"]["decision"], "no_followup"); +} + +/// Naming a validation surface via `--validation-label` is mandatory for +/// `fix_pr`. A fully grounded context does not substitute for it: LoopX refuses to +/// open a PR when it cannot see how the fix would be checked. +/// +/// Read together with `the_generator_prediction_matches_what_loopx_decides`, which +/// shows the converse — the label without full grounding *is* enough. So the label +/// is the real gate, and context grounding is not. +#[tokio::test] +async fn omitting_the_validation_label_downgrades_to_triage() { + let Some(loopx) = loopx_or_skip("omitting_the_validation_label_downgrades_to_triage") else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let context = write_repository_context(dir.path()); + let context = context.to_str().expect("context path is valid UTF-8"); + + let packet = loopx + .issue_fix([ + "feasibility", + "--repo", + "GCWing/BitFun", + "--issue-ref", + "1849", + "--url", + ISSUE_URL, + "--reproduction-status", + "confirmed", + "--reproduction-label", + "workspace-row-icon-branch", + "--repository-context-json", + context, + "--no-write-domain-state", + "--scope-class", + "bounded", + ]) + .await + .expect("feasibility projection succeeds"); + + assert_eq!(packet["decision"]["route"], "triage_only"); + let reasons = packet["decision"]["reason_codes"] + .as_array() + .expect("reason codes are an array"); + assert!( + reasons + .iter() + .any(|code| code == "repository_context_grounded"), + "grounding is intact; only the label is missing: {reasons:?}" + ); +} + +/// The generator predicts grounding locally so callers can decide what else to +/// read before paying for a subprocess call. That prediction is only useful if it +/// agrees with LoopX, so assert the agreement against the real CLI. +/// +/// Note what this does *not* claim: a partial context still permits `fix_pr` as +/// long as `--validation-label` names a validation surface. LoopX distinguishes +/// "which test files did you read" (a context source) from "how will you check +/// this fix" (the label), and only the latter gates the route. +#[tokio::test] +async fn the_generator_prediction_matches_what_loopx_decides() { + let Some(loopx) = loopx_or_skip("the_generator_prediction_matches_what_loopx_decides") else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + + // A context with no validation source: the generator must call this partial + // and name validation as the gap. + let mut builder = RepositoryContextBuilder::new() + .repository_revision("9ed5c5fec0000000000000000000000000000000"); + builder + .push(RepositoryContextSource { + source_id: "scope-only".to_string(), + source_kind: SourceKind::SourceCode, + reference: + "src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx" + .to_string(), + trust: Trust::Verified, + freshness: Freshness::Current, + supports: vec![SupportAspect::ChangeScope, SupportAspect::Reproduction], + summary: "Only change scope and reproduction; nothing covers validation.".to_string(), + consultation_state: None, + }) + .expect("the scope source is valid"); + + assert_eq!( + builder.context_status(), + ContextStatus::Partial, + "no validation source means partial" + ); + assert_eq!( + builder.ungrounded_required_aspects(), + vec![SupportAspect::Validation] + ); + + let path = dir.path().join("partial-context.json"); + std::fs::write( + &path, + serde_json::to_vec_pretty(&builder.build().expect("context builds")) + .expect("context serializes"), + ) + .expect("context file is written"); + let path = path.to_str().expect("path is valid UTF-8"); + + let packet = loopx + .issue_fix(feasibility_args("bounded", path)) + .await + .expect("feasibility projection succeeds"); + + // LoopX must reach the same verdict the generator predicted, aspect for + // aspect. This is the assertion that catches drift between the two. + let context = &packet["observation"]["repository_context"]; + assert_eq!(context["context_status"], "partial"); + assert_eq!( + context["unresolved_required_aspects"] + .as_array() + .expect("unresolved aspects are an array"), + &vec![serde_json::Value::from("validation")] + ); + assert_eq!(context["coverage"]["change_scope"]["status"], "grounded"); + assert_eq!(context["coverage"]["reproduction"]["status"], "grounded"); + assert_eq!(context["coverage"]["validation"]["status"], "missing"); + + let reasons = packet["decision"]["reason_codes"] + .as_array() + .expect("reason codes are an array") + .iter() + .filter_map(|code| code.as_str()) + .collect::>(); + assert!( + reasons.contains(&"repository_context_partial"), + "a partial context must be recorded as such: {reasons:?}" + ); + assert!( + !reasons.contains(&"repository_context_grounded"), + "a partial context must not read as grounded: {reasons:?}" + ); +} + +#[tokio::test] +async fn in_band_refusal_becomes_a_rejected_error() { + let Some(loopx) = loopx_or_skip("in_band_refusal_becomes_a_rejected_error") else { + return; + }; + + // Omitting --reproduction-label makes LoopX refuse. It reports refusals as + // `{"ok": false, "error": ...}` on stdout *and* exits nonzero, so the bridge + // must parse stdout first — otherwise the reason is lost behind a bare exit + // code, which is exactly the bug this test caught. + let error = loopx + .issue_fix([ + "feasibility", + "--repo", + "GCWing/BitFun", + "--issue-ref", + "1849", + "--url", + ISSUE_URL, + "--reproduction-status", + "confirmed", + "--scope-class", + "bounded", + "--no-write-domain-state", + ]) + .await + .expect_err("a missing required label must surface as an error"); + + match error { + LoopxIssueFixError::Rejected(reason) => { + assert!( + reason.contains("reproduction_label"), + "unexpected refusal reason: {reason}" + ); + } + other => panic!("expected an in-band refusal, got {other:?}"), + } +} + +#[tokio::test] +async fn an_unknown_subcommand_surfaces_a_nonzero_exit() { + let Some(loopx) = loopx_or_skip("an_unknown_subcommand_surfaces_a_nonzero_exit") else { + return; + }; + + let error = loopx + .issue_fix(["definitely-not-a-subcommand"]) + .await + .expect_err("an unknown subcommand must fail"); + + assert!( + matches!(error, LoopxIssueFixError::Exit { .. }), + "expected a nonzero exit, got {error:?}" + ); +} + +/// LoopX's own subprocess calls omit `encoding=`, so on a non-UTF-8 locale it +/// decodes `gh` output as the local codepage and dies. The bridge sets +/// `PYTHONUTF8=1` to fix all of its call sites at once; this test proves the +/// fetch path works, which is exactly what fails without it. +#[tokio::test] +async fn fetching_public_metadata_survives_a_non_utf8_host_locale() { + let Some(loopx) = loopx_or_skip("fetching_public_metadata_survives_a_non_utf8_host_locale") + else { + return; + }; + + let result = loopx + .issue_fix([ + "workflow-plan", + "--repo", + "GCWing/BitFun", + "--issue-ref", + "1849", + "--url", + ISSUE_URL, + "--fetch-metadata", + ]) + .await; + + match result { + Ok(packet) => { + assert_eq!(packet["external_reads_performed"], true); + // The issue title is Chinese; reaching this point means no mojibake. + assert_eq!(packet["issue_signal"]["repo"], "GCWing/BitFun"); + } + // `gh` may be absent or unauthenticated on a CI host. That is an + // environment gap, not an encoding regression, so tolerate it — but let + // any other failure fail the test. + Err(LoopxIssueFixError::Rejected(reason)) => { + assert!( + reason.contains("gh") || reason.contains("metadata fetch"), + "unexpected refusal while fetching metadata: {reason}" + ); + eprintln!("tolerating environment gap: {reason}"); + } + Err(other) => panic!("metadata fetch failed unexpectedly: {other:?}"), + } +} + +/// One issue's request, pointing at the real BitFun repository. +fn issue_request<'a>( + context: &'a bitfun_services_integrations::loopx_issue_fix::repository_context::RepositoryContext, + scope_class: ScopeClass, +) -> IssueFixRequest<'a> { + IssueFixRequest { + repo: "GCWing/BitFun", + issue_ref: "1849", + issue_url: ISSUE_URL, + context, + validation_label: "web-ui focused vitest", + reproduction_label: "workspace-row-icon-branch", + reproduction_status: ReproductionStatus::Confirmed, + scope_class, + base_branch: "main", + } +} + +/// The orchestrator's whole reason to exist: reading LoopX's nested JSON without +/// mistaking a refusal for approval. This drives the real CLI end to end. +#[tokio::test] +async fn the_orchestrator_plans_a_bounded_issue_as_a_fix() { + let Some(loopx) = loopx_or_skip("the_orchestrator_plans_a_bounded_issue_as_a_fix") else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let context = grounded_repository_context(); + let request = issue_request(&context, ScopeClass::Bounded); + + let outcome = IssueFixOrchestrator::new(&loopx) + .plan_issue( + &request, + env!("CARGO_MANIFEST_DIR"), + dir.path(), + // Dry run: nothing may touch the working tree in a test. + ExecutionMode::DryRun, + ) + .await + .expect("planning succeeds"); + + assert_eq!(outcome.issue_ref, "1849"); + assert_eq!(outcome.feasibility.route, FixRoute::FixPr); + assert_eq!(outcome.feasibility.next_step, NextStep::RunnableSuccessor); + assert_eq!( + outcome.feasibility.context_grounding, + ContextGrounding::Grounded + ); + assert!(!outcome.feasibility.reason_codes.is_empty()); + + let branch = outcome.branch.expect("a fix route prepares a branch"); + assert_eq!(branch.issue_branch, "codex/issue-1849-fix"); + assert_eq!(branch.base_branch, "main"); + assert_eq!(branch.branch_action, "dry_run"); + assert!(!branch.branch_ready, "a dry run creates nothing"); + assert!(!branch.validation_executed); + // The PR gate must stay shut: a dry run has neither validation nor evidence. + assert!( + !branch.may_open_pull_request(outcome.feasibility.route), + "a dry run must never permit a pull request" + ); +} + +/// An oversized scope must not even reach branch preparation. Under +/// `ExecutionMode::Execute` that would create a branch LoopX just declined to +/// justify, so the skip is a safety property, not an optimization. +#[tokio::test] +async fn the_orchestrator_skips_the_branch_on_a_triage_route() { + let Some(loopx) = loopx_or_skip("the_orchestrator_skips_the_branch_on_a_triage_route") else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let context = grounded_repository_context(); + let request = issue_request(&context, ScopeClass::Oversized); + + let outcome = IssueFixOrchestrator::new(&loopx) + .plan_issue( + &request, + env!("CARGO_MANIFEST_DIR"), + dir.path(), + ExecutionMode::DryRun, + ) + .await + .expect("planning succeeds"); + + assert_eq!(outcome.feasibility.route, FixRoute::TriageOnly); + assert_eq!(outcome.feasibility.next_step, NextStep::NoFollowup); + assert!( + outcome.branch.is_none(), + "a declined route must not prepare a branch" + ); +} + +/// LoopX raises `user_gate` for semantic ambiguity and missing write authority. +/// The orchestrator must surface it as a distinct step a caller cannot cross. +#[tokio::test] +async fn the_orchestrator_surfaces_a_user_gate_from_a_pull_request() { + let Some(loopx) = loopx_or_skip("the_orchestrator_surfaces_a_user_gate_from_a_pull_request") + else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + + let metadata = dir.path().join("pr.json"); + std::fs::write( + &metadata, + serde_json::json!({ + "number": 9999, + "state": "OPEN", + "isDraft": false, + "mergeable": "MERGEABLE", + "reviewDecision": "", + "statusCheckRollup": [{"state": "SUCCESS"}], + }) + .to_string(), + ) + .expect("metadata is written"); + + let correction = dir.path().join("correction.json"); + std::fs::write( + &correction, + serde_json::json!({ + "schema_version": "issue_fix_maintainer_correction_input_v0", + "correction_kind": "semantic_ambiguity", + "source_kind": "maintainer_comment", + "source_ref": "GCWing/BitFun:issues/1849#comment", + "summary": "maintainer suggests highlighting the session instead of the workspace", + "user_question": "Should the arrow be removed or replaced with a check glyph?", + }) + .to_string(), + ) + .expect("correction is written"); + + // Raw call: the orchestrator's lifecycle method does not take a correction, + // so drive the CLI directly and assert the decision the orchestrator would + // then have to classify. + let packet = loopx + .issue_fix([ + "pr-lifecycle", + "--repo", + "GCWing/BitFun", + "--pr-ref", + "9999", + "--issue-ref", + "1849", + "--metadata-json", + metadata.to_str().expect("path is UTF-8"), + "--maintainer-correction-json", + correction.to_str().expect("path is UTF-8"), + "--no-write-domain-state", + ]) + .await + .expect("lifecycle projection succeeds"); + + assert_eq!(packet["transition"]["decision"], "user_gate"); + assert_eq!(packet["transition"]["role"], "user"); + assert!( + NextStep::UserGate.requires_human(), + "the orchestrator must treat this as a human gate" + ); +} + +/// The lifecycle method against a mocked PR state, through the typed path. +#[tokio::test] +async fn the_orchestrator_projects_a_merged_pull_request_as_terminal() { + let Some(loopx) = loopx_or_skip("the_orchestrator_projects_a_merged_pull_request_as_terminal") + else { + return; + }; + let dir = tempfile::tempdir().expect("temp dir is created"); + let metadata = dir.path().join("merged.json"); + std::fs::write( + &metadata, + serde_json::json!({ + "number": 9999, + "state": "MERGED", + "isDraft": false, + "reviewDecision": "APPROVED", + "statusCheckRollup": [{"state": "SUCCESS"}], + }) + .to_string(), + ) + .expect("metadata is written"); + + let outcome = IssueFixOrchestrator::new(&loopx) + .pull_request_lifecycle( + "GCWing/BitFun", + "9999", + "1849", + Some(metadata.to_str().expect("path is UTF-8")), + ) + .await + .expect("lifecycle projection succeeds"); + + assert_eq!(outcome.next_step, NextStep::NoFollowup); + assert_eq!(outcome.state, "MERGED"); + assert_eq!(outcome.state_bucket, "terminal"); + assert!(!outcome.next_step.requires_human()); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 5ca20c00cd..0b872b80dd 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -196,6 +196,11 @@ color: var(--bf-appearance-token-color-text-secondary); } + // Session bound to an enabled scheduled job (Issue-Fix heartbeat host). + &.is-scheduled-host { + color: var(--bf-appearance-token-color-text-secondary); + } + &.is-running { color: var(--bf-appearance-token-color-text-secondary); transition: opacity $motion-fast $easing-standard; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 0b94d08d35..2d336a4f3a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,7 +7,7 @@ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy, CircleHelp, FileDown, ChevronLeft } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy, CircleHelp, FileDown, ChevronLeft, Wrench } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -17,6 +17,7 @@ import { hasPendingAskUserQuestion, resolveTrackedTurn } from '../../../../../fl import { useSceneStore } from '../../../../stores/sceneStore'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; +import { cronAPI } from '@/infrastructure/api/service-api/CronAPI'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { @@ -233,6 +234,10 @@ const SessionsSection: React.FC = ({ const [isExportScopeMenu, setIsExportScopeMenu] = useState(false); const [exportingSessionId, setExportingSessionId] = useState(null); const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); + // Sessions bound to an enabled scheduled job (e.g. the continuous Issue-Fix + // heartbeat) get a distinct icon so users can tell load-bearing sessions + // from ordinary chats before renaming or deleting them. + const [scheduledSessionIds, setScheduledSessionIds] = useState>(new Set()); const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState(null); const editInputRef = useRef(null); const sessionMenuPopoverRef = useRef(null); @@ -271,6 +276,39 @@ const SessionsSection: React.FC = ({ return () => unsubscribe(); }, [flowChatState.sessions]); + // Track which sessions carry an enabled scheduled job. Polled lazily (on + // mount and every 60s) because job changes are rare — start/stop of the + // Issue-Fix loop — and the render side is a pure Set lookup. + useEffect(() => { + let cancelled = false; + const refresh = async () => { + try { + const jobs = await cronAPI.listJobs({ targetKind: 'session' }); + if (cancelled) return; + const bound = new Set(); + for (const job of jobs) { + if (!job.enabled) continue; + const sessionId = job.target.kind === 'session' ? job.target.sessionId : null; + if (sessionId) bound.add(sessionId); + } + setScheduledSessionIds((current) => { + if (current.size === bound.size && [...bound].every((id) => current.has(id))) { + return current; + } + return bound; + }); + } catch { + // Cron service not ready yet (startup) — keep the previous set. + } + }; + void refresh(); + const interval = window.setInterval(() => void refresh(), 60_000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, []); + useEffect(() => { const selector = (s: FlowChatState): string => { const parts: string[] = [s.activeSessionId ?? '']; @@ -1005,13 +1043,29 @@ const SessionsSection: React.FC = ({ const handleDelete = useCallback( async (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); + // Deleting a session that hosts scheduled jobs (e.g. the continuous + // Issue-Fix heartbeat) also stops those jobs — silently, from the + // user's point of view. Surface that consequence before acting; work + // state lives outside the session, so restarting later resumes it. + try { + const jobs = await cronAPI.listJobs({ sessionId }); + if (jobs.some((job) => job.enabled)) { + const confirmed = await confirmWarning( + t('nav.sessions.deleteScheduledConfirmTitle'), + t('nav.sessions.deleteScheduledConfirmMessage') + ); + if (!confirmed) return; + } + } catch (err) { + log.warn('Failed to check scheduled jobs before session delete', err); + } try { await flowChatManager.deleteChatSession(sessionId); } catch (err) { log.error('Failed to delete session', err); } }, - [] + [t] ); const handleArchive = useCallback( @@ -1259,14 +1313,21 @@ const SessionsSection: React.FC = ({ }), }) : null; + const isScheduledHost = scheduledSessionIds.has(session.sessionId); const showRichTooltip = showAssistantInTooltip || isChildSession || showBackgroundSubagentActivity || - isDispatched; + isDispatched || + isScheduledHost; const tooltipContent = showRichTooltip ? (
    {sessionTitle}
    + {isScheduledHost ? ( +
    + {t('nav.sessions.scheduledHost')} +
    + ) : null} {showAssistantInTooltip ? (
    {t('nav.sessions.assistantOwner', { name: trimmedAssistant })} @@ -1311,13 +1372,15 @@ const SessionsSection: React.FC = ({ sessionTitle ); const SessionIcon = - sessionModeKey === 'cowork' - ? ClipboardList - : sessionModeKey === 'claw' - ? showAssistantInTooltip - ? Panda - : Bot - : Code2; + isScheduledHost + ? Wrench + : sessionModeKey === 'cowork' + ? ClipboardList + : sessionModeKey === 'claw' + ? showAssistantInTooltip + ? Panda + : Bot + : Code2; const isRowActive = isSessionNavRowActive({ rowSessionId: session.sessionId, activeTabId, @@ -1383,11 +1446,13 @@ const SessionsSection: React.FC = ({ size={14} className={[ 'bitfun-nav-panel__inline-item-icon', - sessionModeKey === 'cowork' - ? 'is-cowork' - : sessionModeKey === 'claw' - ? 'is-claw' - : 'is-code', + isScheduledHost + ? 'is-scheduled-host' + : sessionModeKey === 'cowork' + ? 'is-cowork' + : sessionModeKey === 'claw' + ? 'is-claw' + : 'is-code', ].join(' ')} /> )} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 8af5e04dee..e8c149e83b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -2,7 +2,6 @@ import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, use import { createPortal } from 'react-dom'; import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil, Server } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, InputDialog, Modal, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; @@ -802,15 +801,9 @@ const WorkspaceItem: React.FC = ({ data-workspace-id={workspace.id} >
    }> + + + ); + case 'browser': return ( {t('flexiblePanel.loading.terminal')}
    }> diff --git a/src/web-ui/src/app/components/panels/base/types.ts b/src/web-ui/src/app/components/panels/base/types.ts index 033b3fb46f..512fed77dc 100644 --- a/src/web-ui/src/app/components/panels/base/types.ts +++ b/src/web-ui/src/app/components/panels/base/types.ts @@ -30,6 +30,7 @@ export type PanelContentType = | 'background-command-output' | 'review-platform' | 'review-platform-pr-detail' + | 'issue-fix' | 'terminal' | 'generative-widget' | 'bitfun-canvas' diff --git a/src/web-ui/src/app/components/panels/base/utils.ts b/src/web-ui/src/app/components/panels/base/utils.ts index 429b26c72b..25873ff29e 100644 --- a/src/web-ui/src/app/components/panels/base/utils.ts +++ b/src/web-ui/src/app/components/panels/base/utils.ts @@ -20,6 +20,7 @@ import { Activity, GitPullRequest, Terminal, + Wrench, } from 'lucide-react'; import { PanelContentType, PanelContentConfig } from './types'; @@ -233,6 +234,14 @@ export const PANEL_CONTENT_CONFIGS: Record supportsDownload: false, showHeader: false }, + 'issue-fix': { + type: 'issue-fix', + displayName: 'Fix Issues', + icon: Wrench, + supportsCopy: false, + supportsDownload: false, + showHeader: false + }, 'terminal': { type: 'terminal', displayName: 'Terminal', diff --git a/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.scss b/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.scss new file mode 100644 index 0000000000..0eb3325df2 --- /dev/null +++ b/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.scss @@ -0,0 +1,931 @@ +@use "../../../../component-library/styles/tokens" as *; + +.issue-fix { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + color: var(--color-text-primary); + background: var(--color-bg-primary); + font-size: 12px; + + &--empty { + align-items: center; + justify-content: center; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border-subtle); + } + + &__actions { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + flex: none; + } + + &__options { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__option { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--color-text-secondary); + white-space: nowrap; + } + + &__header-main { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + } + + &__repo { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__progress, + &__list-count { + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + } + + // An open gate is the one state a user must notice, so it gets a persistent + // banner rather than only a per-row icon. + &__gate-notice { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--color-warning) 30%, transparent); + } + + &__notice { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 7px 12px; + border-bottom: 1px solid var(--color-border-subtle); + line-height: 1.45; + + svg { + flex: none; + margin-top: 1px; + } + + span { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + &--warning { + color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); + border-bottom-color: color-mix(in srgb, var(--color-warning) 26%, transparent); + } + + &--active { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 8%, transparent); + } + } + + &__user-question { + flex: none; + margin: 8px 12px; + + .questions-container { + gap: 10px; + } + + .question-item-header { + align-items: flex-start; + } + + .question-text { + overflow-wrap: anywhere; + } + } + + &__user-question-reason { + width: 100%; + + .bitfun-textarea__field { + min-height: 48px; + max-height: 96px; + resize: vertical; + } + } + + &__gate-notice--stopped { + color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 10%, transparent); + border-bottom-color: color-mix( + in srgb, + var(--color-danger) 28%, + transparent + ); + } + + &__gate-message { + flex: 1 1 auto; + min-width: 180px; + } + + &__gate-actions, + &__decision-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; + } + + &__run-status { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + } + + &__run-status--running { + color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + border-bottom-color: color-mix( + in srgb, + var(--color-primary) 22%, + transparent + ); + } + + &__run-status--submitted { + color: var(--color-primary); + } + + &__run-status--completed { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 8%, transparent); + border-bottom-color: color-mix( + in srgb, + var(--color-success) 22%, + transparent + ); + } + + &__run-status--failed { + color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 8%, transparent); + border-bottom-color: color-mix( + in srgb, + var(--color-danger) 22%, + transparent + ); + } + + &__run-status-icon { + flex: none; + margin-top: 2px; + } + + &__run-status--running &__run-status-icon { + animation: issue-fix-spin 1s linear infinite; + } + + &__run-status-content { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + min-width: 180px; + color: var(--color-text-secondary); + line-height: 1.4; + + strong { + color: var(--color-text-primary); + font-size: 12px; + font-weight: 600; + } + } + + &__run-status-meta { + color: var(--color-text-tertiary); + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; + } + + &__run-status-error { + color: var(--color-danger); + overflow-wrap: anywhere; + } + + &__run-status-actions { + display: flex; + align-items: center; + flex: none; + flex-wrap: wrap; + gap: 4px; + margin-left: auto; + } + + &__body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-height: 0; + flex: 1; + overflow: hidden; + + @container (max-width: 560px) { + grid-template-columns: minmax(0, 1fr); + } + } + + &__list { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--color-border-subtle); + overflow: hidden; + } + + &__list-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--color-border-subtle); + } + + &__rows { + margin: 0; + padding: 4px 0; + list-style: none; + overflow-y: auto; + min-height: 0; + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 12px; + + &:hover { + background: var(--element-bg-soft); + } + + &.is-selected { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + } + + &--done { + color: var(--color-text-tertiary); + } + + &--blocked { + color: var(--color-warning); + } + } + + &__row-button { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + padding: 4px 0; + color: inherit; + background: none; + border: none; + text-align: left; + cursor: pointer; + font: inherit; + } + + &__row-number { + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + } + + &__row-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__row-status { + color: var(--color-text-tertiary); + white-space: nowrap; + } + + &__row-icon { + flex: none; + + &--idle { + opacity: 0.3; + } + + &--queued { + opacity: 0.6; + } + + &--fixing { + animation: issue-fix-spin 1s linear infinite; + color: var(--color-primary); + } + + &--done { + color: var(--color-success); + } + + &--blocked { + color: var(--color-warning); + } + } + + &__detail { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + overflow: hidden; + min-height: 0; + height: 100%; + } + + &__detail-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; + flex: none; + } + + &__detail-title { + flex: 1; + min-width: 0; + margin: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + } + + &__detail-facts { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 8px; + margin: 0; + flex: none; + + dt { + color: var(--color-text-tertiary); + } + + dd { + margin: 0; + } + } + + &__detail-scroll { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-height: 0; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; + } + + &__detail-section { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + min-height: 0; + flex: none; + padding-top: 8px; + border-top: 1px solid var(--color-border-subtle); + } + + &__detail-section--body { + flex: 0 1 auto; + } + + &__detail-section--comments { + padding-bottom: 0; + } + + &__detail-section--compact { + flex: none; + } + + &__section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; + + h4 { + margin: 0; + font-size: 12px; + font-weight: 600; + } + } + + &__section-meta { + color: var(--color-text-tertiary); + white-space: nowrap; + } + + &__comments { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + &__comment { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 70%, transparent); + + &:last-child { + padding-bottom: 8px; + } + } + + &__comment-header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + font-size: 11px; + line-height: 1.35; + } + + &__comment-author { + min-width: 0; + overflow: hidden; + color: var(--color-text-primary); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__comment-time { + color: var(--color-text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + &__comment-link { + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; + + &:hover { + color: var(--color-primary-hover, var(--color-primary)); + } + } + + &__markdown { + --markdown-font-size: 12px; + --markdown-font-size-sm: 11px; + --markdown-code-font-size: 11px; + --markdown-line-height: 1.5; + --markdown-support-line-height: 1.35; + --markdown-block-gap: 8px; + --markdown-paragraph-gap: 6px; + --markdown-list-item-gap: 3px; + --markdown-list-nested-gap: 4px; + --markdown-heading-gap-before: 10px; + --markdown-heading-gap-after: 4px; + --markdown-code-block-gap: 8px; + --markdown-code-block-pad-y: 8px; + --markdown-code-block-pad-x: 10px; + + color: var(--color-text-secondary); + animation: none; + cursor: auto; + + p, + li { + color: var(--color-text-secondary); + } + + h1, + h2, + h3, + h4, + h5, + h6 { + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.35; + } + + blockquote, + .custom-blockquote { + margin: 6px 0; + padding: 6px 8px; + border-radius: 0 5px 5px 0; + } + + img, + .markdown-image { + display: block; + max-height: 180px; + margin: 6px 0; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: var(--element-bg-soft); + object-fit: contain; + } + + pre, + .code-block-wrapper { + max-height: 220px; + overflow: auto; + } + + .table-wrapper { + margin: 6px 0; + } + } + + &__markdown--body { + padding: 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + + &__markdown--comment { + --markdown-block-gap: 6px; + --markdown-paragraph-gap: 4px; + --markdown-heading-gap-before: 6px; + + img, + .markdown-image { + max-height: 140px; + } + + pre, + .code-block-wrapper { + max-height: 160px; + } + } + + &__labels { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + } + + &__label { + padding: 1px 6px; + border: 1px solid var(--color-border-subtle); + border-radius: 10px; + color: var(--color-text-secondary); + } + + // LoopX's reason codes, shown verbatim so a declined fix explains itself in + // LoopX's own vocabulary rather than a paraphrase. + &__decision { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + background: color-mix(in srgb, var(--element-bg-soft) 72%, transparent); + } + + &__decision-next-step { + display: flex; + flex-direction: column; + gap: 6px; + + h4, + p { + margin: 0; + } + + h4 { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); + } + + p { + color: var(--color-text-secondary); + line-height: 1.45; + } + } + + &__decision-actions { + justify-content: flex-start; + margin-left: 0; + } + + &__reasons { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; + } + + &__reason { + padding: 1px 6px; + border-radius: 4px; + background: var(--element-bg-soft); + color: var(--color-text-secondary); + font-family: var(--font-mono); + font-size: 11px; + } + + &__error { + margin: 0; + color: var(--color-danger); + + &--banner { + padding: 7px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--color-danger) 24%, transparent); + background: color-mix(in srgb, var(--color-danger) 8%, transparent); + overflow-wrap: anywhere; + } + } + + &__spin { + animation: issue-fix-spin 1s linear infinite; + } + + &__loading, + &__empty-text { + margin: 0; + padding: 12px; + color: var(--color-text-tertiary); + + &--inline { + padding: 0; + } + } + + &__limitations { + margin: 0; + padding-left: 16px; + color: var(--color-text-tertiary); + overflow-wrap: anywhere; + } + + &__detail-link { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + max-width: 40%; + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; + + &:hover { + color: var(--color-primary-hover, var(--color-primary)); + } + + span { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +@keyframes issue-fix-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +// Read-only projection of open user-lane todos (gates + review/merge actions). +.issue-fix__user-todos { + margin: 0 12px 8px; + padding: 8px 10px; + border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.25)); + border-radius: 8px; + background: var(--bg-secondary, rgba(128, 128, 128, 0.05)); +} + +.issue-fix__user-todos-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 0 0 6px; +} + +.issue-fix__user-todos-title { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #6b7280); +} + +.issue-fix__user-todos-history { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid var(--border-primary, rgba(128, 128, 128, 0.25)); + border-radius: 999px; + background: transparent; + color: var(--text-tertiary, #8a8f98); + font-size: 11px; + line-height: 16px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; + + &:hover { + color: var(--text-accent, #2563eb); + border-color: color-mix(in srgb, var(--text-accent, #2563eb) 40%, transparent); + background: color-mix(in srgb, var(--text-accent, #2563eb) 8%, transparent); + } +} + +.issue-fix__user-todos-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow-y: auto; +} + +.issue-fix__user-todo { + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; + font-size: 12px; + line-height: 18px; +} + +.issue-fix__user-todo-badge { + flex: none; + padding: 0 6px; + border-radius: 999px; + font-size: 10px; + line-height: 16px; + + &--gate { + color: var(--color-warning, #b45309); + background: var(--color-warning-bg, rgba(180, 83, 9, 0.12)); + } + + &--action { + color: var(--color-info, #1d4ed8); + background: var(--color-info-bg, rgba(29, 78, 216, 0.1)); + } +} + +.issue-fix__user-todo-text { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--text-primary, #374151); +} + +.issue-fix__user-todo-link { + flex: none; + display: inline-flex; + align-items: center; + margin-top: 1px; + color: var(--text-tertiary, #8a8f98); + + &:hover { + color: var(--text-accent, #2563eb); + } +} + +// Compact two-line body (action + why) shared by the pending block and the +// app-wide toast so the wording stays identical across surfaces. Rendered +// inside the notification center and toast message slots too, so it must not +// assume any issue-fix-specific token; it inherits the host text color. +.issue-fix__todo-message { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.issue-fix__todo-message-action { + font-weight: 500; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.issue-fix__todo-message-context { + font-size: 11px; + line-height: 1.35; + color: inherit; + opacity: 0.72; + overflow-wrap: anywhere; +} + +// Header chip: pending-count entry into the notification center history. +.issue-fix__pending-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-warning) 10%, transparent); + color: var(--color-warning); + font: inherit; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + } +} + +// GitHub-only notice: full-panel empty state for unsupported code hosts. +.issue-fix__unsupported { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + max-width: 380px; + padding: 24px; + text-align: center; + color: var(--color-text-secondary); + + svg { + color: var(--color-text-tertiary); + } + + h3 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + } + + p { + margin: 0; + font-size: 12px; + line-height: 1.6; + } +} diff --git a/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx b/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx new file mode 100644 index 0000000000..21bc8deeae --- /dev/null +++ b/src/web-ui/src/app/components/panels/issue-fix/IssueFixPanel.tsx @@ -0,0 +1,1024 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AlertTriangle, + CheckCircle, + Circle, + ExternalLink, + Github, + History, + Loader2, + MessageSquare, + Play, + RefreshCw, + Square, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Checkbox, MarkdownRenderer } from '@/component-library'; +import { + agentAPI, + issueFixAPI, + reviewPlatformAPI, + type IssueFixAvailability, + type IssueFixAutonomousStatusResponse, + type IssueFixUserDecision, + type IssueFixUserTodo, + type ReviewPlatformIssueEvidence, + type ReviewPlatformIssueSummary, + type ReviewPlatformKind, +} from '@/infrastructure/api'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { i18nService } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import { createIssueFixTab } from '@/shared/utils/tabUtils'; +import { createLogger } from '@/shared/utils/logger'; +import { + emptyRunState, + mergeLightState, + pruneSelection, + rowLocked, + rowState, + rowStatusKey, + runProgress, + selectAllState, + setAllSelected, + toggleSelection, + userTodoDisplayText, + type IssueFixRowState, +} from './issueFixRunState'; +import { IssueFixTodoMessage } from './IssueFixTodoMessage'; +import { IssueFixUserQuestion } from './IssueFixUserQuestion'; +import './IssueFixPanel.scss'; + +const log = createLogger('IssueFixPanel'); + +export interface IssueFixPanelProps { + workspacePath?: string; + projectPath?: string; + host?: string; +} + +interface IssueMarkdownProps { + content: string; + emptyText: string; + variant: 'body' | 'comment'; + basePath?: string; +} + +const IssueMarkdown: React.FC = ({ + content, + emptyText, + variant, + basePath, +}) => { + const markdown = content.trim(); + if (!markdown) { + return

    {emptyText}

    ; + } + return ( + + ); +}; + +const ROW_ICONS: Record = { + idle: , + queued: , + fixing: , + done: , + blocked: ( + + ), +}; + +function formatIssueTime(value: string | null | undefined): string { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return i18nService.formatDate(date, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function requestId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export const IssueFixPanel: React.FC = ({ + workspacePath, + projectPath: projectPathProp, + host: hostProp, +}) => { + const { t } = useTranslation('panels/issue-fix'); + const [issues, setIssues] = useState([]); + const [selection, setSelection] = useState(emptyRunState); + const [control, setControl] = useState(null); + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [available, setAvailable] = useState(null); + // Full readiness picture: loopx presence is `available`; the gh tiers gate + // guidance banners (the agent's whole evidence/PR channel runs through gh). + const [readiness, setReadiness] = useState(null); + // True once the control probe answered (even with "not connected"), so the + // header can distinguish loading from pre-bootstrap. + const [controlProbed, setControlProbed] = useState(false); + const [loading, setLoading] = useState(false); + const [running, setRunning] = useState(false); + const [stopping, setStopping] = useState(false); + const [loadError, setLoadError] = useState(null); + const [controlError, setControlError] = useState(null); + const [answeringQuestion, setAnsweringQuestion] = useState(false); + const [questionError, setQuestionError] = useState(null); + // Monotonic ticket so a slow response can never overwrite a newer one + // (e.g. a stale refresh resurrecting a gate the user already answered). + const controlTicketRef = useRef(0); + const appliedControlTicketRef = useRef(0); + // Remembers a host session created by a failed start, so retries do not + // orphan one new session per attempt. Scoped to the current workspace. + const createdSessionIdRef = useRef(null); + // While a mutation (start/stop/answer) is in flight, background polls are + // paused: a poll issued mid-mutation would read pre-mutation state yet win + // the ticket order, resurrecting what the mutation just changed. + const mutationDepthRef = useRef(0); + const mountedRef = useRef(true); + + useEffect(() => { + createdSessionIdRef.current = null; + }, [workspacePath]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const takeControlTicket = useCallback(() => ++controlTicketRef.current, []); + + const applyControl = useCallback( + ( + ticket: number, + update: (current: IssueFixAutonomousStatusResponse | null) => IssueFixAutonomousStatusResponse | null, + ): boolean => { + if (ticket < appliedControlTicketRef.current) return false; + appliedControlTicketRef.current = ticket; + setControl(update); + return true; + }, + [], + ); + const [issueEvidenceById, setIssueEvidenceById] = useState< + Record + >({}); + const [issueEvidenceErrors, setIssueEvidenceErrors] = useState>({}); + const [issueEvidenceLoadingId, setIssueEvidenceLoadingId] = useState(null); + const [resolved, setResolved] = useState<{ + projectPath: string; + host: string; + platform: ReviewPlatformKind; + } | null>( + projectPathProp + ? { projectPath: projectPathProp, host: hostProp ?? 'github.com', platform: 'github' } + : null, + ); + // 'pending' while the remote probe runs; 'none' when the workspace has no + // recognizable remote. Continuous fixing is GitHub-only for now, so anything + // else renders an honest unsupported state instead of firing GitHub calls. + const [remoteProbe, setRemoteProbe] = useState<'pending' | 'resolved' | 'none'>( + projectPathProp ? 'resolved' : 'pending', + ); + + useEffect(() => { + if (projectPathProp || !workspacePath) return; + let cancelled = false; + void (async () => { + try { + const snapshot = await reviewPlatformAPI.getWorkspaceSnapshot(workspacePath, null, 1, 1); + const remote = + snapshot.remotes.find((candidate) => candidate.id === snapshot.selectedRemoteId) ?? + snapshot.remotes[0]; + if (cancelled) return; + if (remote) { + setResolved({ + projectPath: remote.projectPath, + host: remote.host, + platform: remote.platform, + }); + setRemoteProbe('resolved'); + } else { + setRemoteProbe('none'); + } + } catch (error) { + log.error('Failed to resolve the workspace remote', { workspacePath, error }); + if (!cancelled) { + setRemoteProbe('none'); + setLoadError(error instanceof Error ? error.message : String(error)); + } + } + })(); + return () => { + cancelled = true; + }; + }, [projectPathProp, workspacePath]); + + useEffect(() => { + let cancelled = false; + void issueFixAPI.probe().then((result) => { + if (cancelled) return; + setAvailable(result.available); + setReadiness(result); + }); + return () => { + cancelled = true; + }; + }, []); + + const projectPath = resolved?.projectPath; + const host = resolved?.host ?? 'github.com'; + const platform = resolved?.platform ?? 'github'; + // Product scope: continuous Issue-Fix is GitHub-only for now. Every data + // path below (issue enumeration via the GitHub API, the agent's `gh`-based + // evidence flow, LoopX's PR-lifecycle monitors) assumes GitHub; other + // platforms get an explicit unsupported state rather than broken requests. + const platformSupported = platform === 'github'; + const issueIds = useMemo(() => issues.map((issue) => issue.issueId), [issues]); + + const loadIssues = useCallback(async () => { + if (!projectPath || !platformSupported) return; + setLoading(true); + setLoadError(null); + try { + const page = await reviewPlatformAPI.listIssues({ + platform, + host, + projectPath, + state: 'open', + perPage: 50, + repositoryPath: workspacePath ?? null, + }); + setIssues(page.items); + setSelection((current) => + pruneSelection(current, page.items.map((issue) => issue.issueId)), + ); + setSelectedIssueId((current) => current ?? page.items[0]?.issueId ?? null); + } catch (error) { + log.error('Failed to list issues', { projectPath, error }); + setLoadError(error instanceof Error ? error.message : String(error)); + } finally { + setLoading(false); + } + }, [host, platform, platformSupported, projectPath, workspacePath]); + + const loadControl = useCallback(async () => { + if (!workspacePath || available !== true) return; + setControlError(null); + const ticket = takeControlTicket(); + try { + const status = await issueFixAPI.getAutonomousStatus(workspacePath); + // Null = repository not connected to LoopX yet (bootstraps on first + // Start); distinguish it from "still loading" for the header text. + applyControl(ticket, () => status); + if (mountedRef.current) setControlProbed(true); + } catch (error) { + log.error('Failed to project continuous Issue-Fix state', { workspacePath, error }); + if (ticket >= appliedControlTicketRef.current && mountedRef.current) { + setControlError(error instanceof Error ? error.message : String(error)); + } + } + }, [applyControl, available, takeControlTicket, workspacePath]); + + useEffect(() => { + void loadIssues(); + }, [loadIssues]); + + useEffect(() => { + if (available === true) void loadControl(); + }, [available, loadControl]); + + // While the host loop is enabled the panel must notice state LoopX changes + // between beats — above all a user gate opening mid-run, which blocks all + // progress until answered. The poll endpoint reads only the todo list (no + // `quota should-run`, which writes a rollout event per call); a finished + // beat (activeTurnId dropping) triggers one full quota-backed refresh. + const hostLoopEnabled = control?.hostLoop.enabled ?? false; + useEffect(() => { + if (!hostLoopEnabled || available !== true || !workspacePath) return; + let lastActiveTurnId = control?.hostLoop.activeTurnId ?? null; + let lastNextRunAtMs = control?.hostLoop.nextRunAtMs ?? null; + let cancelled = false; + const tick = async () => { + if (document.hidden || mutationDepthRef.current > 0) return; + const ticket = takeControlTicket(); + try { + const poll = await issueFixAPI.pollAutonomous(workspacePath); + if (cancelled || !poll) return; + // A beat boundary shows up either as the active turn draining or as + // the schedule advancing (which also catches beats shorter than one + // poll interval); each boundary earns one full quota-backed refresh. + const beatFinished = + (lastActiveTurnId !== null && !poll.hostLoop.activeTurnId) || + (lastNextRunAtMs !== null && + poll.hostLoop.nextRunAtMs != null && + poll.hostLoop.nextRunAtMs !== lastNextRunAtMs); + lastActiveTurnId = poll.hostLoop.activeTurnId ?? null; + lastNextRunAtMs = poll.hostLoop.nextRunAtMs ?? null; + applyControl(ticket, (current) => (current ? mergeLightState(current, poll) : current)); + if (beatFinished) void loadControl(); + } catch (error) { + log.warn('Continuous Issue-Fix poll failed', { workspacePath, error }); + } + }; + const interval = window.setInterval(() => void tick(), 30_000); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + // activeTurnId is tracked inside the closure; depending on it would reset the interval each beat. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [applyControl, available, hostLoopEnabled, loadControl, takeControlTicket, workspacePath]); + + // Surface user-lane work (gates + review/merge actions) through the app's + // own notification system so it reaches the user from any panel: the bell + // shows an unread badge, the center keeps a scrollable history, and new + // arrivals toast. The first projection seeds the center silently with the + // currently open items (no toast burst); a resolved item marks its card + // read so the unread badge stays truthful. + const notifiedTodoIdsRef = useRef | null>(null); + const todoNotificationIdsRef = useRef>(new Map()); + useEffect(() => { + if (!control) return; + const openPanelAction = { + label: t('autonomous.notify.open'), + onClick: () => createIssueFixTab({ workspacePath }), + }; + const gate = control.userQuestion; + // A gate is also listed among the open todos; keep one card per todoId. + const openTodos = (control.userTodos ?? []).filter( + (todo) => todo.todoId !== gate?.todoId, + ); + const linkAction = (todo: IssueFixUserTodo) => + todo.link + ? [ + { + label: t('autonomous.notify.openLink'), + onClick: () => { + if (todo.link) window.open(todo.link, '_blank', 'noreferrer'); + }, + }, + ] + : []; + + if (!notifiedTodoIdsRef.current) { + notifiedTodoIdsRef.current = new Set(); + for (const todo of openTodos) { + notifiedTodoIdsRef.current.add(todo.todoId); + todoNotificationIdsRef.current.set( + todo.todoId, + notificationService.silent({ + title: t('autonomous.notify.actionTitle'), + message: userTodoDisplayText(todo), + messageNode: , + type: 'info', + metadata: { + source: 'issue-fix', + todoId: todo.todoId, + taskClass: todo.taskClass, + link: todo.link, + }, + actions: [openPanelAction, ...linkAction(todo)], + }), + ); + } + if (gate) { + notifiedTodoIdsRef.current.add(gate.todoId); + todoNotificationIdsRef.current.set( + gate.todoId, + notificationService.silent({ + title: t('autonomous.notify.gateTitle'), + message: gate.prompt, + type: 'warning', + metadata: { source: 'issue-fix', todoId: gate.todoId }, + actions: [openPanelAction], + }), + ); + } + return; + } + + const seen = notifiedTodoIdsRef.current; + // Items that left the user lane were resolved or answered: they stop + // demanding attention but stay in the center history for review. + for (const todoId of [...seen]) { + if (openTodos.some((todo) => todo.todoId === todoId)) continue; + if (gate?.todoId === todoId) continue; + seen.delete(todoId); + const notificationId = todoNotificationIdsRef.current.get(todoId); + if (notificationId) { + notificationService.markAsRead(notificationId); + todoNotificationIdsRef.current.delete(todoId); + } + } + + if (gate && !seen.has(gate.todoId)) { + seen.add(gate.todoId); + todoNotificationIdsRef.current.set( + gate.todoId, + notificationService.warning(gate.prompt, { + title: t('autonomous.notify.gateTitle'), + duration: 12_000, + metadata: { source: 'issue-fix', todoId: gate.todoId }, + actions: [openPanelAction], + }), + ); + } + + const fresh = openTodos.filter((todo) => !seen.has(todo.todoId)); + fresh.forEach((todo) => seen.add(todo.todoId)); + if (fresh.length > 3) { + // One digest card instead of a burst of toasts after a productive beat. + notificationService.info(t('autonomous.notify.actionBatch', { count: fresh.length }), { + title: t('autonomous.notify.actionTitle'), + metadata: { source: 'issue-fix' }, + actions: [openPanelAction], + }); + } else { + for (const todo of fresh) { + // Brief two-line body (action + why) keeps the toast scannable; the + // plain `message` stays the full text so the notification center + // search can still find it after the toast fades. + todoNotificationIdsRef.current.set( + todo.todoId, + notificationService.info(userTodoDisplayText(todo), { + title: t('autonomous.notify.actionTitle'), + messageNode: , + duration: 6_000, + metadata: { + source: 'issue-fix', + todoId: todo.todoId, + taskClass: todo.taskClass, + link: todo.link, + }, + actions: [openPanelAction, ...linkAction(todo)], + }), + ); + } + } + }, [control, t, workspacePath]); + + const refresh = useCallback(async () => { + await Promise.all([loadIssues(), loadControl()]); + }, [loadControl, loadIssues]); + + const detail = useMemo( + () => issues.find((issue) => issue.issueId === selectedIssueId) ?? null, + [issues, selectedIssueId], + ); + const detailEvidence = detail ? issueEvidenceById[detail.issueId] : undefined; + const detailEvidenceLoading = detail ? issueEvidenceLoadingId === detail.issueId : false; + const detailEvidenceError = detail ? issueEvidenceErrors[detail.issueId] : undefined; + + useEffect(() => { + if (!detail || !projectPath || issueEvidenceById[detail.issueId]) return; + let cancelled = false; + const issueId = detail.issueId; + setIssueEvidenceLoadingId(issueId); + setIssueEvidenceErrors((current) => ({ ...current, [issueId]: '' })); + void (async () => { + try { + const evidence = await reviewPlatformAPI.getIssue({ + platform, + host, + projectPath, + issueId, + page: 1, + perPage: 20, + repositoryPath: workspacePath ?? null, + }); + if (!cancelled) { + setIssueEvidenceById((current) => ({ ...current, [issueId]: evidence })); + } + } catch (error) { + log.error('Failed to load issue evidence', { issueId, error }); + if (!cancelled) { + setIssueEvidenceErrors((current) => ({ + ...current, + [issueId]: error instanceof Error ? error.message : String(error), + })); + } + } finally { + if (!cancelled) { + setIssueEvidenceLoadingId((current) => (current === issueId ? null : current)); + } + } + })(); + return () => { + cancelled = true; + }; + }, [detail, host, issueEvidenceById, platform, projectPath, workspacePath]); + + const allState = useMemo( + () => selectAllState(selection, control, issueIds), + [control, issueIds, selection], + ); + const progress = useMemo( + () => runProgress(selection, control, issueIds), + [control, issueIds, selection], + ); + + const handleToggleAll = useCallback(() => { + setSelection((current) => setAllSelected(current, control, issueIds, allState !== 'all')); + }, [allState, control, issueIds]); + + const ensureHostSession = useCallback(async (): Promise => { + // Prefer whichever known session actually exists in the store: the loop's + // bound session may have been deleted while a locally created one (from a + // failed start) is still valid. + const { sessions } = flowChatStore.getState(); + for (const candidate of [control?.hostLoop.sessionId, createdSessionIdRef.current]) { + if (candidate && sessions.has(candidate)) return candidate; + } + if (!workspacePath) throw new Error(t('autonomous.missingWorkspace')); + + const activeSession = flowChatStore.getActiveSession(); + const agentType = activeSession?.mode ?? activeSession?.config.agentType ?? 'agentic'; + const modelName = activeSession?.config.modelName ?? 'default'; + const executionTargetRequest = { kind: 'local' as const }; + const response = await agentAPI.createSession({ + sessionName: t('autonomous.sessionTitle'), + agentType, + workspacePath, + projectWorkspacePath: workspacePath, + workspaceId: activeSession?.workspaceId, + executionTarget: executionTargetRequest, + requestId: requestId(), + config: { + modelName, + enableTools: true, + safeMode: true, + autoCompact: true, + enableContextCompression: true, + }, + }); + const sessionWorkspacePath = response.workspacePath ?? workspacePath; + const resolvedAgentType = response.agentType || agentType; + flowChatStore.createSession( + response.sessionId, + { + modelName, + agentType: resolvedAgentType, + workspacePath: sessionWorkspacePath, + projectWorkspacePath: response.projectWorkspacePath ?? workspacePath, + workspaceId: response.workspaceId ?? activeSession?.workspaceId, + executionTargetRequest, + executionTarget: response.executionTarget, + }, + undefined, + response.sessionName || t('autonomous.sessionTitle'), + activeSession?.maxContextTokens, + resolvedAgentType, + sessionWorkspacePath, + ); + createdSessionIdRef.current = response.sessionId; + return response.sessionId; + }, [control?.hostLoop.sessionId, t, workspacePath]); + + const handleStart = useCallback(async () => { + if (!projectPath || !workspacePath || selection.selectedIssueIds.size === 0) return; + setRunning(true); + setControlError(null); + mutationDepthRef.current += 1; + const ticket = takeControlTicket(); + try { + const sessionId = await ensureHostSession(); + const selectedIssues = issues.filter((issue) => selection.selectedIssueIds.has(issue.issueId)); + const started = await issueFixAPI.startAutonomous({ + sessionId, + repo: projectPath, + repositoryPath: workspacePath, + issues: selectedIssues.map((issue) => ({ + issueRef: issue.issueId, + issueUrl: issue.webUrl, + })), + }); + createdSessionIdRef.current = null; + if (applyControl(ticket, () => started)) { + setSelection(emptyRunState()); + if (mountedRef.current) flowChatStore.switchSession(sessionId); + } else { + // A newer response outranked this start; re-sync from the backend so + // the panel cannot show a state the host loop no longer has. + void loadControl(); + } + } catch (error) { + log.error('Failed to start continuous Issue-Fix', { projectPath, error }); + if (mountedRef.current) { + setControlError(error instanceof Error ? error.message : String(error)); + } + } finally { + mutationDepthRef.current -= 1; + if (mountedRef.current) setRunning(false); + } + }, [ + applyControl, + ensureHostSession, + issues, + loadControl, + projectPath, + selection.selectedIssueIds, + takeControlTicket, + workspacePath, + ]); + + const handleStop = useCallback(async () => { + if (!workspacePath) return; + setStopping(true); + setControlError(null); + mutationDepthRef.current += 1; + const ticket = takeControlTicket(); + try { + const hostLoop = await issueFixAPI.stopAutonomous(workspacePath); + if (!applyControl(ticket, (current) => (current ? { ...current, hostLoop } : current))) { + void loadControl(); + } + } catch (error) { + log.error('Failed to stop continuous Issue-Fix', { workspacePath, error }); + if (mountedRef.current) { + setControlError(error instanceof Error ? error.message : String(error)); + } + } finally { + mutationDepthRef.current -= 1; + if (mountedRef.current) setStopping(false); + } + }, [applyControl, loadControl, takeControlTicket, workspacePath]); + + const openHostSession = useCallback(() => { + if (control?.hostLoop.sessionId) flowChatStore.switchSession(control.hostLoop.sessionId); + }, [control?.hostLoop.sessionId]); + + const handleUserQuestion = useCallback(async ( + decision: IssueFixUserDecision, + reason: string, + ) => { + const question = control?.userQuestion; + if (!workspacePath || !question) return; + setAnsweringQuestion(true); + setQuestionError(null); + mutationDepthRef.current += 1; + const ticket = takeControlTicket(); + try { + const answered = await issueFixAPI.answerUserQuestion({ + repositoryPath: workspacePath, + todoId: question.todoId, + decision, + reason: reason || null, + }); + applyControl(ticket, () => answered); + } catch (error) { + log.error('Failed to answer continuous Issue-Fix user question', { + todoId: question.todoId, + decision, + error, + }); + if (mountedRef.current) { + setQuestionError(error instanceof Error ? error.message : String(error)); + } + // The gate may already be closed on the LoopX side (answered elsewhere, + // superseded); re-project Kernel truth so a dead card cannot stick. + void loadControl(); + } finally { + mutationDepthRef.current -= 1; + if (mountedRef.current) setAnsweringQuestion(false); + } + }, [applyControl, control?.userQuestion, loadControl, takeControlTicket, workspacePath]); + + if (remoteProbe === 'pending') { + return ( +
    +

    {t('probingRemote')}

    +
    + ); + } + + if (!projectPath) { + return ( +
    +

    {t('noRepository')}

    +
    + ); + } + + if (!platformSupported) { + // An honest empty state beats a broken panel: the whole pipeline (issue + // enumeration, gh-based evidence, LoopX PR monitors) is GitHub-only today. + return ( +
    +
    +
    +
    + ); + } + + return ( +
    +
    +
    + {projectPath} + + {control + ? t('autonomous.kernelSummary', { + goal: control.goalId, + state: control.kernelState, + queued: progress.queued, + }) + : controlProbed + ? t('autonomous.notConnected') + : t('autonomous.loadingKernel')} + + {control && (control.userTodos ?? []).length > 0 ? ( + + ) : null} +
    +
    + {control?.hostLoop.sessionId ? ( + + ) : null} + {control?.hostLoop.enabled ? ( + + ) : null} + + +
    +
    + + {available === false ? ( +
    + + {t('readiness.loopxMissing')} +
    + ) : readiness && !readiness.ghInstalled ? ( +
    + + {t('readiness.ghMissing')} +
    + ) : readiness && !readiness.ghAuthenticated ? ( +
    + + {t('readiness.ghUnauthenticated')} +
    + ) : null} + {control?.hostLoop.lastError && + ((control.hostLoop.consecutiveFailures ?? 0) > 0 || + (!control.hostLoop.enabled && control.hostLoop.lastRunStatus === 'error')) ? ( +
    + + {t('autonomous.hostLoopFailure', { error: control.hostLoop.lastError })} +
    + ) : null} + {control?.userQuestion ? ( + void handleUserQuestion(decision, reason)} + /> + ) : control?.actionRequired ? ( +
    + + {control.gatePrompt ?? control.recommendedAction ?? t('autonomous.actionRequired')} +
    + ) : control?.hostLoop.enabled ? ( +
    + + {t('autonomous.hostLoopActive', { agent: control.agentId })} +
    + ) : null} + {controlError ?

    {controlError}

    : null} + +
    +
    +
    + + + {t('selectedCount', { + selected: selection.selectedIssueIds.size, + total: progress.total, + })} + +
    + + {loadError ? ( +

    {loadError}

    + ) : loading && issues.length === 0 ? ( +

    {t('loading')}

    + ) : issues.length === 0 ? ( +

    {t('noIssues')}

    + ) : ( +
      + {issues.map((issue) => { + const state = rowState(selection, control, issue.issueId); + const locked = rowLocked(control, issue.issueId); + const statusKey = rowStatusKey(state); + return ( +
    • + + setSelection((current) => toggleSelection(current, control, issue.issueId)) + } + size="small" + /> + +
    • + ); + })} +
    + )} +
    + +
    + {detail ? ( + <> +
    +

    + #{detail.number} {detail.title} +

    + + {t('detail.openOnProvider')} + +
    +
    +
    +
    {t('detail.state')}
    +
    {detail.state}
    + {detail.author ? ( + <> +
    {t('detail.author')}
    +
    {detail.author}
    + + ) : null} +
    {t('detail.comments')}
    +
    {detail.commentsCount}
    + {detail.createdAt ? ( + <> +
    {t('detail.created')}
    +
    {formatIssueTime(detail.createdAt) || detail.createdAt}
    + + ) : null} + {detail.updatedAt ? ( + <> +
    {t('detail.updated')}
    +
    {formatIssueTime(detail.updatedAt) || detail.updatedAt}
    + + ) : null} +
    + {detail.labels.length > 0 ? ( +
      + {detail.labels.map((label) => ( +
    • {label}
    • + ))} +
    + ) : null} + +
    +
    +

    {t('detail.body')}

    + {detailEvidenceLoading ? {t('detail.loadingEvidence')} : null} +
    + {detailEvidenceError ? ( +

    {detailEvidenceError}

    + ) : ( + + )} +
    + +
    +
    +

    {t('detail.commentsTitle', { count: detail.commentsCount })}

    +
    + {detailEvidence?.comments.length ? ( +
      + {detailEvidence.comments.map((comment) => ( +
    1. +
      + + {comment.author || t('detail.unknownAuthor')} + + + {formatIssueTime(comment.createdAt) || comment.createdAt} + +
      + +
    2. + ))} +
    + ) : ( +

    + {detailEvidenceLoading ? t('detail.loadingEvidence') : t('detail.noComments')} +

    + )} +
    +
    + + ) : ( +

    {t('noSelection')}

    + )} +
    +
    +
    + ); +}; + +export default IssueFixPanel; diff --git a/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.test.tsx b/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.test.tsx new file mode 100644 index 0000000000..c5eeadc3a8 --- /dev/null +++ b/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { IssueFixUserTodo } from '@/infrastructure/api'; +import { IssueFixTodoMessage } from './IssueFixTodoMessage'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +// The component resolves i18n keys through react-i18next; without a loaded +// backend the raw key comes back, which is exactly what these tests assert — +// the point is which key gets chosen and what interpolation it receives. +const todo = (overrides: Partial = {}): IssueFixUserTodo => ({ + todoId: 'todo-1', + taskClass: 'user_action', + text: + '[P0] Authorize posting maintainer diagnosis comment for GCWing/BitFun#2032 (comment_only route: diagnosis drafted)', + link: null, + ...overrides, +}); + +describe('IssueFixTodoMessage', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('recognizes a comment authorization and renders the localized action line', () => { + act(() => { + root.render(); + }); + + const action = container.querySelector('.issue-fix__todo-message-action'); + const context = container.querySelector('.issue-fix__todo-message-context'); + + expect(action?.textContent).toBe('autonomous.actionLine.postComment'); + expect(context?.textContent).toBe('Comment_only route: diagnosis drafted'); + }); + + it('recognizes an issue-close authorization and omits the missing context line', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('.issue-fix__todo-message-action')?.textContent).toBe( + 'autonomous.actionLine.closeIssue', + ); + expect(container.querySelector('.issue-fix__todo-message-context')).toBeNull(); + }); + + it('falls back to the compact free-form action when no shape matches', () => { + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('.issue-fix__todo-message-action')?.textContent).toBe( + 'Rotating the deploy credentials', + ); + expect(container.querySelector('.issue-fix__todo-message-context')?.textContent).toBe( + 'Expired yesterday', + ); + }); + + it('preserves the full context line on a long drafted response', () => { + act(() => { + root.render( + , + ); + }); + + const context = container.querySelector('.issue-fix__todo-message-context'); + // Full context is preserved; wrapping is the display layer's job. + expect(context!.textContent).toContain('drafted response includes every support channel'); + }); +}); diff --git a/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.tsx b/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.tsx new file mode 100644 index 0000000000..30ad6e7182 --- /dev/null +++ b/src/web-ui/src/app/components/panels/issue-fix/IssueFixTodoMessage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { IssueFixUserTodo } from '@/infrastructure/api'; +import { userTodoPresentation } from './issueFixRunState'; + +interface IssueFixTodoMessageProps { + todo: IssueFixUserTodo; +} + +/** + * Compact two-line rendering of a pending user-lane todo. The first line says + * what already happened and what is asked of the user, phrased in the UI + * language when the todo matches a known action shape (merge PR / close issue + * / post comment); the second line carries the agent-supplied state/reason. + * Shared by the app-wide toast and the notification center so the wording + * cannot drift between surfaces. + */ +export const IssueFixTodoMessage: React.FC = ({ todo }) => { + const { t } = useTranslation('panels/issue-fix'); + const presentation = userTodoPresentation(todo); + const kind = presentation.kind; + const action = + kind?.type === 'mergePr' + ? kind.issue + ? t('autonomous.actionLine.mergePrForIssue', { pr: kind.pr, issue: kind.issue }) + : t('autonomous.actionLine.mergePr', { pr: kind.pr }) + : kind?.type === 'closeIssue' + ? kind.pr + ? t('autonomous.actionLine.closeIssueByPr', { issue: kind.issue, pr: kind.pr }) + : t('autonomous.actionLine.closeIssue', { issue: kind.issue }) + : kind?.type === 'postComment' + ? t('autonomous.actionLine.postComment', { issue: kind.issue }) + : presentation.action; + return ( + + {action} + {presentation.context ? ( + {presentation.context} + ) : null} + + ); +}; diff --git a/src/web-ui/src/app/components/panels/issue-fix/IssueFixUserQuestion.test.tsx b/src/web-ui/src/app/components/panels/issue-fix/IssueFixUserQuestion.test.tsx new file mode 100644 index 0000000000..fe8cd54692 --- /dev/null +++ b/src/web-ui/src/app/components/panels/issue-fix/IssueFixUserQuestion.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ children, isLoading: _isLoading, ...props }: any) => ( + + ), + Textarea: ({ label, className, autoResize: _autoResize, ...props }: any) => ( +